Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
38 changes: 31 additions & 7 deletions src/concat-kdf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Uint8Array>}
*/
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");
}
Expand All @@ -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);
Expand Down
18 changes: 16 additions & 2 deletions src/ecdh-1pu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

/**
Expand All @@ -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<Uint8Array>} 32-byte KEK
*/
export async function recipientKekAuthcrypt({
Expand All @@ -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) {
Expand Down
51 changes: 41 additions & 10 deletions src/unpack.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -140,22 +150,43 @@ 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,
apu: apuBytes,
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 {
Expand All @@ -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.
Expand All @@ -179,6 +209,7 @@ export async function unpack(jweJson, recipient, sender) {
message,
senderKid: isAuthcrypt ? header.skid : undefined,
authenticated: isAuthcrypt,
legacyKekUsed,
};
}

Expand Down
23 changes: 23 additions & 0 deletions test/concat-kdf.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading
Loading