diff --git a/CHANGELOG.md b/CHANGELOG.md index da64c3f..bbb2566 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,41 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-06-01 + +### Fixed + +- **ECDH-1PU Concat KDF: length-prefix the content-encryption tag** + (interop; tracked as #322 in affinidi-messaging-didcomm). `cc_tag` was + fed into the Concat KDF as SuppPrivInfo **raw**, without the 32-bit + big-endian length prefix every other OtherInfo field carries. This + matched the then-buggy `affinidi-messaging-didcomm` (the + `roundtrip-rust` vectors were generated against it), so JS↔Rust + authcrypt worked *because both were wrong* — but neither interoperated + with credo-ts / didcomm-python. The tag is now length-prefixed per the + ECDH-1PU draft (Appendix B), making `ECDH-1PU+A256KW` authcrypt + spec-correct. Affects X25519 and P-256; anoncrypt (ECDH-ES) was never + affected. + +### Added + +- **Dual-KEK decrypt fallback.** `unpack` derives the spec-correct KEK + first and, if AES-KW unwrap fails, retries with the legacy (pre-0.5, + unprefixed-tag) KEK — so an upgraded recipient still reads authcrypt + from a not-yet-upgraded peer during migration. The result now carries + `legacyKekUsed` (true when the legacy KEK was used) as a migration + signal. + +### Migration + +This is a **breaking authcrypt wire change**: a 0.5 sender's authcrypt +cannot be decrypted by an un-upgraded ≤ 0.4.x recipient. **Upgrade +recipients before senders** — the dual-KEK fallback makes upgraded +recipients accept both old and new senders. Pair with +`affinidi-messaging-didcomm` ≥ 0.14 (the matching Rust fix). The +`roundtrip-rust` interop vectors should be regenerated against a Rust +helper built from didcomm ≥ 0.14. + ## [0.4.2] - 2026-05-30 ### Added diff --git a/package.json b/package.json index a5a8301..d1cc90d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openvtc/vti-didcomm-js", - "version": "0.4.2", + "version": "0.5.0", "description": "Browser-side DIDComm v2 implementation for the Verifiable Trust Infrastructure. Focused subset: authcrypt/anoncrypt (ECDH-1PU/ECDH-ES + A256CBC-HS512) over X25519 and P-256, did:key + did:peer + did:webvh resolution, VTA REST auth, and ATM mediator transport. Byte-compatible with affinidi-messaging-didcomm.", "type": "module", "main": "src/index.js", diff --git a/src/concat-kdf.js b/src/concat-kdf.js index 328ca12..0c6d9c9 100644 --- a/src/concat-kdf.js +++ b/src/concat-kdf.js @@ -22,9 +22,16 @@ // SuppPrivInfo is normally omitted, but for ECDH-1PU in // Key-Agreement-with-Key-Wrap mode (draft-madden-jose-ecdh-1pu §2.3) // it carries the JWE content-encryption auth tag (`cc_tag`), -// appended RAW (no length prefix — that's the convention shared by -// affinidi-messaging-didcomm, go-jose, jwx). This binds the KEK -// derivation to the ciphertext. +// **length-prefixed** like every other variable-length OtherInfo +// field (`uint32_be(len) || tag`) per the draft's Appendix B test +// vector. This binds the KEK derivation to the ciphertext. +// +// NOTE: versions ≤ 0.4.x appended `cc_tag` RAW (no length prefix), +// matching the then-buggy affinidi-messaging-didcomm. That was +// non-spec and only interoperated with other equally-buggy peers, not +// credo-ts / didcomm-python (affinidi-messaging-didcomm fixed it in +// 0.14). `legacyRawSuppPrivInfo` reproduces the old derivation for the +// decrypt fallback during migration — see `unpack.js`. // // We only support SHA-256 + JOSE OtherInfo construction — the // specific shape ECDH-1PU+A256KW needs. A general Concat KDF would @@ -48,15 +55,24 @@ const HASH_LEN = 32; // SHA-256 output length * (NOT base64url). The caller is responsible for base64url-decoding * the `apu` header value before passing it here. Empty allowed. * @param {Uint8Array} otherInfo.apv - Same shape as `apu`. - * @param {Uint8Array} [otherInfo.suppPrivInfo] - Optional raw bytes - * appended after SuppPubInfo (NOT length-prefixed). Used by + * @param {Uint8Array} [otherInfo.suppPrivInfo] - Optional bytes + * appended after SuppPubInfo, length-prefixed (`uint32_be(len) || + * bytes`) like the other variable-length fields. Used by * ECDH-1PU+A256KW to carry the JWE content-encryption auth tag. + * @param {boolean} [otherInfo.legacyRawSuppPrivInfo=false] - When true, + * append `suppPrivInfo` RAW (no length prefix), reproducing the + * pre-0.5 (non-spec) derivation. For the decrypt migration fallback + * only; never use when packing. * @param {number} keyDataLenBits - Number of bits of derived * keying material to produce. Must be a multiple of 8 and ≤ 4096 * (defensive cap to catch order-of-magnitude bugs). * @returns {Promise} */ -export async function deriveKey(z, { alg, apu, apv, suppPrivInfo }, keyDataLenBits) { +export async function deriveKey( + z, + { alg, apu, apv, suppPrivInfo, legacyRawSuppPrivInfo = false }, + keyDataLenBits, +) { if (!(z instanceof Uint8Array)) { throw new TypeError("ConcatKDF: Z must be Uint8Array"); } @@ -82,12 +98,20 @@ export async function deriveKey(z, { alg, apu, apv, suppPrivInfo }, keyDataLenBi const keyDataLenBytes = keyDataLenBits / 8; const algBytes = new TextEncoder().encode(alg); + // SuppPrivInfo (the ECDH-1PU cc_tag) is length-prefixed like every + // other variable-length field. `legacyRawSuppPrivInfo` reproduces the + // pre-0.5 unprefixed form for the decrypt migration fallback only. + const supp = suppPrivInfo + ? legacyRawSuppPrivInfo + ? suppPrivInfo + : lengthPrefix(suppPrivInfo) + : new Uint8Array(); const otherInfo = concatenate( lengthPrefix(algBytes), lengthPrefix(apu), lengthPrefix(apv), uint32be(keyDataLenBits), - suppPrivInfo ?? new Uint8Array(), + supp, ); const reps = Math.ceil(keyDataLenBytes / HASH_LEN); diff --git a/src/ecdh-1pu.js b/src/ecdh-1pu.js index c877554..4201743 100644 --- a/src/ecdh-1pu.js +++ b/src/ecdh-1pu.js @@ -67,11 +67,16 @@ export async function deriveKekAuthcrypt({ apv, ccTag, crv = "X25519", + legacy = false, }) { const ze = keyAgreement.sharedSecret(crv, ephemeralPrivate, recipientPublic); const zs = keyAgreement.sharedSecret(crv, senderPrivate, recipientPublic); const z = concat(ze, zs); - return concatKdf.deriveKey(z, { alg, apu, apv, suppPrivInfo: ccTag }, 256); + return concatKdf.deriveKey( + z, + { alg, apu, apv, suppPrivInfo: ccTag, legacyRawSuppPrivInfo: legacy }, + 256, + ); } /** @@ -93,6 +98,10 @@ export async function deriveKekAuthcrypt({ * @param {Uint8Array} [args.ccTag] - JWE content-encryption auth tag * for key-wrap mode binding. Same value the sender used. * @param {"X25519"|"P-256"} [args.crv="X25519"] - key-agreement curve + * @param {boolean} [args.legacy=false] - derive the KEK with the + * pre-0.5 (unprefixed cc_tag) Concat KDF. Used only by the decrypt + * fallback so a fixed recipient can still read authcrypt from a + * not-yet-upgraded peer (see `unpack.js`). * @returns {Promise} 32-byte KEK */ export async function recipientKekAuthcrypt({ @@ -104,11 +113,16 @@ export async function recipientKekAuthcrypt({ apv, ccTag, crv = "X25519", + legacy = false, }) { const ze = keyAgreement.sharedSecret(crv, recipientPrivate, ephemeralPublic); const zs = keyAgreement.sharedSecret(crv, recipientPrivate, senderPublic); const z = concat(ze, zs); - return concatKdf.deriveKey(z, { alg, apu, apv, suppPrivInfo: ccTag }, 256); + return concatKdf.deriveKey( + z, + { alg, apu, apv, suppPrivInfo: ccTag, legacyRawSuppPrivInfo: legacy }, + 256, + ); } function concat(a, b) { diff --git a/src/unpack.js b/src/unpack.js index e892f3f..65a2646 100644 --- a/src/unpack.js +++ b/src/unpack.js @@ -33,7 +33,10 @@ const ENC = "A256CBC-HS512"; * @param {Object} [sender] - `{ publicJwk }` — the sender's X25519 * public key, required for authcrypt (ECDH-1PU), ignored for * anoncrypt (ECDH-ES). - * @returns {Promise<{ message: Object, senderKid: string|undefined, authenticated: boolean }>} + * @returns {Promise<{ message: Object, senderKid: string|undefined, authenticated: boolean, legacyKekUsed: boolean }>} + * `legacyKekUsed` is true when an authcrypt message only decrypted + * under the pre-0.5 (unprefixed cc_tag) KEK — a migration signal that + * the sender hasn't upgraded yet. */ export async function unpack(jweJson, recipient, sender) { if (typeof jweJson !== "string") { @@ -111,7 +114,14 @@ export async function unpack(jweJson, recipient, sender) { const apuBytes = header.apu ? b64u.decode(header.apu) : new Uint8Array(); const apvBytes = header.apv ? b64u.decode(header.apv) : new Uint8Array(); - let kek; + // 5. Derive the KEK and unwrap the CEK. + const encryptedKey = b64u.decode(recipientEntry.encrypted_key); + let cek; + // True when decryption only succeeded under the legacy (pre-0.5, + // unprefixed cc_tag — issue #322) KEK, i.e. the sender is a + // not-yet-upgraded peer. A migration signal for callers. + let legacyKekUsed = false; + if (isAuthcrypt) { // Bind the authenticated sender identity: `apu` (which is fed into // the KDF) must equal utf8(skid) (which selects the sender key we @@ -131,7 +141,7 @@ export async function unpack(jweJson, recipient, sender) { `unpack: sender key curve (${jwk.curveOf(sender.publicJwk)}) does not match epk curve (${crv})`, ); } - kek = await ecdh1pu.recipientKekAuthcrypt({ + const kekArgs = { recipientPrivate: recipientPriv, ephemeralPublic, senderPublic: jwk.rawPublic(sender.publicJwk), @@ -140,9 +150,29 @@ export async function unpack(jweJson, recipient, sender) { apv: apvBytes, ccTag: tag, crv, - }); + }; + // Try the spec-correct KEK (length-prefixed cc_tag) first. If the + // AES-KW integrity check fails, the sender may be a pre-0.5 peer + // that derived the KEK with cc_tag un-prefixed (issue #322) — retry + // with the legacy derivation so we stay interoperable during + // migration. A genuinely bad envelope fails both; the second throw + // propagates. + const kek = await ecdh1pu.recipientKekAuthcrypt(kekArgs); + try { + cek = await aes.unwrapKey(kek, encryptedKey); + } catch { + const legacyKek = await ecdh1pu.recipientKekAuthcrypt({ ...kekArgs, legacy: true }); + try { + cek = await aes.unwrapKey(legacyKek, encryptedKey); + legacyKekUsed = true; + } finally { + legacyKek.fill(0); + } + } finally { + kek.fill(0); + } } else { - kek = await ecdhEs.recipientKekAnoncrypt({ + const kek = await ecdhEs.recipientKekAnoncrypt({ recipientPrivate: recipientPriv, ephemeralPublic, alg: ALG_ANONCRYPT, @@ -150,12 +180,13 @@ export async function unpack(jweJson, recipient, sender) { apv: apvBytes, crv, }); + try { + cek = await aes.unwrapKey(kek, encryptedKey); + } finally { + kek.fill(0); + } } - // 5. Unwrap the CEK. - const encryptedKey = b64u.decode(recipientEntry.encrypted_key); - const cek = await aes.unwrapKey(kek, encryptedKey); - // 6. A256CBC-HS512 decrypt. let plaintext; try { @@ -164,7 +195,6 @@ export async function unpack(jweJson, recipient, sender) { throw new Error(`unpack: A256CBC-HS512 decrypt failed: ${e.message}`); } finally { cek.fill(0); - kek.fill(0); } // 7. Parse the plaintext. @@ -179,6 +209,7 @@ export async function unpack(jweJson, recipient, sender) { message, senderKid: isAuthcrypt ? header.skid : undefined, authenticated: isAuthcrypt, + legacyKekUsed, }; } diff --git a/test/concat-kdf.test.js b/test/concat-kdf.test.js index 4c08888..7260c5f 100644 --- a/test/concat-kdf.test.js +++ b/test/concat-kdf.test.js @@ -88,6 +88,29 @@ test("deriveKey is deterministic across calls", async () => { assert.deepEqual(a, b); }); +test("ECDH-1PU cc_tag (suppPrivInfo) is length-prefixed by default (#322)", async () => { + const z = new Uint8Array(32).fill(7); + const apu = new TextEncoder().encode("a"); + const apv = new TextEncoder().encode("b"); + const tag = new Uint8Array(32).fill(0xcc); + const opts = { alg: "ECDH-1PU+A256KW", apu, apv }; + + // Default appends `uint32_be(len) || tag`. Equivalent to feeding the + // raw path a tag that's already length-prefixed. + const prefixed = await deriveKey(z, { ...opts, suppPrivInfo: tag }, 256); + const manual = await deriveKey( + z, + { ...opts, suppPrivInfo: lengthPrefix(tag), legacyRawSuppPrivInfo: true }, + 256, + ); + assert.deepEqual(prefixed, manual, "default suppPrivInfo must be length-prefixed"); + + // The legacy (pre-0.5) raw form must differ — that 4-byte difference + // is exactly the interop bug #322. + const raw = await deriveKey(z, { ...opts, suppPrivInfo: tag, legacyRawSuppPrivInfo: true }, 256); + assert.notDeepEqual(prefixed, raw, "length-prefixed vs raw cc_tag must differ"); +}); + test("deriveKey distinguishes apu vs apv (no symmetry bug)", async () => { const z = new Uint8Array(32).fill(1); const x = new TextEncoder().encode("X"); diff --git a/test/migration-fallback.test.js b/test/migration-fallback.test.js new file mode 100644 index 0000000..db7c16b --- /dev/null +++ b/test/migration-fallback.test.js @@ -0,0 +1,161 @@ +// Migration coverage for the ECDH-1PU Concat KDF length-prefix fix +// (issue #322). Versions ≤ 0.4.x fed `cc_tag` to the KDF without a +// length prefix; 0.5 length-prefixes it (spec-correct, interoperable +// with credo-ts / didcomm-python / affinidi-messaging-didcomm ≥ 0.14). +// +// To stay interoperable during rollout, `unpack` tries the spec-correct +// KEK first and falls back to the legacy (unprefixed) KEK, reporting +// `legacyKekUsed`. These tests cover both directions. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { pack } from "../src/pack.js"; +import { unpack } from "../src/unpack.js"; +import * as jwk from "../src/jwk.js"; +import * as x25519 from "../src/x25519.js"; +import * as a256cbcHs512 from "../src/a256cbc-hs512.js"; +import * as aes from "../src/aes.js"; +import * as b64u from "../src/base64url.js"; +import * as ecdh1pu from "../src/ecdh-1pu.js"; +import * as keyAgreement from "../src/key-agreement.js"; + +const ALG = "ECDH-1PU+A256KW"; +const ENC = "A256CBC-HS512"; + +function makeParty(kid) { + const { privateKey, publicKey } = x25519.generateKeyPair(); + return { + kid, + privateJwk: jwk.privateJwk("X25519", privateKey, publicKey, kid), + publicJwk: jwk.publicJwk("X25519", publicKey, kid), + }; +} + +async function sha256(bytes) { + return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)); +} + +/** + * Pack an authcrypt JWE the way a pre-0.5 peer would — identical to + * `pack` except the KEK is derived with the legacy (unprefixed cc_tag) + * Concat KDF. Test-only; production `pack` is always spec-correct. + */ +async function packLegacy({ message, sender, recipient }) { + const crv = jwk.curveOf(recipient.publicJwk); + const senderPriv = jwk.rawPrivate(sender.privateJwk); + const recipientPub = jwk.rawPublic(recipient.publicJwk); + const ephem = keyAgreement.generateKeyPair(crv); + const { cek, iv } = a256cbcHs512.generateCekAndIv(); + const apuBytes = new TextEncoder().encode(sender.kid); + const apvBytes = await sha256(new TextEncoder().encode(recipient.kid)); + + const protectedHeader = { + typ: "application/didcomm-encrypted+json", + alg: ALG, + enc: ENC, + apu: b64u.encode(apuBytes), + apv: b64u.encode(apvBytes), + skid: sender.kid, + epk: jwk.publicJwk(crv, ephem.publicKey), + }; + const protectedB64 = b64u.encode( + new TextEncoder().encode(JSON.stringify(protectedHeader)), + ); + + const { ciphertext, tag } = await a256cbcHs512.encrypt({ + cek, + iv, + aad: new TextEncoder().encode(protectedB64), + plaintext: new TextEncoder().encode(JSON.stringify(message)), + }); + + const kek = await ecdh1pu.deriveKekAuthcrypt({ + ephemeralPrivate: ephem.privateKey, + senderPrivate: senderPriv, + recipientPublic: recipientPub, + alg: ALG, + apu: apuBytes, + apv: apvBytes, + ccTag: tag, + crv, + legacy: true, // ← the pre-0.5 behaviour + }); + const encryptedKey = await aes.wrapKey(kek, cek); + + return JSON.stringify({ + protected: protectedB64, + recipients: [ + { header: { kid: recipient.kid }, encrypted_key: b64u.encode(encryptedKey) }, + ], + iv: b64u.encode(iv), + ciphertext: b64u.encode(ciphertext), + tag: b64u.encode(tag), + }); +} + +test("spec-correct authcrypt unpacks without the legacy fallback", async () => { + const sender = makeParty("did:key:zSender#x25519-1"); + const recipient = makeParty("did:key:zRecipient#x25519-1"); + const message = { id: "m1", type: "x", body: { hello: "spec" } }; + + const jwe = await pack({ + message, + sender: { kid: sender.kid, privateJwk: sender.privateJwk }, + recipient: { kid: recipient.kid, publicJwk: recipient.publicJwk }, + }); + + const res = await unpack( + jwe, + { kid: recipient.kid, privateJwk: recipient.privateJwk }, + { publicJwk: sender.publicJwk }, + ); + assert.deepEqual(res.message, message); + assert.equal(res.authenticated, true); + assert.equal(res.legacyKekUsed, false, "spec-correct must not engage the fallback"); +}); + +test("legacy (pre-0.5) authcrypt decrypts via the fallback", async () => { + const sender = makeParty("did:key:zSenderLegacy#x25519-1"); + const recipient = makeParty("did:key:zRecipientLegacy#x25519-1"); + const message = { id: "m2", type: "x", body: { hello: "legacy" } }; + + const jwe = await packLegacy({ + message, + sender: { kid: sender.kid, privateJwk: sender.privateJwk }, + recipient: { kid: recipient.kid, publicJwk: recipient.publicJwk }, + }); + + const res = await unpack( + jwe, + { kid: recipient.kid, privateJwk: recipient.privateJwk }, + { publicJwk: sender.publicJwk }, + ); + assert.deepEqual(res.message, message); + assert.equal(res.senderKid, sender.kid); + assert.equal(res.legacyKekUsed, true, "legacy-packed JWE must use the fallback KEK"); +}); + +test("spec-correct and legacy KEKs differ for the same inputs (the #322 fix)", async () => { + const sender = makeParty("did:key:zS#k"); + const recipient = makeParty("did:key:zR#k"); + const ephem = keyAgreement.generateKeyPair("X25519"); + const apu = new TextEncoder().encode(sender.kid); + const apv = await sha256(new TextEncoder().encode(recipient.kid)); + const ccTag = new Uint8Array(32).fill(0x5a); + + const common = { + ephemeralPrivate: ephem.privateKey, + senderPrivate: jwk.rawPrivate(sender.privateJwk), + recipientPublic: jwk.rawPublic(recipient.publicJwk), + alg: ALG, + apu, + apv, + ccTag, + crv: "X25519", + }; + + const correct = await ecdh1pu.deriveKekAuthcrypt({ ...common, legacy: false }); + const legacy = await ecdh1pu.deriveKekAuthcrypt({ ...common, legacy: true }); + assert.notDeepEqual(correct, legacy, "length-prefixing cc_tag must change the KEK"); +});