Skip to content

Commit 8c34273

Browse files
committed
cmd/age,tag: implement age1tagpq1.../p256mlkem768tag recipients
Test vectors generated from hpkewg/hpke-pq@19adaeb (hpkewg/hpke-pq#28 + hpkewg/hpke-pq#32) and cfrg/draft-irtf-cfrg-concrete-hybrid-kems@1bbca40 (cfrg/draft-irtf-cfrg-concrete-hybrid-kems#16), plus the following diff: diff --git a/reference-implementation/src/bin/generate.rs b/reference-implementation/src/bin/generate.rs index 25e32e5..bc8f209 100644 --- a/reference-implementation/src/bin/generate.rs +++ b/reference-implementation/src/bin/generate.rs @@ -26,6 +26,15 @@ fn generate_test_vectors() -> TestVectors { // 5. QSF-P384-MLKEM1024 + SHAKE256 + AES-256-GCM vectors.push(TestVector::new::<QsfP384MlKem1024, Shake256, Aes256Gcm>()); + vectors = TestVectors::new(); + + // age1pq - xwing + vectors.push(TestVector::new::<QsfX25519MlKem768, HkdfSha256, ChaChaPoly>()); + // age1tag - p256tag + vectors.push(TestVector::new::<DhkemP256HkdfSha256, HkdfSha256, ChaChaPoly>()); + // age1tagpq - p256mlkem768tag + vectors.push(TestVector::new::<QsfP256MlKem768, HkdfSha256, ChaChaPoly>()); + vectors } diff --git a/reference-implementation/src/test_vectors.rs b/reference-implementation/src/test_vectors.rs index 24335aa..4134fb5 100644 --- a/reference-implementation/src/test_vectors.rs +++ b/reference-implementation/src/test_vectors.rs @@ -369,6 +369,10 @@ impl TestVector { (0x0051, 0x0011, 0x0002) => self.v::<QsfP384MlKem1024, Shake256, Aes256Gcm>(), (0x0051, 0x0011, 0xffff) => self.v::<QsfP384MlKem1024, Shake256, ExportOnly>(), + // age pq combinations + (0x647a, 0x0001, 0x0003) => self.v::<QsfX25519MlKem768, HkdfSha256, ChaChaPoly>(), + (0x0050, 0x0001, 0x0003) => self.v::<QsfP256MlKem768, HkdfSha256, ChaChaPoly>(), + _ => Err(format!( "Unsupported algorithm combination: KEM={:#x}, KDF={:#x}, AEAD={:#x}", self.kem_id, self.kdf_id, self.aead_id
1 parent 8f9e683 commit 8c34273

File tree

8 files changed

+730
-115
lines changed

8 files changed

+730
-115
lines changed

cmd/age/parse.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func (gitHubRecipientError) Error() string {
3131

3232
func parseRecipient(arg string) (age.Recipient, error) {
3333
switch {
34-
case strings.HasPrefix(arg, "age1tag1"):
34+
case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"):
3535
return tag.ParseRecipient(arg)
3636
case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1:
3737
return plugin.NewRecipient(arg, pluginTerminalUI)

go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module filippo.io/age
22

3-
go 1.19
3+
go 1.24.0
44

55
require (
66
filippo.io/edwards25519 v1.1.0
@@ -13,6 +13,7 @@ require (
1313
// Test dependencies.
1414
require (
1515
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805
16+
filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb
1617
github.com/rogpeppe/go-internal v1.12.0
1718
golang.org/x/tools v0.22.0 // indirect
1819
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3I
22
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
33
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
44
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
5+
filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb h1:9eVxcquiUiJn/f8DtnqmsN/8Asqw+h9b1+sM3T/Wl44=
6+
filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb/go.mod h1:ncYN/Z4GaQBV6TIbmQ7+lIaI+qGXCmZr88zrXHneVHs=
57
filippo.io/nistec v0.0.3 h1:h336Je2jRDZdBCLy2fLDUd9E2unG32JLwcJi0JQE9Cw=
68
filippo.io/nistec v0.0.3/go.mod h1:84fxC9mi+MhC2AERXI4LSa8cmSVOzrFikg6hZ4IfCyw=
79
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=

tag/internal/hpke/hpke.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import (
88
"crypto/cipher"
99
"crypto/ecdh"
1010
"crypto/hkdf"
11+
"crypto/mlkem"
1112
"crypto/rand"
1213
"crypto/sha256"
14+
"crypto/sha3"
1315
"encoding/binary"
1416
"errors"
1517
"hash"
@@ -131,6 +133,135 @@ func (dh *dhkemRecipient) Decap(encPubEph []byte) ([]byte, error) {
131133
return dh.extractAndExpand(dhVal, kemContext)
132134
}
133135

136+
type qsf struct {
137+
id uint16
138+
label string
139+
}
140+
141+
func (q *qsf) ID() uint16 {
142+
return q.id
143+
}
144+
145+
func (q *qsf) sharedSecret(ssPQ, ssT, ctT, ekT []byte) []byte {
146+
h := sha3.New256()
147+
h.Write(ssPQ)
148+
h.Write(ssT)
149+
h.Write(ctT)
150+
h.Write(ekT)
151+
h.Write([]byte(q.label))
152+
return h.Sum(nil)
153+
}
154+
155+
type qsfSender struct {
156+
qsf
157+
t *ecdh.PublicKey
158+
pq *mlkem.EncapsulationKey768
159+
}
160+
161+
// QSFSender returns a KEMSender implementing QSF-P256-MLKEM768-SHAKE256-SHA3256
162+
// or QSF-X25519-MLKEM768-SHA3256-SHAKE256 (aka X-Wing) from draft-ietf-hpke-pq
163+
// and draft-irtf-cfrg-concrete-hybrid-kems-00.
164+
func QSFSender(t *ecdh.PublicKey, pq *mlkem.EncapsulationKey768) (KEMSender, error) {
165+
switch t.Curve() {
166+
case ecdh.P256():
167+
return &qsfSender{
168+
t: t, pq: pq,
169+
qsf: qsf{
170+
id: 0x0050,
171+
label: "QSF-P256-MLKEM768-SHAKE256-SHA3256",
172+
},
173+
}, nil
174+
case ecdh.X25519():
175+
return &qsfSender{
176+
t: t, pq: pq,
177+
qsf: qsf{
178+
id: 0x647a,
179+
label: /**/ `\./` +
180+
/* */ `/^\`,
181+
},
182+
}, nil
183+
default:
184+
return nil, errors.New("unsupported curve")
185+
}
186+
}
187+
188+
var testingOnlyEncapsulate func() (ss, ct []byte)
189+
190+
func (s *qsfSender) Encap() (sharedSecret []byte, encapPub []byte, err error) {
191+
skE, err := s.t.Curve().GenerateKey(rand.Reader)
192+
if err != nil {
193+
return nil, nil, err
194+
}
195+
if testingOnlyGenerateKey != nil {
196+
skE = testingOnlyGenerateKey()
197+
}
198+
ssT, err := skE.ECDH(s.t)
199+
if err != nil {
200+
return nil, nil, err
201+
}
202+
ctT := skE.PublicKey().Bytes()
203+
204+
ssPQ, ctPQ := s.pq.Encapsulate()
205+
if testingOnlyEncapsulate != nil {
206+
ssPQ, ctPQ = testingOnlyEncapsulate()
207+
}
208+
209+
ss := s.sharedSecret(ssPQ, ssT, ctT, s.t.Bytes())
210+
ct := append(ctPQ, ctT...)
211+
return ss, ct, nil
212+
}
213+
214+
type qsfRecipient struct {
215+
qsf
216+
t *ecdh.PrivateKey
217+
pq *mlkem.DecapsulationKey768
218+
}
219+
220+
// QSFRecipient returns a KEMRecipient implementing QSF-P256-MLKEM768-SHAKE256-SHA3256
221+
// or QSF-MLKEM768-X25519-SHA3256-SHAKE256 (aka X-Wing) from draft-ietf-hpke-pq
222+
// and draft-irtf-cfrg-concrete-hybrid-kems-00.
223+
func QSFRecipient(t *ecdh.PrivateKey, pq *mlkem.DecapsulationKey768) (KEMRecipient, error) {
224+
switch t.Curve() {
225+
case ecdh.P256():
226+
return &qsfRecipient{
227+
t: t, pq: pq,
228+
qsf: qsf{
229+
id: 0x0050,
230+
label: "QSF-P256-MLKEM768-SHAKE256-SHA3256",
231+
},
232+
}, nil
233+
case ecdh.X25519():
234+
return &qsfRecipient{
235+
t: t, pq: pq,
236+
qsf: qsf{
237+
id: 0x647a,
238+
label: /**/ `\./` +
239+
/* */ `/^\`,
240+
},
241+
}, nil
242+
default:
243+
return nil, errors.New("unsupported curve")
244+
}
245+
}
246+
247+
func (r *qsfRecipient) Decap(enc []byte) ([]byte, error) {
248+
ctPQ, ctT := enc[:mlkem.CiphertextSize768], enc[mlkem.CiphertextSize768:]
249+
ssPQ, err := r.pq.Decapsulate(ctPQ)
250+
if err != nil {
251+
return nil, err
252+
}
253+
pub, err := r.t.Curve().NewPublicKey(ctT)
254+
if err != nil {
255+
return nil, err
256+
}
257+
ssT, err := r.t.ECDH(pub)
258+
if err != nil {
259+
return nil, err
260+
}
261+
ss := r.sharedSecret(ssPQ, ssT, ctT, r.t.PublicKey().Bytes())
262+
return ss, nil
263+
}
264+
134265
type KDF interface {
135266
LabeledExtract(sid, salt []byte, label string, inputKey []byte) ([]byte, error)
136267
LabeledExpand(suiteID, randomKey []byte, label string, info []byte, length uint16) ([]byte, error)

0 commit comments

Comments
 (0)