Skip to content

Commit 309b108

Browse files
committed
Merge branch 'main' into fil-570-stream-api
2 parents c6364c9 + f43b6d3 commit 309b108

21 files changed

Lines changed: 515 additions & 147 deletions

File tree

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,16 @@ recipient = [ {1: alg}, {4: kid, ...}, wrappedKey ] # alg -31 or -5
379379
against a count re-derived from the plaintext length.
380380
- **Chunk count** (label −65791) — advisory metadata for range/seek consumers,
381381
emitted only when the plaintext length is known; not required to decrypt.
382+
- **ECDH-ES key derivation** (alg −31) — HKDF-SHA-256 (RFC 5869) over the X25519
383+
shared secret, as RFC 9053 §6.3.1 requires, with the §5.2 `COSE_KDF_Context`
384+
as the info parameter: `AlgorithmID` −5 (A256KW), empty PartyU/PartyV,
385+
`keyDataLength` 256, and the recipient's serialized protected header
386+
`h'a101381e'`. JOSE derives ECDH-ES differently (RFC 7518 §4.6 uses the NIST
387+
SP 800-56A single-step KDF); a KEK derived that way will not unwrap. v0.1.0
388+
used the COSE Concat-KDF here, so envelopes it encrypted to X25519 recipients
389+
need decrypting with v0.1.0 and re-encrypting with this version; see the
390+
[`ecdhkw` package documentation](https://pkg.go.dev/github.com/filecoin-project/go-fee/ecdhkw)
391+
for the API change that comes with it.
382392

383393
## Security notes
384394

@@ -407,7 +417,8 @@ recipient = [ {1: alg}, {4: kid, ...}, wrappedKey ] # alg -31 or -5
407417
[RFC 9053](https://www.rfc-editor.org/rfc/rfc9053) COSE algorithms,
408418
[RFC 9596](https://www.rfc-editor.org/rfc/rfc9596) COSE `typ` header,
409419
[RFC 8949](https://www.rfc-editor.org/rfc/rfc8949) CBOR,
410-
[RFC 3394](https://www.rfc-editor.org/rfc/rfc3394) AES Key Wrap
420+
[RFC 3394](https://www.rfc-editor.org/rfc/rfc3394) AES Key Wrap,
421+
[RFC 5869](https://www.rfc-editor.org/rfc/rfc5869) HKDF
411422
- [Online Authenticated-Encryption and its Nonce-Reuse Misuse-Resistance](https://eprint.iacr.org/2015/189)
412423
— the STREAM construction
413424

cose/cose_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,3 +297,26 @@ func TestEncStructure(t *testing.T) {
297297
require.Equal(t, want, got)
298298
})
299299
}
300+
301+
// TestProtectedBytesIsolated pins ProtectedBytes against aliasing: the caller
302+
// gets a copy, so writing to the result cannot rewrite the decoded envelope's
303+
// protected header and with it the AAD and the recipient KDF context.
304+
func TestProtectedBytesIsolated(t *testing.T) {
305+
encoded, err := sampleEnvelope().Encode()
306+
require.NoError(t, err)
307+
decoded, _, err := Decode(encoded)
308+
require.NoError(t, err)
309+
310+
first, err := decoded.ProtectedBytes()
311+
require.NoError(t, err)
312+
require.NotEmpty(t, first, "sample envelope has a non-empty protected header")
313+
want := bytes.Clone(first)
314+
315+
for i := range first {
316+
first[i] ^= 0xFF
317+
}
318+
319+
second, err := decoded.ProtectedBytes()
320+
require.NoError(t, err)
321+
require.Equal(t, want, second, "mutating the returned slice changed the protected header")
322+
}

cose/envelope.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cose
22

33
import (
4+
"bytes"
45
"fmt"
56

67
"github.com/fxamacker/cbor/v2"
@@ -44,7 +45,7 @@ func (e *Envelope) isEncrypt0() bool { return len(e.Recipients) == 0 }
4445
// ciphertext after the returned bytes. Encoding is RFC 8949 core deterministic,
4546
// so the same envelope always produces identical bytes.
4647
func (e *Envelope) Encode() ([]byte, error) {
47-
prot, err := e.Headers.protectedBytes()
48+
prot, err := e.Headers.ProtectedBytes()
4849
if err != nil {
4950
return nil, fmt.Errorf("cose: encoding protected header: %w", err)
5051
}
@@ -67,7 +68,7 @@ func (e *Envelope) Encode() ([]byte, error) {
6768
if r == nil {
6869
return nil, fmt.Errorf("cose: recipient %d is nil", i)
6970
}
70-
rprot, err := r.Headers.protectedBytes()
71+
rprot, err := r.Headers.ProtectedBytes()
7172
if err != nil {
7273
return nil, fmt.Errorf("cose: encoding recipient %d protected header: %w", i, err)
7374
}
@@ -100,7 +101,7 @@ func (e *Envelope) Encode() ([]byte, error) {
100101
// Headers.Protected). external_aad is the caller's additional data; pass nil for
101102
// none, which encodes as an empty byte string.
102103
func (e *Envelope) EncStructure(externalAAD []byte) ([]byte, error) {
103-
prot, err := e.Headers.protectedBytes()
104+
prot, err := e.Headers.ProtectedBytes()
104105
if err != nil {
105106
return nil, fmt.Errorf("cose: building Enc_structure: %w", err)
106107
}
@@ -115,9 +116,9 @@ func (e *Envelope) EncStructure(externalAAD []byte) ([]byte, error) {
115116
// the byte string that appears on the wire and inside the Enc_structure. It is
116117
// RawProtected for a decoded envelope and the deterministic serialization of
117118
// Headers.Protected otherwise. The result is empty when the protected header is
118-
// empty.
119+
// empty, and is always a fresh slice the caller owns.
119120
func (e *Envelope) ProtectedBytes() ([]byte, error) {
120-
return e.Headers.protectedBytes()
121+
return e.Headers.ProtectedBytes()
121122
}
122123

123124
// encStructureBytes builds the CBOR-encoded COSE Enc_structure (RFC 9052 §5.3)
@@ -136,14 +137,21 @@ func encStructureBytes(context string, protected, externalAAD []byte) ([]byte, e
136137
return out, nil
137138
}
138139

139-
// protectedBytes returns the protected header byte-string content for this
140+
// ProtectedBytes returns the protected header byte-string content for this
140141
// Headers value: the on-wire RawProtected when present (set by Decode),
141142
// otherwise a fresh deterministic serialization of Protected. An empty protected
142143
// header serializes to an empty (zero-length) byte string, per COSE's
143144
// empty_or_serialized_map rule, rather than to an encoded empty map.
144-
func (h Headers) protectedBytes() ([]byte, error) {
145+
//
146+
// Recipient headers need this as well as body headers: a key-agreement recipient
147+
// binds its own protected bytes into the KDF context (RFC 9053 §5.2).
148+
//
149+
// The result is a fresh slice the caller owns. These bytes decide whether an
150+
// envelope verifies and which KEK a recipient derives, so handing out the
151+
// RawProtected slice itself would let a caller rewrite them by accident.
152+
func (h Headers) ProtectedBytes() ([]byte, error) {
145153
if h.RawProtected != nil {
146-
return h.RawProtected, nil
154+
return bytes.Clone(h.RawProtected), nil
147155
}
148156
if len(h.Protected) == 0 {
149157
return []byte{}, nil

ecdhkw/ecdhkw.go

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
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
@@ -25,6 +26,30 @@
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.
2853
package ecdhkw
2954

3055
import (
@@ -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.
4974
const 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

Comments
 (0)