decentralized-identity / decentralized-identity/veramo

`packDIDCommMessage` omits the required `apv` protected header (anoncrypt and authcrypt)

Open
#1,500 0 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
TypeScript
Stars
543
Forks
137
Avg merge
12m
Merged PRs (30d)
2

Description

### Describe the bug

`packDIDCommMessage()` never computes `apv`. The JWE protected header of both
anoncrypt and authcrypt envelopes contains only `alg`, `typ`, `epk`, `enc`
(plus `skid` for authcrypt). Strict DIDComm v2 implementations treat `apv` as
a required protected-header field and reject the envelope during parsing,
before any decryption is attempted.

### What the spec says

DIDComm v2, [ECDH-ES section](https://identity.foundation/didcomm-messaging/spec/v2.0/#ecdh-es-key-wrapping-and-common-protected-headers)
(same definition in the ECDH-1PU section):

> `apv`: this represents the recipients' `kid` list. The list must be
> alphanumerically sorted, `kid` values will then be concatenated with a `.`
> and the final result MUST be base64 URL (no padding) encoding of the SHA256
> hash of concatenated list.

Reference implementations agree: in didcomm-rust the field is non-optional —
`ProtectedHeader.apv` is a plain `&str` (not `Option`) in
[`src/jwe/envelope.rs`](https://github.com/sicpa-dlab/didcomm-rust/blob/main/src/jwe/envelope.rs),
and `ParsedJWE::verify_didcomm()` in
[`src/jwe/parse.rs`](https://github.com/sicpa-dlab/didcomm-rust/blob/main/src/jwe/parse.rs)
recomputes the hash from the recipient kids and rejects the message on
mismatch ("APV mismatch").

### Observed behavior

Sending an anoncrypt message (`ECDH-ES+A256KW`, tested with both `A256GCM`
and `XC20P`) packed by `packDIDCommMessage()` to a didcomm-rust-based
receiver fails with HTTP 400:

```
DIDCommMalformed: Malformed: Unable parse protected header: missing field `apv` at line 1 column 166
```

Re-packing the same message with `apv = base64url(sha256(sortedKids.join('.')))`
added to the protected header *and* passed into the encrypters is accepted
(HTTP 202), and the resulting envelope also passes didcomm-rust's
`verify_didcomm()` check locally.

### Where in the code

- `packages/did-comm/src/didcomm.ts` — `packDIDCommMessageJWE()` has the full
recipient list before creating encrypters, but never computes `apv`; the
encrypter factory calls (e.g. `xc20pAnonEncrypterX25519WithA256KW(recipient.publicKeyBytes, recipient.kid)`)
omit the optional `apv` argument.
- The low-level layer is already prepared: every anon encrypter in
`packages/did-comm/src/encryption/a256kw-encrypters.ts` and
`xc20pkw-encrypters.ts` accepts `apv?: string` and forwards it to the
Concat KDF via `createFullEncrypter()`.

### Suggested fix

After collecting recipients in `packDIDCommMessageJWE()`:

```ts
const kids = recipients.map((r) => r.kid).sort()
const apv = bytesToBase64url(sha256(stringToUtf8Bytes(kids.join('.'))))
protectedHeader = { ...protectedHeader, apv }
// and pass `apv` to each encrypter so it participates in the KDF
```

Backwards compatibility: did-jwt ≥ 7.x merges the protected header into each
recipient header before KEK computation on decrypt (`decryptJWE`), so older
Veramo receivers can still decrypt envelopes that carry `apv`.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.