Skip to content

feat(sdk): decryptVersioned and V4-aware crypto.decrypt (MRF envelope support) (2/4)#9596

Draft
kevin9foong wants to merge 9 commits into
feat/sdk-adapt-v4-to-v1from
feat/sdk-decrypt-versioned
Draft

feat(sdk): decryptVersioned and V4-aware crypto.decrypt (MRF envelope support) (2/4)#9596
kevin9foong wants to merge 9 commits into
feat/sdk-adapt-v4-to-v1from
feat/sdk-decrypt-versioned

Conversation

@kevin9foong

Copy link
Copy Markdown
Contributor

Problem

Agencies consume FormSG webhooks through the SDK's crypto.decrypt, which only understands V1 content from storage-mode forms. Multirespondent (MRF) webhooks now deliver V4 content wrapped in the MRF envelope (encryptedSubmissionSecretKey on the payload), and the SDK returns null for them, leaving webhook integrators unable to decrypt MRF responses. Closes #9586.

Solution

DecryptParams gains an optional encryptedSubmissionSecretKey. When present, decryption opens the MRF envelope (submission secret key decrypted with the form key, content with the submission key); when absent, content decrypts directly with the form key. The new crypto.decryptVersioned returns the content in its submitted version — DecryptedContent | DecryptedContentV4 | null, discriminated by Array.isArray(result.responses) — with the V4 arm carrying the honestly-populated per-submission secret key. crypto.decrypt keeps its exact signature: it now delegates to decryptVersioned and adapts the V4 arm through adaptV4ToV1, dropping the key. Content version is detected by plaintext shape (array → V1, provenance-carrying record → V4, empty record → empty V4, anything else → null); the version param stays ignored as it always has been. Design is per ADR 0001; the README gains the consumer-contract section "Decrypting Multirespondent (V4) Submissions".

Alternatives considered

  • We considered letting V4-shaped content decrypted without an MRF envelope return a result with submissionSecretKey set to the form secret key, but that hands consumers a key with form-wide blast radius disguised as a per-submission key, so it returns null instead. Making submissionSecretKey optional on the V4 arm was also skipped — it would widen the published DecryptedContentV4 type that cryptoV3.decryptToV4 already returns with the key required.
  • We considered honouring the version field instead of shape detection, but there is no submission-version constant for V4 content (MRF submissions are version 3 whether their plaintext is V3 or V4), and making a long-ignored parameter load-bearing would break integrators who pass junk versions today (per ADR 0001).
  • Verified content is decrypted only after shape validation, matching the legacy decrypt ordering — otherwise a malformed payload with verified content and no signing key would start throwing MissingPublicKeyError where it previously returned null.

Breaking Changes

No - backwards compatible. decrypt's signature and V1 behaviour are unchanged; the pre-existing crypto suite passes unmodified.

Tests

TC1: MRF webhook round-trip

  • Point an MRF form's webhook at a consumer using this SDK build
  • Submit a multirespondent response and pass the payload (including encryptedSubmissionSecretKey) to crypto.decrypt — responses arrive as FormField[]
  • Pass the same payload to crypto.decryptVersioned — V4 structure with submissionSecretKey matching the envelope

TC2: storage-mode regression

  • Submit to a storage-mode form webhook and confirm crypto.decrypt output is unchanged from the previous SDK version

kevin9foong and others added 9 commits June 11, 2026 06:41
)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…9586)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sioned (#9586)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…9586)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ies (#9586)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fied round-trips (#9586)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sions (#9586)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… matching prior decrypt order

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@kevin9foong kevin9foong requested a review from a team as a code owner June 11, 2026 06:59
// An empty record is a valid V4 submission with no answered fields;
// `isFieldResponsesV4` alone cannot claim it since it checks the first entry.
const isV4Record =
Object.keys(decryptedObject).length === 0 ||

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isFieldResponsesV4 returns false for {} (it inspects the first entry), but the PRD defines an empty record as a valid V4 submission with no answered fields. The empty check therefore runs before the first-entry check rather than tightening isFieldResponsesV4, which ADR 0001 says must be reused unchanged.

@kevin9foong kevin9foong marked this pull request as draft June 11, 2026 07:03
@kevin9foong kevin9foong changed the title feat(sdk): decryptVersioned and V4-aware crypto.decrypt (MRF envelope support) feat(sdk): decryptVersioned and V4-aware crypto.decrypt (MRF envelope support) (2/4) Jun 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant