66// identified by an X25519 public key (the sibling fee/aeskw package is the
77// other, wrapping directly under a symmetric KEK). A fresh
88// ephemeral X25519 key pair is generated for every Wrap; an ECDH against the
9- // recipient's static public key yields a shared secret, the COSE Concat-KDF
10- // (RFC 9053 §5.1, see kdf.go) turns that secret into a 256-bit key-encryption
11- // key, and AES Key Wrap (RFC 3394, the sibling fee/aeskw package) wraps the CEK
12- // under it. Unwrap reverses the process with the recipient's private key. Two
13- // useful consequences fall out of the construction:
9+ // recipient's static public key yields a shared secret, HKDF-SHA-256 over the
10+ // COSE_KDF_Context (RFC 9053 §5.1 and §5.2, see kdf.go) turns that secret into
11+ // a 256-bit key-encryption key, and AES Key Wrap (RFC 3394, the sibling
12+ // fee/aeskw package) wraps the CEK under it. Unwrap reverses the process with
13+ // the recipient's private key. Two useful consequences fall out of the
14+ // construction:
1415//
1516// - Recovery is self-checking. AES-KW carries an integrity check, so an
1617// unwrap with the wrong private key — or against a tampered wrapped key or
2526// CEK as the recipient ciphertext, keyed by a kid) is the job of the
2627// higher-level fee package. Keys are passed as crypto/ecdh values directly; any
2728// custody or key-provider abstraction lives above this layer.
29+ //
30+ // # Migrating from v0.1.0
31+ //
32+ // v0.1.0 derived the KEK with the COSE Concat-KDF and took no protected header:
33+ // Wrap(recipientPub, cek) and Unwrap(recipientPriv, w). Both signatures now take
34+ // the serialized COSE_Recipient protected header as a trailing argument, and the
35+ // derivation is HKDF-SHA-256 as RFC 9053 §6.3.1 requires.
36+ //
37+ // Callers that build the recipient with the cose package pass the bytes that
38+ // package produces:
39+ //
40+ // protected, err := cose.Headers{Protected: hdr}.ProtectedBytes()
41+ // w, err := ecdhkw.Wrap(recipientPub, cek, protected)
42+ //
43+ // A recipient with an empty protected bucket takes a nil or zero-length slice,
44+ // which reproduces v0.1.0's context except for the KDF itself. On the unwrap
45+ // side, pass the recipient's protected bytes exactly as they arrived on the
46+ // wire; cose.Headers.ProtectedBytes returns those for a decoded envelope.
47+ //
48+ // Wraps written by v0.1.0 do not survive the change. The two derivations produce
49+ // different KEKs from the same ECDH secret, so Unwrap on an old wrap fails with
50+ // aeskw.ErrIntegrity, and nothing on the wire distinguishes the two: both use
51+ // alg -31. Envelopes encrypted to X25519 recipients under v0.1.0 have to be
52+ // decrypted with v0.1.0 and re-encrypted with this version.
2853package ecdhkw
2954
3055import (
@@ -45,7 +70,7 @@ const AlgorithmECDHESA256KW = -31
4570
4671// algA256KW is the COSE algorithm identifier for AES-256 Key Wrap. It is the
4772// algorithm the derived key feeds, so it is the AlgorithmID embedded in the
48- // Concat-KDF context (RFC 9053 §5.2) that binds the KEK to its purpose.
73+ // COSE_KDF_Context (RFC 9053 §5.2) that binds the KEK to its purpose.
4974const algA256KW = - 5
5075
5176// kekLen is the length in bytes of the A256KW key-encryption key the KDF
@@ -76,7 +101,12 @@ type Wrapped struct {
76101// recipientPub must be an X25519 key. cek must be a valid AES key — a multiple
77102// of 8 bytes, at least 16 (so 16, 24, or 32 bytes). The cek slice is not
78103// retained or modified.
79- func Wrap (recipientPub * ecdh.PublicKey , cek []byte ) (* Wrapped , error ) {
104+ //
105+ // protected is the serialized protected header of the COSE_Recipient this wrap
106+ // will be encoded into, which RFC 9053 §5.2 binds into the key derivation; pass
107+ // a zero-length slice if that bucket is empty. Unwrap must receive the same
108+ // bytes, so the caller has to feed the encoder and the KDF from one value.
109+ func Wrap (recipientPub * ecdh.PublicKey , cek , protected []byte ) (* Wrapped , error ) {
80110 if recipientPub == nil {
81111 return nil , errors .New ("ecdhkw nil recipient public key" )
82112 }
@@ -97,7 +127,7 @@ func Wrap(recipientPub *ecdh.PublicKey, cek []byte) (*Wrapped, error) {
97127 return nil , fmt .Errorf ("ecdhkw generating ephemeral key: %w" , err )
98128 }
99129
100- kek , err := deriveKEK (ephemeral , recipientPub )
130+ kek , err := deriveKEK (ephemeral , recipientPub , protected )
101131 if err != nil {
102132 return nil , err
103133 }
@@ -117,11 +147,15 @@ func Wrap(recipientPub *ecdh.PublicKey, cek []byte) (*Wrapped, error) {
117147// key-encryption key by ECDH between recipientPriv and the ephemeral public key
118148// in w, then AES-KW-unwraps the CEK.
119149//
150+ // protected is the serialized protected header of the COSE_Recipient the wrap
151+ // arrived in, exactly as received; see [Wrap]. Because it feeds the derivation,
152+ // a header rewritten in transit yields a different KEK and fails the unwrap.
153+ //
120154// It returns an error if recipientPriv is the wrong key for this wrap, if the
121- // ephemeral key or wrapped CEK was tampered with, or if either key is not
122- // X25519. A wrong-key unwrap surfaces as aeskw.ErrIntegrity (wrapped), so
123- // callers may match it with errors.Is.
124- func Unwrap (recipientPriv * ecdh.PrivateKey , w * Wrapped ) ([]byte , error ) {
155+ // ephemeral key, protected header, or wrapped CEK was tampered with, or if
156+ // either key is not X25519. A wrong-key unwrap surfaces as aeskw.ErrIntegrity
157+ // (wrapped), so callers may match it with errors.Is.
158+ func Unwrap (recipientPriv * ecdh.PrivateKey , w * Wrapped , protected [] byte ) ([]byte , error ) {
125159 if recipientPriv == nil {
126160 return nil , errors .New ("ecdhkw nil recipient private key" )
127161 }
@@ -138,7 +172,7 @@ func Unwrap(recipientPriv *ecdh.PrivateKey, w *Wrapped) ([]byte, error) {
138172 return nil , errors .New ("ecdhkw ephemeral public key is not X25519" )
139173 }
140174
141- kek , err := deriveKEK (recipientPriv , w .EphemeralPublicKey )
175+ kek , err := deriveKEK (recipientPriv , w .EphemeralPublicKey , protected )
142176 if err != nil {
143177 return nil , err
144178 }
@@ -152,22 +186,26 @@ func Unwrap(recipientPriv *ecdh.PrivateKey, w *Wrapped) ([]byte, error) {
152186}
153187
154188// deriveKEK performs the ECDH-ES key derivation shared by Wrap and Unwrap: an
155- // X25519 ECDH between local and remote, then the COSE Concat-KDF over the
156- // shared secret to produce the A256KW key-encryption key. ECDH symmetry is what
157- // makes the two paths — (ephemeral private, recipient public) on wrap and
158- // (recipient private, ephemeral public) on unwrap — derive the same KEK.
189+ // X25519 ECDH between local and remote, then HKDF-SHA-256 over the shared
190+ // secret to produce the A256KW key-encryption key. ECDH symmetry is what makes
191+ // the two paths — (ephemeral private, recipient public) on wrap and (recipient
192+ // private, ephemeral public) on unwrap — derive the same KEK.
159193//
160194// crypto/ecdh's X25519 ECDH returns an error for a low-order ephemeral point
161195// (one that would force the shared secret to all-zeros), which propagates here.
162- func deriveKEK (local * ecdh.PrivateKey , remote * ecdh.PublicKey ) ([]byte , error ) {
196+ func deriveKEK (local * ecdh.PrivateKey , remote * ecdh.PublicKey , protected [] byte ) ([]byte , error ) {
163197 z , err := local .ECDH (remote )
164198 if err != nil {
165199 return nil , fmt .Errorf ("ecdhkw ECDH: %w" , err )
166200 }
167201 defer zero (z )
168202
169- context := kdfContext (algA256KW , kekLen * 8 , nil )
170- return concatKDF (z , context , kekLen ), nil
203+ context := kdfContext (algA256KW , kekLen * 8 , protected )
204+ kek , err := hkdfKEK (z , context , kekLen )
205+ if err != nil {
206+ return nil , fmt .Errorf ("ecdhkw deriving KEK: %w" , err )
207+ }
208+ return kek , nil
171209}
172210
173211// zero overwrites b, a best-effort wipe of derived key material (the KEK and
0 commit comments