-
Notifications
You must be signed in to change notification settings - Fork 11
feat: add verified content to v3 #121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
bbbd167
6901669
d3c99d1
95819ab
1d64526
3770156
1e5247b
14611d8
52b277b
b95e13c
dea74e6
9af6530
a11a4b2
2ff8cec
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,20 +4,27 @@ import { | |
| ciphertext, | ||
| formPublicKey, | ||
| formSecretKey, | ||
| submissionSecretKey | ||
| submissionSecretKey, | ||
| plainVerifiedText | ||
| } from './resources/crypto-v3-data-20231207' | ||
| import CryptoV3 from '../src/crypto-v3' | ||
| import Crypto from '../src/crypto' | ||
| import { SIGNING_KEYS } from '../src/resource/signing-keys' | ||
|
|
||
| const INTERNAL_TEST_VERSION = 3 | ||
|
|
||
| const testFileBuffer = new Uint8Array(Buffer.from('./resources/ogp.svg')) | ||
|
|
||
| const encryptionPublicKey = SIGNING_KEYS.test.publicKey | ||
| const signingSecretKey = SIGNING_KEYS.test.secretKey | ||
|
|
||
| jest.mock('axios', () => mockAxios) | ||
|
|
||
| describe('CryptoV3', function () { | ||
| afterEach(() => mockAxios.reset()) | ||
|
|
||
| const crypto = new CryptoV3() | ||
| const crypto = new CryptoV3({ signingPublicKey: encryptionPublicKey }) | ||
| const cryptoV1 = new Crypto({ signingPublicKey: encryptionPublicKey }) | ||
|
|
||
| it('should generate a keypair', () => { | ||
| const keypair = crypto.generate() | ||
|
|
@@ -126,4 +133,22 @@ describe('CryptoV3', function () { | |
|
|
||
| expect(decrypted).toBeNull() | ||
| }) | ||
|
|
||
| it('should be able to encrypt and decrypt submissions with verifiedContent from 2023-12-07 end-to-end successfully from the form private key', () => { | ||
| // Arrange | ||
| const { publicKey, secretKey } = crypto.generate() | ||
|
|
||
| // Act | ||
| const ciphertext = crypto.encrypt(plaintext, publicKey) | ||
| const verifiedText = cryptoV1.encrypt(plainVerifiedText, ciphertext.submissionPublicKey, signingSecretKey) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: Could we perhaps leave a comment about the rationale behind why cryptoV1 is used for decrypting verifiedContent here? Perhaps something explaining the below comment (but probably more concise). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question - confirm if this is accurate: For MRF, this verifiedContent is not yet implemented. For singpass with MRF on the FormSG app side, my understanding is that since it is only for 1st step - we can use cryptoV1 to encrypt with the current step's submission public key. However, if we use cryptoV1 for verified content and use the submission steps's latest submission key - would admins still be able to decrypt the verified content with the form private key using
Hence, this change is safe to make. But there likely will need to be explicit logic to do this: in the application code. |
||
| const decrypted = crypto.decrypt(secretKey, { | ||
| ...ciphertext, | ||
| verifiedContent: verifiedText, | ||
| version: INTERNAL_TEST_VERSION, | ||
| }) | ||
| // Assert | ||
| expect(decrypted).toHaveProperty('responses', plaintext) | ||
| expect(decrypted).toHaveProperty('verified', plainVerifiedText) | ||
|
|
||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,9 +5,15 @@ import { | |||||||||||||
| encodeUTF8, | ||||||||||||||
| } from 'tweetnacl-util' | ||||||||||||||
|
|
||||||||||||||
| import { decryptContent, encryptMessage, generateKeypair } from './util/crypto' | ||||||||||||||
| import { | ||||||||||||||
| decryptContent, | ||||||||||||||
| encryptMessage, | ||||||||||||||
| generateKeypair, | ||||||||||||||
| verifySignedMessage, | ||||||||||||||
| } from './util/crypto' | ||||||||||||||
| import { determineIsFormFieldsV3 } from './util/validate' | ||||||||||||||
| import CryptoBase from './crypto-base' | ||||||||||||||
| import { MissingPublicKeyError } from './errors' | ||||||||||||||
| import { | ||||||||||||||
| DecryptedContentV3, | ||||||||||||||
| DecryptParams, | ||||||||||||||
|
|
@@ -17,8 +23,11 @@ import { | |||||||||||||
| } from './types' | ||||||||||||||
|
|
||||||||||||||
| export default class CryptoV3 extends CryptoBase { | ||||||||||||||
scottheng96 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||
| constructor() { | ||||||||||||||
| signingPublicKey?: string | ||||||||||||||
|
|
||||||||||||||
| constructor({ signingPublicKey }: { signingPublicKey?: string } = {}) { | ||||||||||||||
| super() | ||||||||||||||
| this.signingPublicKey = signingPublicKey | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
|
|
@@ -62,7 +71,7 @@ export default class CryptoV3 extends CryptoBase { | |||||||||||||
| decryptParams: DecryptParams | ||||||||||||||
| ): DecryptedContentV3 | null => { | ||||||||||||||
| try { | ||||||||||||||
| const { encryptedContent } = decryptParams | ||||||||||||||
| const { encryptedContent, verifiedContent } = decryptParams | ||||||||||||||
scottheng96 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||
|
|
||||||||||||||
| // Do not return the transformed object in `_decrypt` function as a signed | ||||||||||||||
| // object is not encoded in UTF8 and is encoded in Base-64 instead. | ||||||||||||||
|
|
@@ -85,8 +94,47 @@ export default class CryptoV3 extends CryptoBase { | |||||||||||||
| responses: decryptedObject as FormFieldsV3, | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Note on verifiedContent decryption for cryptoV3: | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||
| * Although decryption is supported, verifiedContent encryption is not supported | ||||||||||||||
| * in cryptoV3 encrypt. | ||||||||||||||
| * This is to keep the encryption of verifiedContent and encryptedContent similar to storage mode - where | ||||||||||||||
| * verifiedContent and encryptedContent are defined and encrypted separately. | ||||||||||||||
| */ | ||||||||||||||
| // decrypt verifiedContent if it exists | ||||||||||||||
| if (verifiedContent) { | ||||||||||||||
| if (!this.signingPublicKey) { | ||||||||||||||
| throw new MissingPublicKeyError( | ||||||||||||||
scottheng96 marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||
| 'Public signing key must be provided when instantiating the Crypto class in order to verify verified content' | ||||||||||||||
| ) | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const decryptedVerifiedContent = decryptContent( | ||||||||||||||
| submissionSecretKey, | ||||||||||||||
| verifiedContent | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| if (!decryptedVerifiedContent) { | ||||||||||||||
| // Returns null if decrypting verified content failed. | ||||||||||||||
| throw new Error('Failed to decrypt verified content') | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const decryptedVerifiedObject = verifySignedMessage( | ||||||||||||||
| decryptedVerifiedContent, | ||||||||||||||
| this.signingPublicKey | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| returnedObject.verified = decryptedVerifiedObject | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| return returnedObject | ||||||||||||||
| } catch (err) { | ||||||||||||||
| // Should only throw if MissingPublicKeyError. | ||||||||||||||
| // This library should be able to be used to encrypt and decrypt content | ||||||||||||||
| // if the content does not contain verified fields. | ||||||||||||||
| if (err instanceof MissingPublicKeyError) { | ||||||||||||||
| throw err | ||||||||||||||
| } | ||||||||||||||
| return null | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. question: in The missing public key error is thrown to the client - should we perhaps be doing the same thing for cryptov3? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good idea to keep it consistent between the versions. I'll throw the error from Tbh We don't handle these errors in our code, where we only check for null and return another error else. But that's for later when we update app implementation |
||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -99,24 +147,34 @@ export default class CryptoV3 extends CryptoBase { | |||||||||||||
| * @param decryptParams.encryptedSubmissionSecretKey The encrypted submission secret key encoded with base-64. | ||||||||||||||
| * @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with. | ||||||||||||||
| * @returns The decrypted content if successful. Else, null will be returned. | ||||||||||||||
| * @throws {MissingPublicKeyError} if a public key is not provided when instantiating this class and is needed for verifying signed content. | ||||||||||||||
| */ | ||||||||||||||
| decrypt = ( | ||||||||||||||
| formSecretKey: string, | ||||||||||||||
| decryptParams: DecryptParamsV3 | ||||||||||||||
| ): DecryptedContentV3 | null => { | ||||||||||||||
| const { encryptedSubmissionSecretKey, ...rest } = decryptParams | ||||||||||||||
| try { | ||||||||||||||
| const { encryptedSubmissionSecretKey, ...rest } = decryptParams | ||||||||||||||
|
|
||||||||||||||
| const submissionSecretKey = decryptContent( | ||||||||||||||
| formSecretKey, | ||||||||||||||
| encryptedSubmissionSecretKey | ||||||||||||||
| ) | ||||||||||||||
| const submissionSecretKey = decryptContent( | ||||||||||||||
| formSecretKey, | ||||||||||||||
| encryptedSubmissionSecretKey | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| if (submissionSecretKey === null) return null | ||||||||||||||
| if (submissionSecretKey === null) return null | ||||||||||||||
|
|
||||||||||||||
| return this.decryptFromSubmissionKey( | ||||||||||||||
| encodeBase64(submissionSecretKey), | ||||||||||||||
| rest | ||||||||||||||
| ) | ||||||||||||||
| return this.decryptFromSubmissionKey( | ||||||||||||||
| encodeBase64(submissionSecretKey), | ||||||||||||||
| rest | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| } catch (err) { | ||||||||||||||
| if (err instanceof MissingPublicKeyError) { | ||||||||||||||
| // rethrow to let the caller decide how to handle missing signing key | ||||||||||||||
| throw err | ||||||||||||||
| } | ||||||||||||||
| return null | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
praise: Thanks for identifying and removing this! 🎉 Always happy to remove stuff -> less things to maintain!