From a7cf2df5b6184fabb04cbe599f4ef528e13e1628 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sat, 16 Aug 2025 23:11:02 +0100 Subject: [PATCH 01/14] cmd/age,tag: implement age1tag1.../p256tag recipients See C2SP/C2SP#156 --- cmd/age/parse.go | 3 + go.mod | 1 + go.sum | 2 + tag/internal/hpke/hpke.go | 346 ++++++++++++++++++ tag/internal/hpke/hpke_test.go | 218 +++++++++++ .../hpke/testdata/rfc9180-vectors.json | 7 + tag/tag.go | 103 ++++++ 7 files changed, 680 insertions(+) create mode 100644 tag/internal/hpke/hpke.go create mode 100644 tag/internal/hpke/hpke_test.go create mode 100644 tag/internal/hpke/testdata/rfc9180-vectors.json create mode 100644 tag/tag.go diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 4a59e7a4..27074848 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -16,6 +16,7 @@ import ( "filippo.io/age/agessh" "filippo.io/age/armor" "filippo.io/age/plugin" + "filippo.io/age/tag" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/ssh" ) @@ -30,6 +31,8 @@ func (gitHubRecipientError) Error() string { func parseRecipient(arg string) (age.Recipient, error) { switch { + case strings.HasPrefix(arg, "age1tag1"): + return tag.ParseRecipient(arg) case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: return plugin.NewRecipient(arg, pluginTerminalUI) case strings.HasPrefix(arg, "age1"): diff --git a/go.mod b/go.mod index 31df01fe..7c014860 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.19 require ( filippo.io/edwards25519 v1.1.0 + filippo.io/nistec v0.0.3 golang.org/x/crypto v0.24.0 golang.org/x/sys v0.21.0 golang.org/x/term v0.21.0 diff --git a/go.sum b/go.sum index fd0f776d..b3b4202f 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3I c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/nistec v0.0.3 h1:h336Je2jRDZdBCLy2fLDUd9E2unG32JLwcJi0JQE9Cw= +filippo.io/nistec v0.0.3/go.mod h1:84fxC9mi+MhC2AERXI4LSa8cmSVOzrFikg6hZ4IfCyw= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= diff --git a/tag/internal/hpke/hpke.go b/tag/internal/hpke/hpke.go new file mode 100644 index 00000000..6d2bed73 --- /dev/null +++ b/tag/internal/hpke/hpke.go @@ -0,0 +1,346 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package hpke + +import ( + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "errors" + "hash" + "math/bits" + + "golang.org/x/crypto/chacha20poly1305" +) + +type KEMSender interface { + Encap() (sharedSecret, enc []byte, err error) + ID() uint16 +} + +type KEMRecipient interface { + Decap(enc []byte) (sharedSecret []byte, err error) + ID() uint16 +} + +type dhKEM struct { + kdf KDF + id uint16 + nSecret uint16 +} + +func (dh *dhKEM) extractAndExpand(dhKey, kemContext []byte) ([]byte, error) { + suiteID := binary.BigEndian.AppendUint16([]byte("KEM"), dh.id) + eaePRK, err := dh.kdf.LabeledExtract(suiteID, nil, "eae_prk", dhKey) + if err != nil { + return nil, err + } + return dh.kdf.LabeledExpand(suiteID, eaePRK, "shared_secret", kemContext, dh.nSecret) +} + +func (dh *dhKEM) ID() uint16 { + return dh.id +} + +type dhkemSender struct { + dhKEM + pub *ecdh.PublicKey +} + +// DHKEMSender returns a KEMSender implementing DHKEM(P-256, HKDF-SHA256). +func DHKEMSender(pub *ecdh.PublicKey) (KEMSender, error) { + switch pub.Curve() { + case ecdh.P256(): + return &dhkemSender{ + pub: pub, + dhKEM: dhKEM{ + kdf: HKDFSHA256(), + id: 0x0010, + nSecret: 32, + }, + }, nil + default: + return nil, errors.New("unsupported curve") + } +} + +// testingOnlyGenerateKey is only used during testing, to provide +// a fixed test key to use when checking the RFC 9180 vectors. +var testingOnlyGenerateKey func() *ecdh.PrivateKey + +func (dh *dhkemSender) Encap() (sharedSecret []byte, encapPub []byte, err error) { + privEph, err := dh.pub.Curve().GenerateKey(rand.Reader) + if err != nil { + return nil, nil, err + } + if testingOnlyGenerateKey != nil { + privEph = testingOnlyGenerateKey() + } + dhVal, err := privEph.ECDH(dh.pub) + if err != nil { + return nil, nil, err + } + encPubEph := privEph.PublicKey().Bytes() + + encPubRecip := dh.pub.Bytes() + kemContext := append(encPubEph, encPubRecip...) + sharedSecret, err = dh.extractAndExpand(dhVal, kemContext) + if err != nil { + return nil, nil, err + } + return sharedSecret, encPubEph, nil +} + +type dhkemRecipient struct { + dhKEM + priv *ecdh.PrivateKey +} + +// DHKEMRecipient returns a KEMRecipient implementing DHKEM(P-256, HKDF-SHA256). +func DHKEMRecipient(priv *ecdh.PrivateKey) (KEMRecipient, error) { + switch priv.Curve() { + case ecdh.P256(): + return &dhkemRecipient{ + priv: priv, + dhKEM: dhKEM{ + kdf: HKDFSHA256(), + id: 0x0010, + nSecret: 32, + }, + }, nil + default: + return nil, errors.New("unsupported curve") + } +} + +func (dh *dhkemRecipient) Decap(encPubEph []byte) ([]byte, error) { + pubEph, err := dh.priv.Curve().NewPublicKey(encPubEph) + if err != nil { + return nil, err + } + dhVal, err := dh.priv.ECDH(pubEph) + if err != nil { + return nil, err + } + kemContext := append(encPubEph, dh.priv.PublicKey().Bytes()...) + return dh.extractAndExpand(dhVal, kemContext) +} + +type KDF interface { + LabeledExtract(sid, salt []byte, label string, inputKey []byte) ([]byte, error) + LabeledExpand(suiteID, randomKey []byte, label string, info []byte, length uint16) ([]byte, error) + ID() uint16 +} + +type hkdfKDF struct { + hash func() hash.Hash + id uint16 +} + +func HKDFSHA256() KDF { + return &hkdfKDF{hash: sha256.New, id: 0x0001} +} + +func (kdf *hkdfKDF) ID() uint16 { + return kdf.id +} + +func (kdf *hkdfKDF) LabeledExtract(sid []byte, salt []byte, label string, inputKey []byte) ([]byte, error) { + labeledIKM := make([]byte, 0, 7+len(sid)+len(label)+len(inputKey)) + labeledIKM = append(labeledIKM, []byte("HPKE-v1")...) + labeledIKM = append(labeledIKM, sid...) + labeledIKM = append(labeledIKM, label...) + labeledIKM = append(labeledIKM, inputKey...) + return hkdf.Extract(kdf.hash, labeledIKM, salt) +} + +func (kdf *hkdfKDF) LabeledExpand(suiteID []byte, randomKey []byte, label string, info []byte, length uint16) ([]byte, error) { + labeledInfo := make([]byte, 0, 2+7+len(suiteID)+len(label)+len(info)) + labeledInfo = binary.BigEndian.AppendUint16(labeledInfo, length) + labeledInfo = append(labeledInfo, []byte("HPKE-v1")...) + labeledInfo = append(labeledInfo, suiteID...) + labeledInfo = append(labeledInfo, label...) + labeledInfo = append(labeledInfo, info...) + return hkdf.Expand(kdf.hash, randomKey, string(labeledInfo), int(length)) +} + +type AEAD interface { + AEAD(key []byte) (cipher.AEAD, error) + KeySize() int + NonceSize() int + ID() uint16 +} + +type aead struct { + keySize int + nonceSize int + aead func([]byte) (cipher.AEAD, error) + id uint16 +} + +func ChaCha20Poly1305() AEAD { + return &aead{ + keySize: chacha20poly1305.KeySize, + nonceSize: chacha20poly1305.NonceSize, + aead: chacha20poly1305.New, + id: 0x0003, + } +} + +func (a *aead) ID() uint16 { + return a.id +} + +func (a *aead) AEAD(key []byte) (cipher.AEAD, error) { + if len(key) != a.keySize { + return nil, errors.New("invalid key size") + } + return a.aead(key) +} + +func (a *aead) KeySize() int { + return a.keySize +} + +func (a *aead) NonceSize() int { + return a.nonceSize +} + +type context struct { + aead cipher.AEAD + suiteID []byte + + key []byte + baseNonce []byte + + seqNum uint128 +} + +type Sender struct { + *context +} + +type Recipient struct { + *context +} + +func newContext(sharedSecret []byte, kemID uint16, kdf KDF, aead AEAD, info []byte) (*context, error) { + sid := suiteID(kemID, kdf.ID(), aead.ID()) + + pskIDHash, err := kdf.LabeledExtract(sid, nil, "psk_id_hash", nil) + if err != nil { + return nil, err + } + infoHash, err := kdf.LabeledExtract(sid, nil, "info_hash", info) + if err != nil { + return nil, err + } + ksContext := append([]byte{0}, pskIDHash...) + ksContext = append(ksContext, infoHash...) + + secret, err := kdf.LabeledExtract(sid, sharedSecret, "secret", nil) + if err != nil { + return nil, err + } + key, err := kdf.LabeledExpand(sid, secret, "key", ksContext, uint16(aead.KeySize())) + if err != nil { + return nil, err + } + baseNonce, err := kdf.LabeledExpand(sid, secret, "base_nonce", ksContext, uint16(aead.NonceSize())) + if err != nil { + return nil, err + } + + a, err := aead.AEAD(key) + if err != nil { + return nil, err + } + + return &context{ + aead: a, + suiteID: sid, + key: key, + baseNonce: baseNonce, + }, nil +} + +func SetupSender(kem KEMSender, kdf KDF, aead AEAD, info []byte) ([]byte, *Sender, error) { + sharedSecret, encapsulatedKey, err := kem.Encap() + if err != nil { + return nil, nil, err + } + context, err := newContext(sharedSecret, kem.ID(), kdf, aead, info) + if err != nil { + return nil, nil, err + } + return encapsulatedKey, &Sender{context}, nil +} + +func SetupRecipient(kem KEMRecipient, kdf KDF, aead AEAD, info, enc []byte) (*Recipient, error) { + sharedSecret, err := kem.Decap(enc) + if err != nil { + return nil, err + } + context, err := newContext(sharedSecret, kem.ID(), kdf, aead, info) + if err != nil { + return nil, err + } + return &Recipient{context}, nil +} + +func (ctx *context) nextNonce() []byte { + nonce := ctx.seqNum.bytes()[16-ctx.aead.NonceSize():] + for i := range ctx.baseNonce { + nonce[i] ^= ctx.baseNonce[i] + } + return nonce +} + +func (ctx *context) incrementNonce() { + ctx.seqNum = ctx.seqNum.addOne() +} + +func (s *Sender) Seal(aad, plaintext []byte) ([]byte, error) { + ciphertext := s.aead.Seal(nil, s.nextNonce(), plaintext, aad) + s.incrementNonce() + return ciphertext, nil +} + +func (r *Recipient) Open(aad, ciphertext []byte) ([]byte, error) { + plaintext, err := r.aead.Open(nil, r.nextNonce(), ciphertext, aad) + if err != nil { + return nil, err + } + r.incrementNonce() + return plaintext, nil +} + +func suiteID(kemID, kdfID, aeadID uint16) []byte { + suiteID := make([]byte, 0, 4+2+2+2) + suiteID = append(suiteID, []byte("HPKE")...) + suiteID = binary.BigEndian.AppendUint16(suiteID, kemID) + suiteID = binary.BigEndian.AppendUint16(suiteID, kdfID) + suiteID = binary.BigEndian.AppendUint16(suiteID, aeadID) + return suiteID +} + +type uint128 struct { + hi, lo uint64 +} + +func (u uint128) addOne() uint128 { + lo, carry := bits.Add64(u.lo, 1, 0) + return uint128{u.hi + carry, lo} +} + +func (u uint128) bytes() []byte { + b := make([]byte, 16) + binary.BigEndian.PutUint64(b[0:], u.hi) + binary.BigEndian.PutUint64(b[8:], u.lo) + return b +} diff --git a/tag/internal/hpke/hpke_test.go b/tag/internal/hpke/hpke_test.go new file mode 100644 index 00000000..d15a6573 --- /dev/null +++ b/tag/internal/hpke/hpke_test.go @@ -0,0 +1,218 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package hpke + +import ( + "bytes" + "crypto/ecdh" + "encoding/hex" + "encoding/json" + "errors" + "os" + "strconv" + "strings" + "testing" +) + +func mustDecodeHex(t *testing.T, in string) []byte { + t.Helper() + b, err := hex.DecodeString(in) + if err != nil { + t.Fatal(err) + } + return b +} + +func parseVectorSetup(vector string) map[string]string { + vals := map[string]string{} + for _, l := range strings.Split(vector, "\n") { + fields := strings.Split(l, ": ") + vals[fields[0]] = fields[1] + } + return vals +} + +func parseVectorEncryptions(vector string) []map[string]string { + vals := []map[string]string{} + for _, section := range strings.Split(vector, "\n\n") { + e := map[string]string{} + for _, l := range strings.Split(section, "\n") { + fields := strings.Split(l, ": ") + e[fields[0]] = fields[1] + } + vals = append(vals, e) + } + return vals +} + +func TestRFC9180Vectors(t *testing.T) { + vectorsJSON, err := os.ReadFile("testdata/rfc9180-vectors.json") + if err != nil { + t.Fatal(err) + } + + var vectors []struct { + Name string + Setup string + Encryptions string + } + if err := json.Unmarshal(vectorsJSON, &vectors); err != nil { + t.Fatal(err) + } + + for _, vector := range vectors { + t.Run(vector.Name, func(t *testing.T) { + setup := parseVectorSetup(vector.Setup) + + kemID, err := strconv.Atoi(setup["kem_id"]) + if err != nil { + t.Fatal(err) + } + kdfID, err := strconv.Atoi(setup["kdf_id"]) + if err != nil { + t.Fatal(err) + } + aeadID, err := strconv.Atoi(setup["aead_id"]) + if err != nil { + t.Fatal(err) + } + info := mustDecodeHex(t, setup["info"]) + pubKeyBytes := mustDecodeHex(t, setup["pkRm"]) + pub, err := parsePublicKey(uint16(kemID), pubKeyBytes) + if err != nil { + t.Fatal(err) + } + + ephemeralPrivKey := mustDecodeHex(t, setup["skEm"]) + + testingOnlyGenerateKey = func() *ecdh.PrivateKey { + priv, err := parsePrivateKey(uint16(kemID), ephemeralPrivKey) + if err != nil { + t.Fatal(err) + } + return priv + } + t.Cleanup(func() { testingOnlyGenerateKey = nil }) + + kemSender, err := DHKEMSender(pub) + if err != nil { + t.Fatal(err) + } + kdf, err := getKDF(uint16(kdfID)) + if err != nil { + t.Fatal(err) + } + aead, err := getAEAD(uint16(aeadID)) + if err != nil { + t.Fatal(err) + } + encap, sender, err := SetupSender(kemSender, kdf, aead, info) + if err != nil { + t.Fatal(err) + } + + expectedEncap := mustDecodeHex(t, setup["enc"]) + if !bytes.Equal(encap, expectedEncap) { + t.Errorf("unexpected encapsulated key, got: %x, want %x", encap, expectedEncap) + } + + privKeyBytes := mustDecodeHex(t, setup["skRm"]) + priv, err := parsePrivateKey(uint16(kemID), privKeyBytes) + if err != nil { + t.Fatal(err) + } + + kemRecipient, err := DHKEMRecipient(priv) + if err != nil { + t.Fatal(err) + } + recipient, err := SetupRecipient(kemRecipient, kdf, aead, info, encap) + if err != nil { + t.Fatal(err) + } + + for _, ctx := range []*context{sender.context, recipient.context} { + expectedKey := mustDecodeHex(t, setup["key"]) + if !bytes.Equal(ctx.key, expectedKey) { + t.Errorf("unexpected key, got: %x, want %x", ctx.key, expectedKey) + } + expectedBaseNonce := mustDecodeHex(t, setup["base_nonce"]) + if !bytes.Equal(ctx.baseNonce, expectedBaseNonce) { + t.Errorf("unexpected base nonce, got: %x, want %x", ctx.baseNonce, expectedBaseNonce) + } + } + + for _, enc := range parseVectorEncryptions(vector.Encryptions) { + t.Run("seq num "+enc["sequence number"], func(t *testing.T) { + seqNum, err := strconv.Atoi(enc["sequence number"]) + if err != nil { + t.Fatal(err) + } + sender.seqNum = uint128{lo: uint64(seqNum)} + recipient.seqNum = uint128{lo: uint64(seqNum)} + expectedNonce := mustDecodeHex(t, enc["nonce"]) + computedNonce := sender.nextNonce() + if !bytes.Equal(computedNonce, expectedNonce) { + t.Errorf("unexpected nonce: got %x, want %x", computedNonce, expectedNonce) + } + + expectedCiphertext := mustDecodeHex(t, enc["ct"]) + ciphertext, err := sender.Seal(mustDecodeHex(t, enc["aad"]), mustDecodeHex(t, enc["pt"])) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(ciphertext, expectedCiphertext) { + t.Errorf("unexpected ciphertext: got %x want %x", ciphertext, expectedCiphertext) + } + + expectedPlaintext := mustDecodeHex(t, enc["pt"]) + plaintext, err := recipient.Open(mustDecodeHex(t, enc["aad"]), mustDecodeHex(t, enc["ct"])) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(plaintext, expectedPlaintext) { + t.Errorf("unexpected plaintext: got %x want %x", plaintext, expectedPlaintext) + } + }) + } + }) + } +} + +func parsePublicKey(kemID uint16, keyBytes []byte) (*ecdh.PublicKey, error) { + switch kemID { + case 0x0010: // DHKEM(P-256, HKDF-SHA256) + return ecdh.P256().NewPublicKey(keyBytes) + default: + return nil, errors.New("unsupported KEM") + } +} + +func parsePrivateKey(kemID uint16, keyBytes []byte) (*ecdh.PrivateKey, error) { + switch kemID { + case 0x0010: // DHKEM(P-256, HKDF-SHA256) + return ecdh.P256().NewPrivateKey(keyBytes) + default: + return nil, errors.New("unsupported KEM") + } +} + +func getKDF(kdfID uint16) (KDF, error) { + switch kdfID { + case 0x0001: // HKDF-SHA256 + return HKDFSHA256(), nil + default: + return nil, errors.New("unsupported KDF") + } +} + +func getAEAD(aeadID uint16) (AEAD, error) { + switch aeadID { + case 0x0003: // ChaCha20Poly1305 + return ChaCha20Poly1305(), nil + default: + return nil, errors.New("unsupported AEAD") + } +} diff --git a/tag/internal/hpke/testdata/rfc9180-vectors.json b/tag/internal/hpke/testdata/rfc9180-vectors.json new file mode 100644 index 00000000..44dc4183 --- /dev/null +++ b/tag/internal/hpke/testdata/rfc9180-vectors.json @@ -0,0 +1,7 @@ +[ + { + "Name": "DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, ChaCha20Poly1305", + "Setup": "mode: 0\nkem_id: 16\nkdf_id: 1\naead_id: 3\ninfo: 4f6465206f6e2061204772656369616e2055726e\nikmE: f1f1a3bc95416871539ecb51c3a8f0cf608afb40fbbe305c0a72819d35c33f1f\npkEm: 04c07836a0206e04e31d8ae99bfd549380b072a1b1b82e563c935c095827824fc1559eac6fb9e3c70cd3193968994e7fe9781aa103f5b50e934b5b2f387e381291\nskEm: 7550253e1147aae48839c1f8af80d2770fb7a4c763afe7d0afa7e0f42a5b3689\nikmR: 61092f3f56994dd424405899154a9918353e3e008171517ad576b900ddb275e7\npkRm: 04a697bffde9405c992883c5c439d6cc358170b51af72812333b015621dc0f40bad9bb726f68a5c013806a790ec716ab8669f84f6b694596c2987cf35baba2a006\nskRm: a4d1c55836aa30f9b3fbb6ac98d338c877c2867dd3a77396d13f68d3ab150d3b\nenc: 04c07836a0206e04e31d8ae99bfd549380b072a1b1b82e563c935c095827824fc1559eac6fb9e3c70cd3193968994e7fe9781aa103f5b50e934b5b2f387e381291\nshared_secret: 806520f82ef0b03c823b7fc524b6b55a088f566b9751b89551c170f4113bd850\nkey_schedule_context: 00b738cd703db7b4106e93b4621e9a19c89c838e55964240e5d3f331aaf8b0d58b2e986ea1c671b61cf45eec134dac0bae58ec6f63e790b1400b47c33038b0269c\nsecret: fe891101629aa355aad68eff3cc5170d057eca0c7573f6575e91f9783e1d4506\nkey: a8f45490a92a3b04d1dbf6cf2c3939ad8bfc9bfcb97c04bffe116730c9dfe3fc\nbase_nonce: 726b4390ed2209809f58c693\nexporter_secret: 4f9bd9b3a8db7d7c3a5b9d44fdc1f6e37d5d77689ade5ec44a7242016e6aa205", + "Encryptions": "sequence number: 0\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d30\nnonce: 726b4390ed2209809f58c693\nct: 6469c41c5c81d3aa85432531ecf6460ec945bde1eb428cb2fedf7a29f5a685b4ccb0d057f03ea2952a27bb458b\n\nsequence number: 1\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d31\nnonce: 726b4390ed2209809f58c692\nct: f1564199f7e0e110ec9c1bcdde332177fc35c1adf6e57f8d1df24022227ffa8716862dbda2b1dc546c9d114374\n\nsequence number: 2\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d32\nnonce: 726b4390ed2209809f58c691\nct: 39de89728bcb774269f882af8dc5369e4f3d6322d986e872b3a8d074c7c18e8549ff3f85b6d6592ff87c3f310c\n\nsequence number: 4\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d34\nnonce: 726b4390ed2209809f58c697\nct: bc104a14fbede0cc79eeb826ea0476ce87b9c928c36e5e34dc9b6905d91473ec369a08b1a25d305dd45c6c5f80\n\nsequence number: 255\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d323535\nnonce: 726b4390ed2209809f58c66c\nct: 8f2814a2c548b3be50259713c6724009e092d37789f6856553d61df23ebc079235f710e6af3c3ca6eaba7c7c6c\n\nsequence number: 256\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d323536\nnonce: 726b4390ed2209809f58c793\nct: b45b69d419a9be7219d8c94365b89ad6951caf4576ea4774ea40e9b7047a09d6537d1aa2f7c12d6ae4b729b4d0" + } +] diff --git a/tag/tag.go b/tag/tag.go new file mode 100644 index 00000000..78326e41 --- /dev/null +++ b/tag/tag.go @@ -0,0 +1,103 @@ +// Copyright 2025 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag + +import ( + "crypto/ecdh" + "crypto/hkdf" + "crypto/sha256" + "fmt" + + "filippo.io/age" + "filippo.io/age/internal/format" + "filippo.io/age/plugin" + "filippo.io/age/tag/internal/hpke" + "filippo.io/nistec" +) + +type Recipient struct { + kem hpke.KEMSender + + compressed [33]byte + uncompressed [65]byte +} + +var _ age.Recipient = &Recipient{} + +// ParseRecipient returns a new [Recipient] from a Bech32 public key +// encoding with the "age1tag1" prefix. +func ParseRecipient(s string) (*Recipient, error) { + t, k, err := plugin.ParseRecipient(s) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + if t != "tag" { + return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t) + } + r, err := NewRecipient(k) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + return r, nil +} + +// NewRecipient returns a new [Recipient] from a raw public key. +func NewRecipient(publicKey []byte) (*Recipient, error) { + if len(publicKey) != 1+32 { + return nil, fmt.Errorf("invalid tag recipient public key size %d", len(publicKey)) + } + p, err := nistec.NewP256Point().SetBytes(publicKey) + if err != nil { + return nil, fmt.Errorf("invalid tag recipient public key: %v", err) + } + k, err := ecdh.P256().NewPublicKey(p.Bytes()) + if err != nil { + return nil, fmt.Errorf("invalid tag recipient public key: %v", err) + } + kem, err := hpke.DHKEMSender(k) + if err != nil { + return nil, fmt.Errorf("failed to create DHKEM sender: %v", err) + } + r := &Recipient{kem: kem} + copy(r.compressed[:], publicKey) + copy(r.uncompressed[:], p.Bytes()) + return r, nil +} + +var p256TagLabel = []byte("age-encryption.org/p256tag") + +func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { + enc, s, err := hpke.SetupSender(r.kem, + hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), + p256TagLabel) + if err != nil { + return nil, fmt.Errorf("failed to set up HPKE sender: %v", err) + } + ct, err := s.Seal(nil, fileKey) + if err != nil { + return nil, fmt.Errorf("failed to encrypt file key: %v", err) + } + + tag, err := hkdf.Extract(sha256.New, append(enc, r.uncompressed[:]...), p256TagLabel) + if err != nil { + return nil, fmt.Errorf("failed to compute tag: %v", err) + } + + l := &age.Stanza{ + Type: "p256tag", + Args: []string{ + format.EncodeToString(tag[:4]), + format.EncodeToString(enc), + }, + Body: ct, + } + + return []*age.Stanza{l}, nil +} + +// String returns the Bech32 public key encoding of r. +func (r *Recipient) String() string { + return plugin.EncodeRecipient("tag", r.compressed[:]) +} From fceb17ca7a585af4ad8e942800c966d5a67f6fe2 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 17 Aug 2025 20:23:59 +0100 Subject: [PATCH 02/14] 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::()); + vectors = TestVectors::new(); + + // age1pq - xwing + vectors.push(TestVector::new::()); + // age1tag - p256tag + vectors.push(TestVector::new::()); + // age1tagpq - p256mlkem768tag + vectors.push(TestVector::new::()); + 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::(), (0x0051, 0x0011, 0xffff) => self.v::(), + // age pq combinations + (0x647a, 0x0001, 0x0003) => self.v::(), + (0x0050, 0x0001, 0x0003) => self.v::(), + _ => Err(format!( "Unsupported algorithm combination: KEM={:#x}, KDF={:#x}, AEAD={:#x}", self.kem_id, self.kdf_id, self.aead_id --- cmd/age/parse.go | 2 +- go.mod | 3 +- go.sum | 2 + tag/internal/hpke/hpke.go | 131 +++++++ tag/internal/hpke/hpke_test.go | 303 ++++++++++++----- tag/internal/hpke/testdata/hpke-pq.json | 320 ++++++++++++++++++ .../hpke/testdata/rfc9180-vectors.json | 7 - tag/tag.go | 77 ++++- 8 files changed, 730 insertions(+), 115 deletions(-) create mode 100644 tag/internal/hpke/testdata/hpke-pq.json delete mode 100644 tag/internal/hpke/testdata/rfc9180-vectors.json diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 27074848..70dcd86d 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -31,7 +31,7 @@ func (gitHubRecipientError) Error() string { func parseRecipient(arg string) (age.Recipient, error) { switch { - case strings.HasPrefix(arg, "age1tag1"): + case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"): return tag.ParseRecipient(arg) case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: return plugin.NewRecipient(arg, pluginTerminalUI) diff --git a/go.mod b/go.mod index 7c014860..999619c1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module filippo.io/age -go 1.19 +go 1.24.0 require ( filippo.io/edwards25519 v1.1.0 @@ -13,6 +13,7 @@ require ( // Test dependencies. require ( c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 + filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb github.com/rogpeppe/go-internal v1.12.0 golang.org/x/tools v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index b3b4202f..70c89dcd 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3I c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb h1:9eVxcquiUiJn/f8DtnqmsN/8Asqw+h9b1+sM3T/Wl44= +filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb/go.mod h1:ncYN/Z4GaQBV6TIbmQ7+lIaI+qGXCmZr88zrXHneVHs= filippo.io/nistec v0.0.3 h1:h336Je2jRDZdBCLy2fLDUd9E2unG32JLwcJi0JQE9Cw= filippo.io/nistec v0.0.3/go.mod h1:84fxC9mi+MhC2AERXI4LSa8cmSVOzrFikg6hZ4IfCyw= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= diff --git a/tag/internal/hpke/hpke.go b/tag/internal/hpke/hpke.go index 6d2bed73..37c55ba4 100644 --- a/tag/internal/hpke/hpke.go +++ b/tag/internal/hpke/hpke.go @@ -8,8 +8,10 @@ import ( "crypto/cipher" "crypto/ecdh" "crypto/hkdf" + "crypto/mlkem" "crypto/rand" "crypto/sha256" + "crypto/sha3" "encoding/binary" "errors" "hash" @@ -131,6 +133,135 @@ func (dh *dhkemRecipient) Decap(encPubEph []byte) ([]byte, error) { return dh.extractAndExpand(dhVal, kemContext) } +type qsf struct { + id uint16 + label string +} + +func (q *qsf) ID() uint16 { + return q.id +} + +func (q *qsf) sharedSecret(ssPQ, ssT, ctT, ekT []byte) []byte { + h := sha3.New256() + h.Write(ssPQ) + h.Write(ssT) + h.Write(ctT) + h.Write(ekT) + h.Write([]byte(q.label)) + return h.Sum(nil) +} + +type qsfSender struct { + qsf + t *ecdh.PublicKey + pq *mlkem.EncapsulationKey768 +} + +// QSFSender returns a KEMSender implementing QSF-P256-MLKEM768-SHAKE256-SHA3256 +// or QSF-X25519-MLKEM768-SHA3256-SHAKE256 (aka X-Wing) from draft-ietf-hpke-pq +// and draft-irtf-cfrg-concrete-hybrid-kems-00. +func QSFSender(t *ecdh.PublicKey, pq *mlkem.EncapsulationKey768) (KEMSender, error) { + switch t.Curve() { + case ecdh.P256(): + return &qsfSender{ + t: t, pq: pq, + qsf: qsf{ + id: 0x0050, + label: "QSF-P256-MLKEM768-SHAKE256-SHA3256", + }, + }, nil + case ecdh.X25519(): + return &qsfSender{ + t: t, pq: pq, + qsf: qsf{ + id: 0x647a, + label: /**/ `\./` + + /* */ `/^\`, + }, + }, nil + default: + return nil, errors.New("unsupported curve") + } +} + +var testingOnlyEncapsulate func() (ss, ct []byte) + +func (s *qsfSender) Encap() (sharedSecret []byte, encapPub []byte, err error) { + skE, err := s.t.Curve().GenerateKey(rand.Reader) + if err != nil { + return nil, nil, err + } + if testingOnlyGenerateKey != nil { + skE = testingOnlyGenerateKey() + } + ssT, err := skE.ECDH(s.t) + if err != nil { + return nil, nil, err + } + ctT := skE.PublicKey().Bytes() + + ssPQ, ctPQ := s.pq.Encapsulate() + if testingOnlyEncapsulate != nil { + ssPQ, ctPQ = testingOnlyEncapsulate() + } + + ss := s.sharedSecret(ssPQ, ssT, ctT, s.t.Bytes()) + ct := append(ctPQ, ctT...) + return ss, ct, nil +} + +type qsfRecipient struct { + qsf + t *ecdh.PrivateKey + pq *mlkem.DecapsulationKey768 +} + +// QSFRecipient returns a KEMRecipient implementing QSF-P256-MLKEM768-SHAKE256-SHA3256 +// or QSF-MLKEM768-X25519-SHA3256-SHAKE256 (aka X-Wing) from draft-ietf-hpke-pq +// and draft-irtf-cfrg-concrete-hybrid-kems-00. +func QSFRecipient(t *ecdh.PrivateKey, pq *mlkem.DecapsulationKey768) (KEMRecipient, error) { + switch t.Curve() { + case ecdh.P256(): + return &qsfRecipient{ + t: t, pq: pq, + qsf: qsf{ + id: 0x0050, + label: "QSF-P256-MLKEM768-SHAKE256-SHA3256", + }, + }, nil + case ecdh.X25519(): + return &qsfRecipient{ + t: t, pq: pq, + qsf: qsf{ + id: 0x647a, + label: /**/ `\./` + + /* */ `/^\`, + }, + }, nil + default: + return nil, errors.New("unsupported curve") + } +} + +func (r *qsfRecipient) Decap(enc []byte) ([]byte, error) { + ctPQ, ctT := enc[:mlkem.CiphertextSize768], enc[mlkem.CiphertextSize768:] + ssPQ, err := r.pq.Decapsulate(ctPQ) + if err != nil { + return nil, err + } + pub, err := r.t.Curve().NewPublicKey(ctT) + if err != nil { + return nil, err + } + ssT, err := r.t.ECDH(pub) + if err != nil { + return nil, err + } + ss := r.sharedSecret(ssPQ, ssT, ctT, r.t.PublicKey().Bytes()) + return ss, nil +} + type KDF interface { LabeledExtract(sid, salt []byte, label string, inputKey []byte) ([]byte, error) LabeledExpand(suiteID, randomKey []byte, label string, info []byte, length uint16) ([]byte, error) diff --git a/tag/internal/hpke/hpke_test.go b/tag/internal/hpke/hpke_test.go index d15a6573..96e89779 100644 --- a/tag/internal/hpke/hpke_test.go +++ b/tag/internal/hpke/hpke_test.go @@ -7,13 +7,19 @@ package hpke import ( "bytes" "crypto/ecdh" + "crypto/elliptic" + "crypto/mlkem" + "crypto/sha3" + "encoding/binary" "encoding/hex" "encoding/json" "errors" + "fmt" + "math/big" "os" - "strconv" - "strings" "testing" + + "filippo.io/mlkem768" ) func mustDecodeHex(t *testing.T, in string) []byte { @@ -25,106 +31,85 @@ func mustDecodeHex(t *testing.T, in string) []byte { return b } -func parseVectorSetup(vector string) map[string]string { - vals := map[string]string{} - for _, l := range strings.Split(vector, "\n") { - fields := strings.Split(l, ": ") - vals[fields[0]] = fields[1] - } - return vals -} - -func parseVectorEncryptions(vector string) []map[string]string { - vals := []map[string]string{} - for _, section := range strings.Split(vector, "\n\n") { - e := map[string]string{} - for _, l := range strings.Split(section, "\n") { - fields := strings.Split(l, ": ") - e[fields[0]] = fields[1] - } - vals = append(vals, e) - } - return vals -} - -func TestRFC9180Vectors(t *testing.T) { - vectorsJSON, err := os.ReadFile("testdata/rfc9180-vectors.json") +func TestVectors(t *testing.T) { + vectorsJSON, err := os.ReadFile("testdata/hpke-pq.json") if err != nil { t.Fatal(err) } var vectors []struct { - Name string - Setup string - Encryptions string + Mode uint16 `json:"mode"` + KEM uint16 `json:"kem_id"` + KDF uint16 `json:"kdf_id"` + AEAD uint16 `json:"aead_id"` + Info string `json:"info"` + EncapRand string `json:"encap_rand"` + IkmR string `json:"ikmR"` + SkRm string `json:"skRm"` + PkRm string `json:"pkRm"` + Enc string `json:"enc"` + SuiteID string `json:"suite_id"` + Key string `json:"key"` + BaseNonce string `json:"base_nonce"` + Encryptions []struct { + Aad string `json:"aad"` + Ct string `json:"ct"` + Nonce string `json:"nonce"` + Pt string `json:"pt"` + } `json:"encryptions"` } if err := json.Unmarshal(vectorsJSON, &vectors); err != nil { t.Fatal(err) } for _, vector := range vectors { - t.Run(vector.Name, func(t *testing.T) { - setup := parseVectorSetup(vector.Setup) + name := fmt.Sprintf("kem %04x kdf %04x aead %04x", + vector.KEM, vector.KDF, vector.AEAD) + t.Run(name, func(t *testing.T) { + info := mustDecodeHex(t, vector.Info) + pubKeyBytes := mustDecodeHex(t, vector.PkRm) + pubT, pubPQ := parsePublicKey(t, vector.KEM, pubKeyBytes) - kemID, err := strconv.Atoi(setup["kem_id"]) - if err != nil { - t.Fatal(err) + var kemSender KEMSender + if pubPQ != nil { + kemSender, err = QSFSender(pubT, pubPQ) + } else { + kemSender, err = DHKEMSender(pubT) } - kdfID, err := strconv.Atoi(setup["kdf_id"]) if err != nil { t.Fatal(err) } - aeadID, err := strconv.Atoi(setup["aead_id"]) + kdf, err := getKDF(vector.KDF) if err != nil { t.Fatal(err) } - info := mustDecodeHex(t, setup["info"]) - pubKeyBytes := mustDecodeHex(t, setup["pkRm"]) - pub, err := parsePublicKey(uint16(kemID), pubKeyBytes) + aead, err := getAEAD(vector.AEAD) if err != nil { t.Fatal(err) } - ephemeralPrivKey := mustDecodeHex(t, setup["skEm"]) + encapsRand := mustDecodeHex(t, vector.EncapRand) + setupEncapDerand(t, vector.KEM, encapsRand, pubPQ, kdf) - testingOnlyGenerateKey = func() *ecdh.PrivateKey { - priv, err := parsePrivateKey(uint16(kemID), ephemeralPrivKey) - if err != nil { - t.Fatal(err) - } - return priv - } - t.Cleanup(func() { testingOnlyGenerateKey = nil }) - - kemSender, err := DHKEMSender(pub) - if err != nil { - t.Fatal(err) - } - kdf, err := getKDF(uint16(kdfID)) - if err != nil { - t.Fatal(err) - } - aead, err := getAEAD(uint16(aeadID)) - if err != nil { - t.Fatal(err) - } encap, sender, err := SetupSender(kemSender, kdf, aead, info) if err != nil { t.Fatal(err) } - expectedEncap := mustDecodeHex(t, setup["enc"]) + expectedEncap := mustDecodeHex(t, vector.Enc) if !bytes.Equal(encap, expectedEncap) { t.Errorf("unexpected encapsulated key, got: %x, want %x", encap, expectedEncap) } - privKeyBytes := mustDecodeHex(t, setup["skRm"]) - priv, err := parsePrivateKey(uint16(kemID), privKeyBytes) - if err != nil { - t.Fatal(err) - } + privKeyBytes := mustDecodeHex(t, vector.SkRm) + privT, privQ := parsePrivateKey(t, vector.KEM, privKeyBytes) - kemRecipient, err := DHKEMRecipient(priv) + var kemRecipient KEMRecipient + if privQ != nil { + kemRecipient, err = QSFRecipient(privT, privQ) + } else { + kemRecipient, err = DHKEMRecipient(privT) + } if err != nil { t.Fatal(err) } @@ -133,33 +118,33 @@ func TestRFC9180Vectors(t *testing.T) { t.Fatal(err) } - for _, ctx := range []*context{sender.context, recipient.context} { - expectedKey := mustDecodeHex(t, setup["key"]) + for i, ctx := range []*context{sender.context, recipient.context} { + name := []string{"sender", "recipient"}[i] + expectedSuiteID := mustDecodeHex(t, vector.SuiteID) + if !bytes.Equal(ctx.suiteID, expectedSuiteID) { + t.Errorf("%s: unexpected suite ID, got: %x, want %x", name, ctx.suiteID, expectedSuiteID) + } + expectedKey := mustDecodeHex(t, vector.Key) if !bytes.Equal(ctx.key, expectedKey) { - t.Errorf("unexpected key, got: %x, want %x", ctx.key, expectedKey) + t.Errorf("%s: unexpected key, got: %x, want %x", name, ctx.key, expectedKey) } - expectedBaseNonce := mustDecodeHex(t, setup["base_nonce"]) + expectedBaseNonce := mustDecodeHex(t, vector.BaseNonce) if !bytes.Equal(ctx.baseNonce, expectedBaseNonce) { - t.Errorf("unexpected base nonce, got: %x, want %x", ctx.baseNonce, expectedBaseNonce) + t.Errorf("%s: unexpected base nonce, got: %x, want %x", name, ctx.baseNonce, expectedBaseNonce) } } - for _, enc := range parseVectorEncryptions(vector.Encryptions) { - t.Run("seq num "+enc["sequence number"], func(t *testing.T) { - seqNum, err := strconv.Atoi(enc["sequence number"]) - if err != nil { - t.Fatal(err) - } - sender.seqNum = uint128{lo: uint64(seqNum)} - recipient.seqNum = uint128{lo: uint64(seqNum)} - expectedNonce := mustDecodeHex(t, enc["nonce"]) + for i, enc := range vector.Encryptions { + name := fmt.Sprintf("encryption %d", i) + t.Run(name, func(t *testing.T) { + expectedNonce := mustDecodeHex(t, enc.Nonce) computedNonce := sender.nextNonce() if !bytes.Equal(computedNonce, expectedNonce) { t.Errorf("unexpected nonce: got %x, want %x", computedNonce, expectedNonce) } - expectedCiphertext := mustDecodeHex(t, enc["ct"]) - ciphertext, err := sender.Seal(mustDecodeHex(t, enc["aad"]), mustDecodeHex(t, enc["pt"])) + expectedCiphertext := mustDecodeHex(t, enc.Ct) + ciphertext, err := sender.Seal(mustDecodeHex(t, enc.Aad), mustDecodeHex(t, enc.Pt)) if err != nil { t.Fatal(err) } @@ -167,8 +152,8 @@ func TestRFC9180Vectors(t *testing.T) { t.Errorf("unexpected ciphertext: got %x want %x", ciphertext, expectedCiphertext) } - expectedPlaintext := mustDecodeHex(t, enc["pt"]) - plaintext, err := recipient.Open(mustDecodeHex(t, enc["aad"]), mustDecodeHex(t, enc["ct"])) + expectedPlaintext := mustDecodeHex(t, enc.Pt) + plaintext, err := recipient.Open(mustDecodeHex(t, enc.Aad), mustDecodeHex(t, enc.Ct)) if err != nil { t.Fatal(err) } @@ -181,21 +166,155 @@ func TestRFC9180Vectors(t *testing.T) { } } -func parsePublicKey(kemID uint16, keyBytes []byte) (*ecdh.PublicKey, error) { +func parsePublicKey(t *testing.T, kemID uint16, keyBytes []byte) (*ecdh.PublicKey, *mlkem.EncapsulationKey768) { + switch kemID { + case 0x0010: // DHKEM(P-256, HKDF-SHA256) + k, err := ecdh.P256().NewPublicKey(keyBytes) + if err != nil { + t.Fatal(err) + } + return k, nil + case 0x0050: // QSF-P256-MLKEM768-SHAKE256-SHA3256 + pq, err := mlkem.NewEncapsulationKey768(keyBytes[:mlkem.EncapsulationKeySize768]) + if err != nil { + t.Fatal(err) + } + k, err := ecdh.P256().NewPublicKey(keyBytes[mlkem.EncapsulationKeySize768:]) + if err != nil { + t.Fatal(err) + } + return k, pq + case 0x647a: // QSF-X25519-MLKEM768-SHAKE256-SHA3256 + pq, err := mlkem.NewEncapsulationKey768(keyBytes[:mlkem.EncapsulationKeySize768]) + if err != nil { + t.Fatal(err) + } + k, err := ecdh.X25519().NewPublicKey(keyBytes[mlkem.EncapsulationKeySize768:]) + if err != nil { + t.Fatal(err) + } + return k, pq + default: + t.Fatalf("unsupported KEM %04x", kemID) + panic("unreachable") + } +} + +func p256KeyFromSeedQSF(t *testing.T, seed []byte) *ecdh.PrivateKey { + t.Helper() + if len(seed) != 48 { + t.Fatalf("invalid seed length %d, expected 48", len(seed)) + } + s := new(big.Int).Mod(new(big.Int).SetBytes(seed), elliptic.P256().Params().P) + sb := make([]byte, 32) + s.FillBytes(sb) + k, err := ecdh.P256().NewPrivateKey(sb) + if err != nil { + t.Fatalf("failed to create P-256 private key: %v", err) + } + return k +} + +func p256KeyFromSeedDHKEM(t *testing.T, seed []byte, kdf KDF, suiteID []byte) *ecdh.PrivateKey { + // RFC 9180, Section 7.1.3. Only for testing, without rejection handling. + t.Helper() + if len(seed) != 32 { + t.Fatalf("invalid seed length %d, expected 32", len(seed)) + } + prk, err := kdf.LabeledExtract(suiteID, nil, "dkp_prk", seed) + if err != nil { + t.Fatalf("failed to extract PRK: %v", err) + } + s, err := kdf.LabeledExpand(suiteID, prk, "candidate", []byte{0x00}, 32) + if err != nil { + t.Fatalf("failed to expand candidate: %v", err) + } + k, err := ecdh.P256().NewPrivateKey(s) + if err != nil { + t.Fatalf("failed to create P-256 private key: %v", err) + } + return k +} + +func setupEncapDerand(t *testing.T, kemID uint16, randBytes []byte, pubPQ *mlkem.EncapsulationKey768, kdf KDF) { switch kemID { case 0x0010: // DHKEM(P-256, HKDF-SHA256) - return ecdh.P256().NewPublicKey(keyBytes) + suiteID := binary.BigEndian.AppendUint16([]byte("KEM"), kemID) + k := p256KeyFromSeedDHKEM(t, randBytes, kdf, suiteID) + testingOnlyGenerateKey = func() *ecdh.PrivateKey { return k } + t.Cleanup(func() { testingOnlyGenerateKey = nil }) + case 0x0050: // QSF-P256-MLKEM768-SHAKE256-SHA3256 + pqRand, tRand := randBytes[:32], randBytes[32:] + k := p256KeyFromSeedQSF(t, tRand) + testingOnlyGenerateKey = func() *ecdh.PrivateKey { return k } + t.Cleanup(func() { testingOnlyGenerateKey = nil }) + testingOnlyEncapsulate = func() ([]byte, []byte) { + ct, ss, err := mlkem768.EncapsulateDerand(pubPQ.Bytes(), pqRand) + if err != nil { + t.Fatal(err) + } + return ss, ct + } + t.Cleanup(func() { testingOnlyEncapsulate = nil }) + case 0x647a: // QSF-X25519-MLKEM768-SHAKE256-SHA3256 + pqRand, tRand := randBytes[:32], randBytes[32:] + k, err := ecdh.X25519().NewPrivateKey(tRand) + if err != nil { + t.Fatal(err) + } + testingOnlyGenerateKey = func() *ecdh.PrivateKey { return k } + t.Cleanup(func() { testingOnlyGenerateKey = nil }) + testingOnlyEncapsulate = func() ([]byte, []byte) { + ct, ss, err := mlkem768.EncapsulateDerand(pubPQ.Bytes(), pqRand) + if err != nil { + t.Fatal(err) + } + return ss, ct + } + t.Cleanup(func() { testingOnlyEncapsulate = nil }) default: - return nil, errors.New("unsupported KEM") + t.Fatal("unsupported KEM") } } -func parsePrivateKey(kemID uint16, keyBytes []byte) (*ecdh.PrivateKey, error) { +func parsePrivateKey(t *testing.T, kemID uint16, keyBytes []byte) (*ecdh.PrivateKey, *mlkem.DecapsulationKey768) { switch kemID { case 0x0010: // DHKEM(P-256, HKDF-SHA256) - return ecdh.P256().NewPrivateKey(keyBytes) + k, err := ecdh.P256().NewPrivateKey(keyBytes) + if err != nil { + t.Fatal(err) + } + return k, nil + case 0x0050: // QSF-P256-MLKEM768-SHAKE256-SHA3256 + s := sha3.NewSHAKE256() + s.Write(keyBytes) + exp := make([]byte, mlkem.SeedSize+48) + s.Read(exp) + + pq, err := mlkem.NewDecapsulationKey768(exp[:mlkem.SeedSize]) + if err != nil { + t.Fatal(err) + } + k := p256KeyFromSeedQSF(t, exp[mlkem.SeedSize:]) + return k, pq + case 0x647a: // QSF-X25519-MLKEM768-SHAKE256-SHA3256 + s := sha3.NewSHAKE256() + s.Write(keyBytes) + exp := make([]byte, mlkem.SeedSize+32) + s.Read(exp) + + pq, err := mlkem.NewDecapsulationKey768(exp[:mlkem.SeedSize]) + if err != nil { + t.Fatal(err) + } + k, err := ecdh.X25519().NewPrivateKey(exp[mlkem.SeedSize:]) + if err != nil { + t.Fatal(err) + } + return k, pq default: - return nil, errors.New("unsupported KEM") + t.Fatalf("unsupported KEM %04x", kemID) + panic("unreachable") } } diff --git a/tag/internal/hpke/testdata/hpke-pq.json b/tag/internal/hpke/testdata/hpke-pq.json new file mode 100644 index 00000000..caabcdca --- /dev/null +++ b/tag/internal/hpke/testdata/hpke-pq.json @@ -0,0 +1,320 @@ +[ + { + "mode": 0, + "kem_id": 25722, + "kdf_id": 1, + "aead_id": 3, + "info": "34663634363532303666366532303631323034373732363536333639363136653230353537323665", + "encap_rand": "19f270b5955d8c21d2033111b9d16d0c06c282a75eea4f7dc945e0939d6fa8d983985a0d098204532afa26bb0df2442d900999c8f6d53d1e619633a2270ea622", + "ikmR": "b78c64611bd91ab62f5d75855092796fae54f28863d47c58d3973b3748b0196b", + "skRm": "b78c64611bd91ab62f5d75855092796fae54f28863d47c58d3973b3748b0196b", + "pkRm": "193c149214bfa9d3c14ee192e5844cea7a0a3066b196a6527feba3f41370d65572bc65a5a80709867a299b5877a6a94f6e034576f58d3d534e05bba480e41ac5a63110c67e0bd4bea5e82fa2d0a5cbe6905001be7b267d3e283163c49569f9b343f56c4acaa55e5b24667b45aa86503c2780f5b97df0b29561790ad4f2118ca12daaeb9385d61e2703b6880c0acf8b9b19ec1a578c97f7aa036db2488f840094187dfd6067e6f744d9e50e4b441fc564308426ca92dcc89d41b28125a9544c4a2dbaacd43b8817699ebb104c1937be19e349cf5918a2d35c8ba301d4691b3d657b1d032971835b3a7cb4d554171f83a55f3455d1d34e44cb2f8ce699416c5a4eb79adefc3525b42adb433500c2987c0459e736a05399628b4179fd246ea06b090be610064856be191369b93d900a88fc39c93a941c37583529ec5757ab21004c3f97fc11ead8a015d75a5d9355c1287c84a36086e209b05a3038c2a64e21672bea1ff7c385904b099f747791161ca8b09bf222b3a4f615679b959f6424cf520078c42e7545ad80cb6a17d5be8f711a208653293782cc3c94a091cac50bb3b55b13f8c234f4c70b6bfa72e09357548386e785b523571a0c51a6874567a35b037e01b83340a575275c022a2426b0380a4a0f9f0491b7bacee32bbf8eab8a70e62abe31292b7503f274063415c7d676a862d2175738069a0644e8ab95bef87b1568192edc7a05a77843c70bbebb98f24cce20cc0aceb3713e6065b69b9e29987eaba0c669d3c7c3e8221f4680bf45825042892bb0556a694b7d954498435949917093581ee09a0c874c6a323c7d7447472c192cd5e3007742092369c606ab9e2279bbd06c7d2bcc8e43cc69487604294a457580af5962125fe25664e5c2abeb4be3b1943e9b5dfd14a68b873c7b3049647c2188d1b93b7947b38bc7665043193252f1f6450d640832b33cc7274eaa72734720afc0b9b2fed7c838a3778769299761508d507b76ccce76525c822b9a4f8ca3165c3fcd35b0af2c4d3d34c5aec05dd1fcc0222093f7ca5fec1a9ae0c48fc37862c1582d9c042fbdfca0de8550dbda839cf48c1e816bff17c8d7f5470f2463ea77c9c84a42737b3117e2a3086607543514927bac51b5b5d258bc71204519ec980927c2d2c31fe9d86a5b3869654230013583bff3a7c99210817588853acf6350c1976bbb9df0a82ee6a7a2d8305c575a46b570bfd39dde7754ef6273a1b30b78ec85dc6c1bcc49c6a7bc1591e4384acb13d2dbbd58a7153bdcb07e727fb765cd0b57667da0c32ec254c393a171c6abf3b88531827307d95f40b78f4c152183544f77f548e6c064a7aa567e40ae2ce0912e09b3f24169c78528121b10618c6e17341e7b8b26c9d821e584503c786102fb9110dac136fa52ce3acb9b492c9632aa84f7674898620119c1bf1103a6405fdcb25b40928857bb54a9297d80bc1cca7639b9f4422a666b3bd122e9c9baabe1a04dd950c3919784c647823b6f472673fc0012b960b546d17b7fa94d27d836c5709f86a9022a382518172fd6aa5be0a373ab12bd93687f6a52743f685f62b1472d5b0b59a740d484c56a2bcf0df47133dc95aa37529dda6b36679e1a924df2c88ee180134f41bdb90c4e77b2fc9be781a77f7e9beeb7badd04f884b7547b0279ece1cef0486d045bf812a32d802ff5f5a54549e93ebcab143e7b70c46148421f2d", + "enc": "c40b97a1af0f4066fa9626ba68b1980185dac3a7cb1ad6ca63650e78b4d305ed2ee557ae5dbd3df9c807cce1aa88d739d35feb4d06735484cd8507cd4eeb4c0fb2c1abef0c7e0cb1177841ba7397d8f6a1a2d226de046659903df93cd26322786b1626cd86579d03cc9d5c568bdb6123826380f46e2990fa9bae9dffb0126ca61da6326528a215c84bbd401999b9861cb81b8cca0ac72298dfa400589ba91c87dc1bc48981439bf02120637fac354e5bfea3b0c6de84b2e726a450b791ab38b96fadc74f6348118f9359b9eaf860463c76f7d1d9aafb213230bdf4dd9cb536c8a5c913307fea36dbffc53b59dbb6b88fb71c4526508eed79cb33d59c996e828e89ef29383a6236605020fdb1202b5aa1e7b30c54da7490e8228cfa460f95a0c17778609acac11ae969c54543f05078568b330ea6795aab1d049ebf871881a3192da35d045b3bbd6e5542fff3d060e880e6dc10cc27c123b003aa8c5e0ca36fea6bd20c34ad8ac8b759df0c87e3fa780a1c75b543a7d85ec7ee187eab34d53477cde9dd22503e602ccc9fa9bf725a0c6a176058ae05b2e44d5b790a46b432ca3f9c69b83f49c1b71b8585cdc0a4bc676f1d8744bf9d8a036c2628b11281ac2f243113a08dd81716f88c697a2b123c2e8bd61c74ce4d64edaca7313aaaa877f6f8c9c1f766aabf8734e6ac6117b7c3e8c1dfc1cd6f082f675cf28507cc09162b441180daaf1784f702c173f914478e1bef288e0d5ed644fa4e3d66e8e1de8fd9fc32a4f0cf31791c84d4a362ffd730caa6bbc0ee9d2d2e41422b5f4337b55566512580b5866bcd5390fa72b6a07396281818e2cfefbdef7c9859d4cde52ec098ef802c835eb7eeb453f4ad819a90929b2664533f427f56709f238753d284894bb0393d2dd7c3f31c80535764f5d4e20062b8fa7a025f1583aa3b8c145d064ce22730e68bc322d78d225110b0851f193555c3e07e517f3e79b445f5d7356f1ca681f7c678a018f250b18a08c6b87d83415707c33404609c969b6865cb5f0bdbc78f736b790ef588fee12fb38ea7f083608ab362e3c72a5e8b6c086223ecd234e1358f50823e11099337134bbec85a203e32b5915ca100c9fcd505b9f36055fff011e2f691f57fc6aa567b79492e8cbca09e174c2d2446b240ad20c9d9c16b321c1cdb16c709b1130ff0b3456abe305a9e2c0a514ce41251044fdc83b99d20c03b62b7bfd5a3e3bc4df60e5cbc7544de9d34c97d0bf9248d30b7eb686f0c520624eff3a60cd9b5999a3fef79184d0c63d1dde140de5a92d3f997ca4ceb0f002e5437e7ce6dd6640668dbc5afeb5795b65720f87f363fda1a0f9fcc38ba21e8f9b280083049a3378c6c119fe761759faf6e79d1fb487447b87e4daff98f36521bff863f9556ba7f0fbcc6c99c9b6c1db9f278fa6bf9dcc24790825947ed8b1b9404372e38fe07dee698b47c449b608718f92ef66dc19658db870997e21c1c262ab89a68504286214a03cd291d2a41d749c5f56633542f50ebdeaccbdfcbc7999ab8880319b9ce0ef73d15958f5f168b8ad29e108a20186d906567497f5b2218d06a35", + "shared_secret": "51d7c1b3038e92e6e402de53a915c490c0a3333f7a56fd4c5ab9e2d9e29c8e80", + "suite_id": "48504b45647a00010003", + "key": "edce21c81f84b213a0997c408a566fa901f97dcd27427d2bb94ca660119a8683", + "base_nonce": "2fda4392a0b23a352d70f0e0", + "exporter_secret": "0f2816531c4db55d8d2e45c0d55ff00fa07cfa98216e4dbe58632b8e0160d5c9", + "encryptions": [ + { + "aad": "436f756e742d30", + "ct": "b177c7b2d4d4dd604722231ee430f4c6334bfef1fe7bd9e95782f7995e37dcc0a2b902a9675cde8fd05f6d9a0f2f109de0533e16c34ed557516050eb9d620d2768e05278d451aeb390c9", + "nonce": "2fda4392a0b23a352d70f0e0", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d31", + "ct": "c4cc0bf69d0a6f059a61f32243955851757274af96075ba473cafa2615bbc1abc0fd1b8522213ea7b93a77881ce531818644266d2deb9c9ec2cea9c2922fceaa7e79b2d32678590a450c", + "nonce": "2fda4392a0b23a352d70f0e1", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d32", + "ct": "249a52262884fd861965d335eab7f6674460177390f607b83b9ac26c126d28141bffd5538607c73e9b1a3f2931e1e65f00034189a062d80f2560c00f24b506cab0d02d4ab95a3260d58e", + "nonce": "2fda4392a0b23a352d70f0e2", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d33", + "ct": "829ec3fb97f05b6ca0c392add4fa3ff518256c739072b84fc78315e15fc0cca9b129f02313729af796d27f0155fdb0e65dbc1c1bb68022a0ee47545d88fbcc3ec60e978f01656faa1a11", + "nonce": "2fda4392a0b23a352d70f0e3", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d34", + "ct": "bab611de27d0d7f951e4f9368d6486d51104da8e887ad8e20637b9db506fde90034f29610f88e0ef70358f836366b9c20b83999cd3a5076a6256e20223e172ed54b15e4b2548caeffdff", + "nonce": "2fda4392a0b23a352d70f0e4", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d35", + "ct": "fb480f295b8f3e41c5cfa7aa3064f727e648c2d43982dcbcc1d04f8c2f538055bda8aac03e9c7a77eb50eef87a3dd3f0a47fb7230433e13d91ff194912846f69effe3f4a223eb7e965fc", + "nonce": "2fda4392a0b23a352d70f0e5", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d36", + "ct": "0da3d3112e53368b006bbd510d33769f3487fec9563f62abac563287a3a53ad032591cb57128a12fcbe689be33e92b02e96ecb5c12fed23aa517ef7be59f4dca9060ca359c971e014a2f", + "nonce": "2fda4392a0b23a352d70f0e6", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d37", + "ct": "8c3a4ca5373704e664f5266cff459b40f0d2e1b855134b7e668453eb95bce1b7a09eefffcae866b7bd886a9aaba10dc1b012cf1037b0d80963c71aaf2c619ac877144dbf01a9d57390df", + "nonce": "2fda4392a0b23a352d70f0e7", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d38", + "ct": "144e33c04ab411a116253b6858ea27d57ccb0b247f83c013d10193e50010504155b4b8b4572b83410b8f008749b873d5c159027334c074762f860f4d60e41c207838e0f444545af1152c", + "nonce": "2fda4392a0b23a352d70f0e8", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d39", + "ct": "d92a59325eac0496a2c33c48e12bc76b637150d4db794ad5c8ea14bf8c742351b6e74b65464dc50af0ec4d364219ed36f56ece2b5260e850d5c24660e2240f440e07fdb462be20fac811", + "nonce": "2fda4392a0b23a352d70f0e9", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + } + ], + "exports": [ + { + "exporter_context": "70736575646f72616e646f6d30", + "L": 32, + "exported_value": "dfe6bdeea3a82eef95414480fdda8ecf9ea8d43846dd86072348a07183b52215" + }, + { + "exporter_context": "70736575646f72616e646f6d31", + "L": 32, + "exported_value": "cef76709dd0e384a97a5babcb77b221c2c30b223d3926b4b353b64767dbabedf" + }, + { + "exporter_context": "70736575646f72616e646f6d32", + "L": 32, + "exported_value": "b12381e05242f2a609545b9a3d826ecf2183f871314ee52c6f64760c6f636c86" + }, + { + "exporter_context": "70736575646f72616e646f6d33", + "L": 32, + "exported_value": "f0245f260ba33d995197bf06e6b8330cdac8b72dc3fc6e712ed4d3f6241b93dd" + }, + { + "exporter_context": "70736575646f72616e646f6d34", + "L": 32, + "exported_value": "2b279cb0c27ecce59d305e369b31b2c467a60946014278ca8bdca12c384f7a8d" + } + ] + }, + { + "mode": 0, + "kem_id": 16, + "kdf_id": 1, + "aead_id": 3, + "info": "34663634363532303666366532303631323034373732363536333639363136653230353537323665", + "encap_rand": "ae3c5f0e0d711f220f174b948620b6a9a84931f1510a1e78fe75735ffa585c29", + "ikmR": "f60dafbaa4dae9c499d09cedd84143297a66c23097bc4e69d1e5c89d1d6d7fc2", + "skRm": "5d1e0a06d9d5159783d89efb66b82fdf81f16f1ff5cd81e39a117275312a80d0", + "pkRm": "0469d46c0d5acbf0813fec4cbf81309675e822b6740983a55d5eb905d5e07a86dc70378bbfa6a1d9aa7269f98eeed2d9882346d5b0f5477e84918445853c267065", + "enc": "04885fb4ad2c5088593ee72afb295a709684ba2c016561b27d62d4fc39d2c884e5df85d77d3366d922726ebe95fd3aa2b8019fc1cde75b53684f21e8612ff48f6c", + "shared_secret": "3b26aca70d3510ae3acab4c117ede13249de20dc1fad0b0c6262137457c333e8", + "suite_id": "48504b45001000010003", + "key": "7aaed1082c439b9520fdaf4b7da76a52e53f92b592f35266b3683a72297436d2", + "base_nonce": "4e5d41b58438f9ddd494a510", + "exporter_secret": "c01ee22ac8dd68260f02c9e29aa0745819a8327443eadcf6a4e57c69c613ffb8", + "encryptions": [ + { + "aad": "436f756e742d30", + "ct": "1e26c3096c6c768dcc2ec1ec41d188bbeaf6bc6c6bd24e43ba313fac8d9eb3140592204e29cd1ee40aa5ccfacdd2c36e6cad773baf7bb9cfa8f53e626a4728d910043eda957fc01b04c8", + "nonce": "4e5d41b58438f9ddd494a510", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d31", + "ct": "e3a595f805e18a948dc5a84cdf693f8903ead0ab73c7cc9bdab7f749d607967c7672f2ffced518116bf55a1db2157b73079ea4cb1a07b3df6826858e609e0e1a1900e6a6a867ff33d8f3", + "nonce": "4e5d41b58438f9ddd494a511", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d32", + "ct": "0dd72194de92d55f648f029ccf7665a4635356c3c0ad9d9877ca99bfeb0c26f0a9194c00e025019dccb2015bfabed58542798caf305a25d03b934add6c8894632c9490cab1d5d8b8fb52", + "nonce": "4e5d41b58438f9ddd494a512", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d33", + "ct": "053ef6c31808e8c250debc14794e135b077441746b4760f13fe4a90cbeffcaf63ae284c1156aa0db6a9f8d5dc8189e6e9d284f0dbe96b8dea196a631d0f287ff3bb3df185a136b6e4a12", + "nonce": "4e5d41b58438f9ddd494a513", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d34", + "ct": "d8add82d80ba3e65c72d5b34a9edd19980ca2fc28b473867c4205d4fc7b4f0a3d84070624d4f6d922311e80dc4de402f90a745cd58bf022b00852ecc4187c27da22518b4132121601562", + "nonce": "4e5d41b58438f9ddd494a514", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d35", + "ct": "d13fbdda5d6048bbdef281fb2207487f71144c0e4025c439eac0a73f679e0c2f3ac2cf748f1a3af8607434507f85f63f06f9ee1891a951b3d5222c807acbad3759fe49e0b0cd86fa4872", + "nonce": "4e5d41b58438f9ddd494a515", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d36", + "ct": "f05bcdce13576896bc8044965477e1c2450cec84ceaad9a04c93fae1ee2863fbe9c6d1944f681b7aa44ae803e9849b8a5f47ea6464f26f1b5e19af28daa56ff5cc969f5d21ce061fc446", + "nonce": "4e5d41b58438f9ddd494a516", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d37", + "ct": "5c6fe7f4e7f61305e99f8de862102d00bb0300f5342c78b535a2159359d5fe7512bd3232988e97e6b46a988e3e0cf1207e749e3d2206631c994792dc1075f8d63f49ec8f02956239dce0", + "nonce": "4e5d41b58438f9ddd494a517", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d38", + "ct": "e7af3bea35ed4d63b9a4a9a9125112e13e783620f6767c92cf1daad63a27c6dd68f87d17009e70094b2ca9eee4ffaabc9a45d966fd74c7ac2cae4aa3bf4007dae88d7929a2623569ce04", + "nonce": "4e5d41b58438f9ddd494a518", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d39", + "ct": "082552f1ca06613e68279ef115742dd353259260ca055b38436d570f3dc404e335baddd4368ae21cc24e1e1850e97d7580659c0796221ed37b561732a01bc2234bc4dc653af483e88955", + "nonce": "4e5d41b58438f9ddd494a519", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + } + ], + "exports": [ + { + "exporter_context": "70736575646f72616e646f6d30", + "L": 32, + "exported_value": "4d06b6f07867c5031ba504e4f5467c316fca853f4f79b8246311af7d4bb77263" + }, + { + "exporter_context": "70736575646f72616e646f6d31", + "L": 32, + "exported_value": "304ca02f1e562aff01e6059955a8c1aff4189e5b274e8d2f4f46cb56333fa48b" + }, + { + "exporter_context": "70736575646f72616e646f6d32", + "L": 32, + "exported_value": "25003705b62981a81cb6a6b7c5766f62163f2e52bac488ae47e10a2eb439a68e" + }, + { + "exporter_context": "70736575646f72616e646f6d33", + "L": 32, + "exported_value": "c787490330f5c4b667f2f51a03117c36e983b87e1cc7c2c8def1e0df42cbd034" + }, + { + "exporter_context": "70736575646f72616e646f6d34", + "L": 32, + "exported_value": "157124564aab5e3ec50342ed8b76cff77ff589314b636b66df4d8aff5f96716e" + } + ] + }, + { + "mode": 0, + "kem_id": 80, + "kdf_id": 1, + "aead_id": 3, + "info": "34663634363532303666366532303631323034373732363536333639363136653230353537323665", + "encap_rand": "bd31d63122a4c4cf37244a31ba6acec390ce06f412ad3cbef973c03f3a32602e899409cbd7b4f9ea2a29d5f45952dc1368836b7d1b2a627e1fa94bcc799fcdd20d63f763872837ffe279632acc12d85f", + "ikmR": "9ed4e7555bc2c65f43e2ff3a5beb826daca1a79e7bc89ddae587659ac87e82fb", + "skRm": "9ed4e7555bc2c65f43e2ff3a5beb826daca1a79e7bc89ddae587659ac87e82fb", + "pkRm": "65070243e439adeac9e3bc7bb3c569d22027d1009640b7ae404a6ed0849c2f9599e16943d105b81a196f1f2990ac0204f8e35041032cddb1170a39c79c79568b01bcb62549fcc78bde6546387ac5e64767a1ca9f65cc2fd8704ad199c90d8184257cac4e853d0a0c89eed239f3d5bb52a4005f11cce49bb7569b8553968923056902fa5ac080bc80e404116143eba0210f2b43231a28058824f7136fcb1b14759a3e81e5ca292505747659c488abcf25219ac7cfa5887b2bba7a9754922148cd45054e3b5b57ac9b6666b17f13787284359eb695109923491513930d173dc8c2708dabc6303cac77eb51f349a31ed3c108fb8a3f8537247bc7fae93c706b264ca0323e122c5b5b78b4f0c385867fdb6440ecd804eff8be81362043156740a183016a4f418573db6ccbedc9b9dcc9347eca8912f46bcf02a115c67b206994becc3104c45f04154a0aac9391d57e2d835479385eea571db7921ccbb35575ca8bae196a9784a4de76ac6d003d6be1a090dbc993795e39f5243924717b8462e2e7b564b7770e81abcf47a96abb872c758d68d7a0cb1ba00ee43fab1abd3435b31bc33f2c3b2a0b51c4c60c5e2c280bbfc423d07c31e4e6475ee138daab3d8c799d59d04eca814204e58168798e4188b53685c0df2085f006c709e3b63e7a16c3027a12ac081be6cc822376bdf8168e8253b1271d63501480b535127a068eeab7a7da2c26764dbb73b1f749a5ab572ef0904d7ed072782206df76a3e27570be88a33f364d65e5679d3c3ff3522f843396aa71536b3578fb8336f7f5adf1a59308007e5da949fc80332b40098da8611a0139e3315fc90c2454a2243759b8d1f6a5925685637a43a0f14199e9bce0213d5d20c808694680a01c75c057f3b19fb8218e18d3cad6743616e8814381a25a54a1a32a5f1f045288f5b567f03d7a052cd54400da215c2d7a0acc7c4fb0f06c1024a7100940e328843037449b04a0c2114937fa279ab94ec83a7e3b232df9b5a947c0a3bdf06b31544d253bbc0439050fd352149119d5526cf8c9abe565b6414b721b76422c752a4176205f82a3d89a0a4b5b20b56bb9bd0bb43c51814843ab8f402f34820cfadc71558713e41023373ab5bf0ac5a54541bfb0ad54e17e177a9b8ddb284741beb188c1e396006479044a12afe0281096a0c554b4a843c97d45ca4def0bb68e8345453c6899fa63c3208b98b60b48424cc2926e96514ca1d5bb09d2c3db043f29fcbeee064c8b860d9a01d094aacb2f351f76c70f2774b55a24a74fd29007f206a1acbde0044820cbb708e2a9b08a11910989accab8d001c7d83cb3063393e5bc1450577658b6b5ae1b9fe3c0c36e39658f33abba40ce7a357706186af3f621db8952fa7311d03c05a50274159009951233e6c15cfc2151b7a6c44596beb4090d9bb39c6b1aa75d3c638ad0768b2180dd672ab179654b144f8f1c237a5785315b162858570f1a2bd7a5201133cfa9eb0725f154011a960862bbf48bba699288966a4543a99f26ca44fa65aef8c4bb02f833b3685def4b270ecb470c3c7b0bc0ceb74137c64449963751989c63196acb64bc63ea9a48d96672f44035949b3c6439a5cec59433c335d832bbffc92a1f3e03e8a5d3272e35b725657f724711059361818c23285f53dca378840a4b5e04486919d414ea7b6203a1e71a8204b78696220a9f385232f097e28505c93b963ac7d2a3d6cc53794d14e70319332bc67c9b93c787fef6808dd84e10a208bb3890", + "enc": "0ec138280990dd2deeeb9ceba63f944e12e551e788dd268e8680622ec47a9500ad1f83f21f3ffffd764e318c86fce86ca75e5de9c9124d8ab5b4baa36b64743ed449738166677a6564c2ee9b4abd33d42c2c7e66bc9bf1fef96028214934deedd763be2e2567f6021aad99ba3a477d1565f4ae35a129549e620464daa564f7569db5a5d24ac6a01f43e7879885349c547d288fe253c3feadb244510b38cd65344477b2bff0e2b12db4f69b3cc0219b868d11a1a6d61ee2e76c1b598920c30149004b77d523d3991863df21011bd4ca589c1081880c00fce107292d0bfd770dd42a68e7b12ebb173766421986c0015ad7cabc1c191a26e84d692f167deac56cae31fc992a9fc6b2fd15e7c161f926d46e5d9ee478c90ea5195005f62de0d75beafeb828c2336c6070fc254e5aacd4ae74ff148b614468aaeeccd0c0089de312524e343b72805d71bd534c4e3daadeac64a1cc683dc311c917663d81e0a937f553e59c8f17ec754b476212c6a4e155c05ee2c2b79dcd3bc0e75aa2b7185a2345b981c710083574389f2710f2c658d6ae236ed0e8e75ccf3aaeb9bc34fd7251306128d2b3a3cd921c51f93ede6ae7a68192775efd4c242f80fa87142394615dd1a27e92fa4c9a7030f416e183c42b63e73ab2dd42775fbbc26e0040defd97530d1142da3f5bd0a3f021478e1c46f45f0ff520a474c067544fc3ead1d7782ee666082a40c1dfe12a7d679ca19e5a0775c444e48a7e1c6a4223178dabaa5f99b202cddf0834373e90f54368a7892b17784b4ec04ae7546d0cf29914d1672c9e67cae3fc92943487053f00ad23c1adfa4f70c4297dc68914eefcf9f0b37f06832651766a67ba5bce8a020433ae1fad356cdccecb9b8d37b8fc41217e1d5093ffd156aaca4fe7821151c03e730806ec770be5977e7e68ce1c02ffcfd20f773dece498f1683d568eb171f5122fa1e2ae1aa186a4782ca2adc40927f05b59b577fe0c7e95591b7936517780fdec2177eb1061347716e931f97f0d7da89fee1ea790564af8eaf7314aaf5196af51e8590d1bda044b0f504f0040f83798b22c1722bcf7a0bc713efed24dcbe2c30c77cdf4f22fda3a96ddebcf7c3546be2daf728bc7f312cff4b5706af353d74a519652cd274a0bd4116168a138d311da2877aa190803aef91455d9faa733eb0912177739f47b1951f7da23104f5b9e249f25270bdb0e58c290d3c64f681ab9202b42987d392d3f94d58c1e81a4f4e62293e95fb011cb2b8f49f5ca2fd85f399a386f91ead63bbd97cf2bb68b372cbd1b081ac9bcf14109ab0298f4b0d7c9b16f75090b1c49721b71747a357065c4833cccd52a2c7406f1c141e8cc669678b62fdfe119610dbdec0ec87b0ea54c71f64ea0598672b40becd957594e2c07d5cb0e90dc4378ed110a1b4450f42497c2efd699f91a774ca29a370f76d4fb04dd1ac3fa0779805587819ab6f1785a3330ef60aec8e707e945c776611f530f245e6f554ab408fc1193d0f08dd7773629132e65ae4f3e70bcb0a71004494def2db94f30c53f4c17e39ebf15efbae07810dce16868256c9abfb5b1c339e670c223739a81057db8e7d8bda8ecd9a7cdadaf4350f050ecd10e679137eb1e", + "shared_secret": "61765d0dc46c62192c9800d74ab8ec77c633810720055663fbe6a0d57bded97c", + "suite_id": "48504b45005000010003", + "key": "9b8e5d4f868de455a2f29cd8dc127513be97ec9e0f616e45b7ece8d8e55ac17d", + "base_nonce": "599544220e8706a477505030", + "exporter_secret": "dbfe1ea45410d7b5c857e41c5ce41fad4ba0d9a3ad5617b9a13e0fd26c62b6b9", + "encryptions": [ + { + "aad": "436f756e742d30", + "ct": "def109ce3c4d4d489e585b1cebf86c679665121425c729754c034036b914f7f0ca14c52da51419e2c9a677f1186994c8eb6e707f42acd66f07d15bba920eb87d6687158ebe8f9615215e", + "nonce": "599544220e8706a477505030", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d31", + "ct": "677528b4d1a8eb24b9964c95f7a844bd97e6b1e40eed75575326336e4d6fc57d9b41fc42bf2bd61152a76578ff5729279c5d975e2f4f1fae7263a9370a40c9ae28105064487ad4feb3ff", + "nonce": "599544220e8706a477505031", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d32", + "ct": "0d11a1ac1f6b6d29702c358793f440685b74505f8b0d318997a2fb6c451ccd633e342690120a9c767eb0614fe3cfd518484c974df0c1459881c897b7ed590785ccdea03baa086d5da33b", + "nonce": "599544220e8706a477505032", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d33", + "ct": "0f23a23ae250f808c2a29ba52c214b10ca6cc95fbbc99e48d35ac8bb38f9a251bceaac0d73ef168ec68f645e1cf3ef56175fd63608e4cf0eeb02aaa2005535530d77da6a80ab63aac334", + "nonce": "599544220e8706a477505033", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d34", + "ct": "5e3c3d049535375c3a6de075c1cc5b619b734c99b9c2be9e9413087fe66576a9ca7629094e3293fc8fb443c21464e9070dd8b14d31701bc61a9aa8dbe0a6c45531322f6616d96197c79c", + "nonce": "599544220e8706a477505034", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d35", + "ct": "f8ffd8ad031ea2e8dac99d810b2f969db2ca7c1a5d581f8a8f16a9db6b3cc7a8620ea16ff438568100c40b094cf951c53abc30c5ff6f2e2a25b3242ed04b193d29138de02c792d0b883c", + "nonce": "599544220e8706a477505035", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d36", + "ct": "fee465b871efa4e2f428f9a66458cfa2a7b99774ebeb630ed548f2fa22caaf5ab50e9fed77293aa312c7c5209100a97455dc92f4b4cb4c4e07b3547e7e73228e18f152c67915c71cdcac", + "nonce": "599544220e8706a477505036", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d37", + "ct": "582afc61ffb3ffd7704d76088d2c74cc2530f6bdd6593cbb2977239b6f484716bdedb0d6b1b129f45e1d4afc8f407d17fadd3a3d971c82f8369fd6772d5f2d5274cfff48c3d2db63a44d", + "nonce": "599544220e8706a477505037", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d38", + "ct": "81ce8e9ae429b85b4dd4b40d7f7bb426565d23f2c3d63f74ea96dbba881dfcfe33b95c8202ff37b15bb14f85d52de1a506c3e2b42d650e850fe97a63017670ee52815705dd61421589fc", + "nonce": "599544220e8706a477505038", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + }, + { + "aad": "436f756e742d39", + "ct": "691608a0e0e29d0c2def20bf6f0991bb7eccf57f26722975f3640b4dc23f4be29cfa60352ef3831e6b560e896c766a0126746c380dd3f695b82e4039549202ef01809ee5fde406f2c8a1", + "nonce": "599544220e8706a477505039", + "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" + } + ], + "exports": [ + { + "exporter_context": "70736575646f72616e646f6d30", + "L": 32, + "exported_value": "fe89a3b25515355f1e831529a2306040e6a9edb1643acab8bb9182dc09a029f5" + }, + { + "exporter_context": "70736575646f72616e646f6d31", + "L": 32, + "exported_value": "d79a2633bd36ff4220e355f381303398abc48ceca0491410791afb733c5fc882" + }, + { + "exporter_context": "70736575646f72616e646f6d32", + "L": 32, + "exported_value": "60a3cfd587ce2bf68235328399ca47ef336f63a2c16a57129aa824b8238e8e85" + }, + { + "exporter_context": "70736575646f72616e646f6d33", + "L": 32, + "exported_value": "678cbb9bda711aa6e4f1108db0fa9e9e0585764895d9adbd3b5c633d92dacdc0" + }, + { + "exporter_context": "70736575646f72616e646f6d34", + "L": 32, + "exported_value": "1df9fd502558a98bdd8c80b54d2811e9537850d6b8254567a23c6f049818fc17" + } + ] + } +] diff --git a/tag/internal/hpke/testdata/rfc9180-vectors.json b/tag/internal/hpke/testdata/rfc9180-vectors.json deleted file mode 100644 index 44dc4183..00000000 --- a/tag/internal/hpke/testdata/rfc9180-vectors.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "Name": "DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, ChaCha20Poly1305", - "Setup": "mode: 0\nkem_id: 16\nkdf_id: 1\naead_id: 3\ninfo: 4f6465206f6e2061204772656369616e2055726e\nikmE: f1f1a3bc95416871539ecb51c3a8f0cf608afb40fbbe305c0a72819d35c33f1f\npkEm: 04c07836a0206e04e31d8ae99bfd549380b072a1b1b82e563c935c095827824fc1559eac6fb9e3c70cd3193968994e7fe9781aa103f5b50e934b5b2f387e381291\nskEm: 7550253e1147aae48839c1f8af80d2770fb7a4c763afe7d0afa7e0f42a5b3689\nikmR: 61092f3f56994dd424405899154a9918353e3e008171517ad576b900ddb275e7\npkRm: 04a697bffde9405c992883c5c439d6cc358170b51af72812333b015621dc0f40bad9bb726f68a5c013806a790ec716ab8669f84f6b694596c2987cf35baba2a006\nskRm: a4d1c55836aa30f9b3fbb6ac98d338c877c2867dd3a77396d13f68d3ab150d3b\nenc: 04c07836a0206e04e31d8ae99bfd549380b072a1b1b82e563c935c095827824fc1559eac6fb9e3c70cd3193968994e7fe9781aa103f5b50e934b5b2f387e381291\nshared_secret: 806520f82ef0b03c823b7fc524b6b55a088f566b9751b89551c170f4113bd850\nkey_schedule_context: 00b738cd703db7b4106e93b4621e9a19c89c838e55964240e5d3f331aaf8b0d58b2e986ea1c671b61cf45eec134dac0bae58ec6f63e790b1400b47c33038b0269c\nsecret: fe891101629aa355aad68eff3cc5170d057eca0c7573f6575e91f9783e1d4506\nkey: a8f45490a92a3b04d1dbf6cf2c3939ad8bfc9bfcb97c04bffe116730c9dfe3fc\nbase_nonce: 726b4390ed2209809f58c693\nexporter_secret: 4f9bd9b3a8db7d7c3a5b9d44fdc1f6e37d5d77689ade5ec44a7242016e6aa205", - "Encryptions": "sequence number: 0\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d30\nnonce: 726b4390ed2209809f58c693\nct: 6469c41c5c81d3aa85432531ecf6460ec945bde1eb428cb2fedf7a29f5a685b4ccb0d057f03ea2952a27bb458b\n\nsequence number: 1\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d31\nnonce: 726b4390ed2209809f58c692\nct: f1564199f7e0e110ec9c1bcdde332177fc35c1adf6e57f8d1df24022227ffa8716862dbda2b1dc546c9d114374\n\nsequence number: 2\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d32\nnonce: 726b4390ed2209809f58c691\nct: 39de89728bcb774269f882af8dc5369e4f3d6322d986e872b3a8d074c7c18e8549ff3f85b6d6592ff87c3f310c\n\nsequence number: 4\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d34\nnonce: 726b4390ed2209809f58c697\nct: bc104a14fbede0cc79eeb826ea0476ce87b9c928c36e5e34dc9b6905d91473ec369a08b1a25d305dd45c6c5f80\n\nsequence number: 255\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d323535\nnonce: 726b4390ed2209809f58c66c\nct: 8f2814a2c548b3be50259713c6724009e092d37789f6856553d61df23ebc079235f710e6af3c3ca6eaba7c7c6c\n\nsequence number: 256\npt: 4265617574792069732074727574682c20747275746820626561757479\naad: 436f756e742d323536\nnonce: 726b4390ed2209809f58c793\nct: b45b69d419a9be7219d8c94365b89ad6951caf4576ea4774ea40e9b7047a09d6537d1aa2f7c12d6ae4b729b4d0" - } -] diff --git a/tag/tag.go b/tag/tag.go index 78326e41..ec8d9c73 100644 --- a/tag/tag.go +++ b/tag/tag.go @@ -7,6 +7,7 @@ package tag import ( "crypto/ecdh" "crypto/hkdf" + "crypto/mlkem" "crypto/sha256" "fmt" @@ -20,32 +21,44 @@ import ( type Recipient struct { kem hpke.KEMSender - compressed [33]byte - uncompressed [65]byte + mlkem *mlkem.EncapsulationKey768 + compressed [compressedPointSize]byte + uncompressed [uncompressedPointSize]byte } var _ age.Recipient = &Recipient{} // ParseRecipient returns a new [Recipient] from a Bech32 public key -// encoding with the "age1tag1" prefix. +// encoding with the "age1tag1" or "age1tagpq1" prefix. func ParseRecipient(s string) (*Recipient, error) { t, k, err := plugin.ParseRecipient(s) if err != nil { return nil, fmt.Errorf("malformed recipient %q: %v", s, err) } - if t != "tag" { + switch t { + case "tag": + r, err := NewRecipient(k) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + return r, nil + case "tagpq": + r, err := NewHybridRecipient(k) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + return r, nil + default: return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t) } - r, err := NewRecipient(k) - if err != nil { - return nil, fmt.Errorf("malformed recipient %q: %v", s, err) - } - return r, nil } +const compressedPointSize = 1 + 32 +const uncompressedPointSize = 1 + 32 + 32 + // NewRecipient returns a new [Recipient] from a raw public key. func NewRecipient(publicKey []byte) (*Recipient, error) { - if len(publicKey) != 1+32 { + if len(publicKey) != compressedPointSize { return nil, fmt.Errorf("invalid tag recipient public key size %d", len(publicKey)) } p, err := nistec.NewP256Point().SetBytes(publicKey) @@ -66,12 +79,44 @@ func NewRecipient(publicKey []byte) (*Recipient, error) { return r, nil } +// NewHybridRecipient returns a new [Recipient] from raw concatenated public keys. +func NewHybridRecipient(publicKey []byte) (*Recipient, error) { + if len(publicKey) != compressedPointSize+mlkem.EncapsulationKeySize768 { + return nil, fmt.Errorf("invalid tagpq recipient public key size %d", len(publicKey)) + } + p, err := nistec.NewP256Point().SetBytes(publicKey) + if err != nil { + return nil, fmt.Errorf("invalid tagpq recipient DH public key: %v", err) + } + k, err := ecdh.P256().NewPublicKey(p.Bytes()) + if err != nil { + return nil, fmt.Errorf("invalid tagpq recipient DH public key: %v", err) + } + pq, err := mlkem.NewEncapsulationKey768(publicKey[compressedPointSize:]) + if err != nil { + return nil, fmt.Errorf("invalid tagpq recipient PQ public key: %v", err) + } + kem, err := hpke.QSFSender(k, pq) + if err != nil { + return nil, fmt.Errorf("failed to create DHKEM sender: %v", err) + } + r := &Recipient{kem: kem, mlkem: pq} + copy(r.compressed[:], publicKey[:compressedPointSize]) + copy(r.uncompressed[:], p.Bytes()) + return r, nil +} + var p256TagLabel = []byte("age-encryption.org/p256tag") +var p256MLKEM768TagLabel = []byte("age-encryption.org/p256mlkem768tag") func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { + label, arg := p256TagLabel, "p256tag" + if r.mlkem != nil { + label, arg = p256MLKEM768TagLabel, "p256mlkem768tag" + } + enc, s, err := hpke.SetupSender(r.kem, - hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), - p256TagLabel) + hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), label) if err != nil { return nil, fmt.Errorf("failed to set up HPKE sender: %v", err) } @@ -80,13 +125,14 @@ func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { return nil, fmt.Errorf("failed to encrypt file key: %v", err) } - tag, err := hkdf.Extract(sha256.New, append(enc, r.uncompressed[:]...), p256TagLabel) + tag, err := hkdf.Extract(sha256.New, + append(enc[:uncompressedPointSize], r.uncompressed[:]...), label) if err != nil { return nil, fmt.Errorf("failed to compute tag: %v", err) } l := &age.Stanza{ - Type: "p256tag", + Type: arg, Args: []string{ format.EncodeToString(tag[:4]), format.EncodeToString(enc), @@ -99,5 +145,8 @@ func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { // String returns the Bech32 public key encoding of r. func (r *Recipient) String() string { + if r.mlkem != nil { + return plugin.EncodeRecipient("tagpq", append(r.compressed[:], r.mlkem.Bytes()...)) + } return plugin.EncodeRecipient("tag", r.compressed[:]) } From bf2ac3aa1624ac282fe96288cdb45451f394fa40 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 7 Sep 2025 14:11:07 +0200 Subject: [PATCH 03/14] tag: use filippo.io/hpke --- go.mod | 8 +- go.sum | 16 +- tag/internal/hpke/hpke.go | 477 ------------------------ tag/internal/hpke/hpke_test.go | 337 ----------------- tag/internal/hpke/testdata/hpke-pq.json | 320 ---------------- tag/internal/tagtest/tagtest.go | 157 ++++++++ tag/tag.go | 123 +++--- tag/tag_test.go | 109 ++++++ 8 files changed, 344 insertions(+), 1203 deletions(-) delete mode 100644 tag/internal/hpke/hpke.go delete mode 100644 tag/internal/hpke/hpke_test.go delete mode 100644 tag/internal/hpke/testdata/hpke-pq.json create mode 100644 tag/internal/tagtest/tagtest.go create mode 100644 tag/tag_test.go diff --git a/go.mod b/go.mod index 999619c1..8579734a 100644 --- a/go.mod +++ b/go.mod @@ -4,16 +4,16 @@ go 1.24.0 require ( filippo.io/edwards25519 v1.1.0 + filippo.io/hpke v0.4.0 filippo.io/nistec v0.0.3 - golang.org/x/crypto v0.24.0 - golang.org/x/sys v0.21.0 - golang.org/x/term v0.21.0 + golang.org/x/crypto v0.45.0 + golang.org/x/sys v0.38.0 + golang.org/x/term v0.37.0 ) // Test dependencies. require ( c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 - filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb github.com/rogpeppe/go-internal v1.12.0 golang.org/x/tools v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 70c89dcd..13c692de 100644 --- a/go.sum +++ b/go.sum @@ -2,17 +2,17 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3I c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb h1:9eVxcquiUiJn/f8DtnqmsN/8Asqw+h9b1+sM3T/Wl44= -filippo.io/mlkem768 v0.0.0-20250818110517-29047ffe79fb/go.mod h1:ncYN/Z4GaQBV6TIbmQ7+lIaI+qGXCmZr88zrXHneVHs= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= filippo.io/nistec v0.0.3 h1:h336Je2jRDZdBCLy2fLDUd9E2unG32JLwcJi0JQE9Cw= filippo.io/nistec v0.0.3/go.mod h1:84fxC9mi+MhC2AERXI4LSa8cmSVOzrFikg6hZ4IfCyw= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= -golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= diff --git a/tag/internal/hpke/hpke.go b/tag/internal/hpke/hpke.go deleted file mode 100644 index 37c55ba4..00000000 --- a/tag/internal/hpke/hpke.go +++ /dev/null @@ -1,477 +0,0 @@ -// Copyright 2024 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package hpke - -import ( - "crypto/cipher" - "crypto/ecdh" - "crypto/hkdf" - "crypto/mlkem" - "crypto/rand" - "crypto/sha256" - "crypto/sha3" - "encoding/binary" - "errors" - "hash" - "math/bits" - - "golang.org/x/crypto/chacha20poly1305" -) - -type KEMSender interface { - Encap() (sharedSecret, enc []byte, err error) - ID() uint16 -} - -type KEMRecipient interface { - Decap(enc []byte) (sharedSecret []byte, err error) - ID() uint16 -} - -type dhKEM struct { - kdf KDF - id uint16 - nSecret uint16 -} - -func (dh *dhKEM) extractAndExpand(dhKey, kemContext []byte) ([]byte, error) { - suiteID := binary.BigEndian.AppendUint16([]byte("KEM"), dh.id) - eaePRK, err := dh.kdf.LabeledExtract(suiteID, nil, "eae_prk", dhKey) - if err != nil { - return nil, err - } - return dh.kdf.LabeledExpand(suiteID, eaePRK, "shared_secret", kemContext, dh.nSecret) -} - -func (dh *dhKEM) ID() uint16 { - return dh.id -} - -type dhkemSender struct { - dhKEM - pub *ecdh.PublicKey -} - -// DHKEMSender returns a KEMSender implementing DHKEM(P-256, HKDF-SHA256). -func DHKEMSender(pub *ecdh.PublicKey) (KEMSender, error) { - switch pub.Curve() { - case ecdh.P256(): - return &dhkemSender{ - pub: pub, - dhKEM: dhKEM{ - kdf: HKDFSHA256(), - id: 0x0010, - nSecret: 32, - }, - }, nil - default: - return nil, errors.New("unsupported curve") - } -} - -// testingOnlyGenerateKey is only used during testing, to provide -// a fixed test key to use when checking the RFC 9180 vectors. -var testingOnlyGenerateKey func() *ecdh.PrivateKey - -func (dh *dhkemSender) Encap() (sharedSecret []byte, encapPub []byte, err error) { - privEph, err := dh.pub.Curve().GenerateKey(rand.Reader) - if err != nil { - return nil, nil, err - } - if testingOnlyGenerateKey != nil { - privEph = testingOnlyGenerateKey() - } - dhVal, err := privEph.ECDH(dh.pub) - if err != nil { - return nil, nil, err - } - encPubEph := privEph.PublicKey().Bytes() - - encPubRecip := dh.pub.Bytes() - kemContext := append(encPubEph, encPubRecip...) - sharedSecret, err = dh.extractAndExpand(dhVal, kemContext) - if err != nil { - return nil, nil, err - } - return sharedSecret, encPubEph, nil -} - -type dhkemRecipient struct { - dhKEM - priv *ecdh.PrivateKey -} - -// DHKEMRecipient returns a KEMRecipient implementing DHKEM(P-256, HKDF-SHA256). -func DHKEMRecipient(priv *ecdh.PrivateKey) (KEMRecipient, error) { - switch priv.Curve() { - case ecdh.P256(): - return &dhkemRecipient{ - priv: priv, - dhKEM: dhKEM{ - kdf: HKDFSHA256(), - id: 0x0010, - nSecret: 32, - }, - }, nil - default: - return nil, errors.New("unsupported curve") - } -} - -func (dh *dhkemRecipient) Decap(encPubEph []byte) ([]byte, error) { - pubEph, err := dh.priv.Curve().NewPublicKey(encPubEph) - if err != nil { - return nil, err - } - dhVal, err := dh.priv.ECDH(pubEph) - if err != nil { - return nil, err - } - kemContext := append(encPubEph, dh.priv.PublicKey().Bytes()...) - return dh.extractAndExpand(dhVal, kemContext) -} - -type qsf struct { - id uint16 - label string -} - -func (q *qsf) ID() uint16 { - return q.id -} - -func (q *qsf) sharedSecret(ssPQ, ssT, ctT, ekT []byte) []byte { - h := sha3.New256() - h.Write(ssPQ) - h.Write(ssT) - h.Write(ctT) - h.Write(ekT) - h.Write([]byte(q.label)) - return h.Sum(nil) -} - -type qsfSender struct { - qsf - t *ecdh.PublicKey - pq *mlkem.EncapsulationKey768 -} - -// QSFSender returns a KEMSender implementing QSF-P256-MLKEM768-SHAKE256-SHA3256 -// or QSF-X25519-MLKEM768-SHA3256-SHAKE256 (aka X-Wing) from draft-ietf-hpke-pq -// and draft-irtf-cfrg-concrete-hybrid-kems-00. -func QSFSender(t *ecdh.PublicKey, pq *mlkem.EncapsulationKey768) (KEMSender, error) { - switch t.Curve() { - case ecdh.P256(): - return &qsfSender{ - t: t, pq: pq, - qsf: qsf{ - id: 0x0050, - label: "QSF-P256-MLKEM768-SHAKE256-SHA3256", - }, - }, nil - case ecdh.X25519(): - return &qsfSender{ - t: t, pq: pq, - qsf: qsf{ - id: 0x647a, - label: /**/ `\./` + - /* */ `/^\`, - }, - }, nil - default: - return nil, errors.New("unsupported curve") - } -} - -var testingOnlyEncapsulate func() (ss, ct []byte) - -func (s *qsfSender) Encap() (sharedSecret []byte, encapPub []byte, err error) { - skE, err := s.t.Curve().GenerateKey(rand.Reader) - if err != nil { - return nil, nil, err - } - if testingOnlyGenerateKey != nil { - skE = testingOnlyGenerateKey() - } - ssT, err := skE.ECDH(s.t) - if err != nil { - return nil, nil, err - } - ctT := skE.PublicKey().Bytes() - - ssPQ, ctPQ := s.pq.Encapsulate() - if testingOnlyEncapsulate != nil { - ssPQ, ctPQ = testingOnlyEncapsulate() - } - - ss := s.sharedSecret(ssPQ, ssT, ctT, s.t.Bytes()) - ct := append(ctPQ, ctT...) - return ss, ct, nil -} - -type qsfRecipient struct { - qsf - t *ecdh.PrivateKey - pq *mlkem.DecapsulationKey768 -} - -// QSFRecipient returns a KEMRecipient implementing QSF-P256-MLKEM768-SHAKE256-SHA3256 -// or QSF-MLKEM768-X25519-SHA3256-SHAKE256 (aka X-Wing) from draft-ietf-hpke-pq -// and draft-irtf-cfrg-concrete-hybrid-kems-00. -func QSFRecipient(t *ecdh.PrivateKey, pq *mlkem.DecapsulationKey768) (KEMRecipient, error) { - switch t.Curve() { - case ecdh.P256(): - return &qsfRecipient{ - t: t, pq: pq, - qsf: qsf{ - id: 0x0050, - label: "QSF-P256-MLKEM768-SHAKE256-SHA3256", - }, - }, nil - case ecdh.X25519(): - return &qsfRecipient{ - t: t, pq: pq, - qsf: qsf{ - id: 0x647a, - label: /**/ `\./` + - /* */ `/^\`, - }, - }, nil - default: - return nil, errors.New("unsupported curve") - } -} - -func (r *qsfRecipient) Decap(enc []byte) ([]byte, error) { - ctPQ, ctT := enc[:mlkem.CiphertextSize768], enc[mlkem.CiphertextSize768:] - ssPQ, err := r.pq.Decapsulate(ctPQ) - if err != nil { - return nil, err - } - pub, err := r.t.Curve().NewPublicKey(ctT) - if err != nil { - return nil, err - } - ssT, err := r.t.ECDH(pub) - if err != nil { - return nil, err - } - ss := r.sharedSecret(ssPQ, ssT, ctT, r.t.PublicKey().Bytes()) - return ss, nil -} - -type KDF interface { - LabeledExtract(sid, salt []byte, label string, inputKey []byte) ([]byte, error) - LabeledExpand(suiteID, randomKey []byte, label string, info []byte, length uint16) ([]byte, error) - ID() uint16 -} - -type hkdfKDF struct { - hash func() hash.Hash - id uint16 -} - -func HKDFSHA256() KDF { - return &hkdfKDF{hash: sha256.New, id: 0x0001} -} - -func (kdf *hkdfKDF) ID() uint16 { - return kdf.id -} - -func (kdf *hkdfKDF) LabeledExtract(sid []byte, salt []byte, label string, inputKey []byte) ([]byte, error) { - labeledIKM := make([]byte, 0, 7+len(sid)+len(label)+len(inputKey)) - labeledIKM = append(labeledIKM, []byte("HPKE-v1")...) - labeledIKM = append(labeledIKM, sid...) - labeledIKM = append(labeledIKM, label...) - labeledIKM = append(labeledIKM, inputKey...) - return hkdf.Extract(kdf.hash, labeledIKM, salt) -} - -func (kdf *hkdfKDF) LabeledExpand(suiteID []byte, randomKey []byte, label string, info []byte, length uint16) ([]byte, error) { - labeledInfo := make([]byte, 0, 2+7+len(suiteID)+len(label)+len(info)) - labeledInfo = binary.BigEndian.AppendUint16(labeledInfo, length) - labeledInfo = append(labeledInfo, []byte("HPKE-v1")...) - labeledInfo = append(labeledInfo, suiteID...) - labeledInfo = append(labeledInfo, label...) - labeledInfo = append(labeledInfo, info...) - return hkdf.Expand(kdf.hash, randomKey, string(labeledInfo), int(length)) -} - -type AEAD interface { - AEAD(key []byte) (cipher.AEAD, error) - KeySize() int - NonceSize() int - ID() uint16 -} - -type aead struct { - keySize int - nonceSize int - aead func([]byte) (cipher.AEAD, error) - id uint16 -} - -func ChaCha20Poly1305() AEAD { - return &aead{ - keySize: chacha20poly1305.KeySize, - nonceSize: chacha20poly1305.NonceSize, - aead: chacha20poly1305.New, - id: 0x0003, - } -} - -func (a *aead) ID() uint16 { - return a.id -} - -func (a *aead) AEAD(key []byte) (cipher.AEAD, error) { - if len(key) != a.keySize { - return nil, errors.New("invalid key size") - } - return a.aead(key) -} - -func (a *aead) KeySize() int { - return a.keySize -} - -func (a *aead) NonceSize() int { - return a.nonceSize -} - -type context struct { - aead cipher.AEAD - suiteID []byte - - key []byte - baseNonce []byte - - seqNum uint128 -} - -type Sender struct { - *context -} - -type Recipient struct { - *context -} - -func newContext(sharedSecret []byte, kemID uint16, kdf KDF, aead AEAD, info []byte) (*context, error) { - sid := suiteID(kemID, kdf.ID(), aead.ID()) - - pskIDHash, err := kdf.LabeledExtract(sid, nil, "psk_id_hash", nil) - if err != nil { - return nil, err - } - infoHash, err := kdf.LabeledExtract(sid, nil, "info_hash", info) - if err != nil { - return nil, err - } - ksContext := append([]byte{0}, pskIDHash...) - ksContext = append(ksContext, infoHash...) - - secret, err := kdf.LabeledExtract(sid, sharedSecret, "secret", nil) - if err != nil { - return nil, err - } - key, err := kdf.LabeledExpand(sid, secret, "key", ksContext, uint16(aead.KeySize())) - if err != nil { - return nil, err - } - baseNonce, err := kdf.LabeledExpand(sid, secret, "base_nonce", ksContext, uint16(aead.NonceSize())) - if err != nil { - return nil, err - } - - a, err := aead.AEAD(key) - if err != nil { - return nil, err - } - - return &context{ - aead: a, - suiteID: sid, - key: key, - baseNonce: baseNonce, - }, nil -} - -func SetupSender(kem KEMSender, kdf KDF, aead AEAD, info []byte) ([]byte, *Sender, error) { - sharedSecret, encapsulatedKey, err := kem.Encap() - if err != nil { - return nil, nil, err - } - context, err := newContext(sharedSecret, kem.ID(), kdf, aead, info) - if err != nil { - return nil, nil, err - } - return encapsulatedKey, &Sender{context}, nil -} - -func SetupRecipient(kem KEMRecipient, kdf KDF, aead AEAD, info, enc []byte) (*Recipient, error) { - sharedSecret, err := kem.Decap(enc) - if err != nil { - return nil, err - } - context, err := newContext(sharedSecret, kem.ID(), kdf, aead, info) - if err != nil { - return nil, err - } - return &Recipient{context}, nil -} - -func (ctx *context) nextNonce() []byte { - nonce := ctx.seqNum.bytes()[16-ctx.aead.NonceSize():] - for i := range ctx.baseNonce { - nonce[i] ^= ctx.baseNonce[i] - } - return nonce -} - -func (ctx *context) incrementNonce() { - ctx.seqNum = ctx.seqNum.addOne() -} - -func (s *Sender) Seal(aad, plaintext []byte) ([]byte, error) { - ciphertext := s.aead.Seal(nil, s.nextNonce(), plaintext, aad) - s.incrementNonce() - return ciphertext, nil -} - -func (r *Recipient) Open(aad, ciphertext []byte) ([]byte, error) { - plaintext, err := r.aead.Open(nil, r.nextNonce(), ciphertext, aad) - if err != nil { - return nil, err - } - r.incrementNonce() - return plaintext, nil -} - -func suiteID(kemID, kdfID, aeadID uint16) []byte { - suiteID := make([]byte, 0, 4+2+2+2) - suiteID = append(suiteID, []byte("HPKE")...) - suiteID = binary.BigEndian.AppendUint16(suiteID, kemID) - suiteID = binary.BigEndian.AppendUint16(suiteID, kdfID) - suiteID = binary.BigEndian.AppendUint16(suiteID, aeadID) - return suiteID -} - -type uint128 struct { - hi, lo uint64 -} - -func (u uint128) addOne() uint128 { - lo, carry := bits.Add64(u.lo, 1, 0) - return uint128{u.hi + carry, lo} -} - -func (u uint128) bytes() []byte { - b := make([]byte, 16) - binary.BigEndian.PutUint64(b[0:], u.hi) - binary.BigEndian.PutUint64(b[8:], u.lo) - return b -} diff --git a/tag/internal/hpke/hpke_test.go b/tag/internal/hpke/hpke_test.go deleted file mode 100644 index 96e89779..00000000 --- a/tag/internal/hpke/hpke_test.go +++ /dev/null @@ -1,337 +0,0 @@ -// Copyright 2024 The Go Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package hpke - -import ( - "bytes" - "crypto/ecdh" - "crypto/elliptic" - "crypto/mlkem" - "crypto/sha3" - "encoding/binary" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "math/big" - "os" - "testing" - - "filippo.io/mlkem768" -) - -func mustDecodeHex(t *testing.T, in string) []byte { - t.Helper() - b, err := hex.DecodeString(in) - if err != nil { - t.Fatal(err) - } - return b -} - -func TestVectors(t *testing.T) { - vectorsJSON, err := os.ReadFile("testdata/hpke-pq.json") - if err != nil { - t.Fatal(err) - } - - var vectors []struct { - Mode uint16 `json:"mode"` - KEM uint16 `json:"kem_id"` - KDF uint16 `json:"kdf_id"` - AEAD uint16 `json:"aead_id"` - Info string `json:"info"` - EncapRand string `json:"encap_rand"` - IkmR string `json:"ikmR"` - SkRm string `json:"skRm"` - PkRm string `json:"pkRm"` - Enc string `json:"enc"` - SuiteID string `json:"suite_id"` - Key string `json:"key"` - BaseNonce string `json:"base_nonce"` - Encryptions []struct { - Aad string `json:"aad"` - Ct string `json:"ct"` - Nonce string `json:"nonce"` - Pt string `json:"pt"` - } `json:"encryptions"` - } - if err := json.Unmarshal(vectorsJSON, &vectors); err != nil { - t.Fatal(err) - } - - for _, vector := range vectors { - name := fmt.Sprintf("kem %04x kdf %04x aead %04x", - vector.KEM, vector.KDF, vector.AEAD) - t.Run(name, func(t *testing.T) { - info := mustDecodeHex(t, vector.Info) - pubKeyBytes := mustDecodeHex(t, vector.PkRm) - pubT, pubPQ := parsePublicKey(t, vector.KEM, pubKeyBytes) - - var kemSender KEMSender - if pubPQ != nil { - kemSender, err = QSFSender(pubT, pubPQ) - } else { - kemSender, err = DHKEMSender(pubT) - } - if err != nil { - t.Fatal(err) - } - kdf, err := getKDF(vector.KDF) - if err != nil { - t.Fatal(err) - } - aead, err := getAEAD(vector.AEAD) - if err != nil { - t.Fatal(err) - } - - encapsRand := mustDecodeHex(t, vector.EncapRand) - setupEncapDerand(t, vector.KEM, encapsRand, pubPQ, kdf) - - encap, sender, err := SetupSender(kemSender, kdf, aead, info) - if err != nil { - t.Fatal(err) - } - - expectedEncap := mustDecodeHex(t, vector.Enc) - if !bytes.Equal(encap, expectedEncap) { - t.Errorf("unexpected encapsulated key, got: %x, want %x", encap, expectedEncap) - } - - privKeyBytes := mustDecodeHex(t, vector.SkRm) - privT, privQ := parsePrivateKey(t, vector.KEM, privKeyBytes) - - var kemRecipient KEMRecipient - if privQ != nil { - kemRecipient, err = QSFRecipient(privT, privQ) - } else { - kemRecipient, err = DHKEMRecipient(privT) - } - if err != nil { - t.Fatal(err) - } - recipient, err := SetupRecipient(kemRecipient, kdf, aead, info, encap) - if err != nil { - t.Fatal(err) - } - - for i, ctx := range []*context{sender.context, recipient.context} { - name := []string{"sender", "recipient"}[i] - expectedSuiteID := mustDecodeHex(t, vector.SuiteID) - if !bytes.Equal(ctx.suiteID, expectedSuiteID) { - t.Errorf("%s: unexpected suite ID, got: %x, want %x", name, ctx.suiteID, expectedSuiteID) - } - expectedKey := mustDecodeHex(t, vector.Key) - if !bytes.Equal(ctx.key, expectedKey) { - t.Errorf("%s: unexpected key, got: %x, want %x", name, ctx.key, expectedKey) - } - expectedBaseNonce := mustDecodeHex(t, vector.BaseNonce) - if !bytes.Equal(ctx.baseNonce, expectedBaseNonce) { - t.Errorf("%s: unexpected base nonce, got: %x, want %x", name, ctx.baseNonce, expectedBaseNonce) - } - } - - for i, enc := range vector.Encryptions { - name := fmt.Sprintf("encryption %d", i) - t.Run(name, func(t *testing.T) { - expectedNonce := mustDecodeHex(t, enc.Nonce) - computedNonce := sender.nextNonce() - if !bytes.Equal(computedNonce, expectedNonce) { - t.Errorf("unexpected nonce: got %x, want %x", computedNonce, expectedNonce) - } - - expectedCiphertext := mustDecodeHex(t, enc.Ct) - ciphertext, err := sender.Seal(mustDecodeHex(t, enc.Aad), mustDecodeHex(t, enc.Pt)) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(ciphertext, expectedCiphertext) { - t.Errorf("unexpected ciphertext: got %x want %x", ciphertext, expectedCiphertext) - } - - expectedPlaintext := mustDecodeHex(t, enc.Pt) - plaintext, err := recipient.Open(mustDecodeHex(t, enc.Aad), mustDecodeHex(t, enc.Ct)) - if err != nil { - t.Fatal(err) - } - if !bytes.Equal(plaintext, expectedPlaintext) { - t.Errorf("unexpected plaintext: got %x want %x", plaintext, expectedPlaintext) - } - }) - } - }) - } -} - -func parsePublicKey(t *testing.T, kemID uint16, keyBytes []byte) (*ecdh.PublicKey, *mlkem.EncapsulationKey768) { - switch kemID { - case 0x0010: // DHKEM(P-256, HKDF-SHA256) - k, err := ecdh.P256().NewPublicKey(keyBytes) - if err != nil { - t.Fatal(err) - } - return k, nil - case 0x0050: // QSF-P256-MLKEM768-SHAKE256-SHA3256 - pq, err := mlkem.NewEncapsulationKey768(keyBytes[:mlkem.EncapsulationKeySize768]) - if err != nil { - t.Fatal(err) - } - k, err := ecdh.P256().NewPublicKey(keyBytes[mlkem.EncapsulationKeySize768:]) - if err != nil { - t.Fatal(err) - } - return k, pq - case 0x647a: // QSF-X25519-MLKEM768-SHAKE256-SHA3256 - pq, err := mlkem.NewEncapsulationKey768(keyBytes[:mlkem.EncapsulationKeySize768]) - if err != nil { - t.Fatal(err) - } - k, err := ecdh.X25519().NewPublicKey(keyBytes[mlkem.EncapsulationKeySize768:]) - if err != nil { - t.Fatal(err) - } - return k, pq - default: - t.Fatalf("unsupported KEM %04x", kemID) - panic("unreachable") - } -} - -func p256KeyFromSeedQSF(t *testing.T, seed []byte) *ecdh.PrivateKey { - t.Helper() - if len(seed) != 48 { - t.Fatalf("invalid seed length %d, expected 48", len(seed)) - } - s := new(big.Int).Mod(new(big.Int).SetBytes(seed), elliptic.P256().Params().P) - sb := make([]byte, 32) - s.FillBytes(sb) - k, err := ecdh.P256().NewPrivateKey(sb) - if err != nil { - t.Fatalf("failed to create P-256 private key: %v", err) - } - return k -} - -func p256KeyFromSeedDHKEM(t *testing.T, seed []byte, kdf KDF, suiteID []byte) *ecdh.PrivateKey { - // RFC 9180, Section 7.1.3. Only for testing, without rejection handling. - t.Helper() - if len(seed) != 32 { - t.Fatalf("invalid seed length %d, expected 32", len(seed)) - } - prk, err := kdf.LabeledExtract(suiteID, nil, "dkp_prk", seed) - if err != nil { - t.Fatalf("failed to extract PRK: %v", err) - } - s, err := kdf.LabeledExpand(suiteID, prk, "candidate", []byte{0x00}, 32) - if err != nil { - t.Fatalf("failed to expand candidate: %v", err) - } - k, err := ecdh.P256().NewPrivateKey(s) - if err != nil { - t.Fatalf("failed to create P-256 private key: %v", err) - } - return k -} - -func setupEncapDerand(t *testing.T, kemID uint16, randBytes []byte, pubPQ *mlkem.EncapsulationKey768, kdf KDF) { - switch kemID { - case 0x0010: // DHKEM(P-256, HKDF-SHA256) - suiteID := binary.BigEndian.AppendUint16([]byte("KEM"), kemID) - k := p256KeyFromSeedDHKEM(t, randBytes, kdf, suiteID) - testingOnlyGenerateKey = func() *ecdh.PrivateKey { return k } - t.Cleanup(func() { testingOnlyGenerateKey = nil }) - case 0x0050: // QSF-P256-MLKEM768-SHAKE256-SHA3256 - pqRand, tRand := randBytes[:32], randBytes[32:] - k := p256KeyFromSeedQSF(t, tRand) - testingOnlyGenerateKey = func() *ecdh.PrivateKey { return k } - t.Cleanup(func() { testingOnlyGenerateKey = nil }) - testingOnlyEncapsulate = func() ([]byte, []byte) { - ct, ss, err := mlkem768.EncapsulateDerand(pubPQ.Bytes(), pqRand) - if err != nil { - t.Fatal(err) - } - return ss, ct - } - t.Cleanup(func() { testingOnlyEncapsulate = nil }) - case 0x647a: // QSF-X25519-MLKEM768-SHAKE256-SHA3256 - pqRand, tRand := randBytes[:32], randBytes[32:] - k, err := ecdh.X25519().NewPrivateKey(tRand) - if err != nil { - t.Fatal(err) - } - testingOnlyGenerateKey = func() *ecdh.PrivateKey { return k } - t.Cleanup(func() { testingOnlyGenerateKey = nil }) - testingOnlyEncapsulate = func() ([]byte, []byte) { - ct, ss, err := mlkem768.EncapsulateDerand(pubPQ.Bytes(), pqRand) - if err != nil { - t.Fatal(err) - } - return ss, ct - } - t.Cleanup(func() { testingOnlyEncapsulate = nil }) - default: - t.Fatal("unsupported KEM") - } -} - -func parsePrivateKey(t *testing.T, kemID uint16, keyBytes []byte) (*ecdh.PrivateKey, *mlkem.DecapsulationKey768) { - switch kemID { - case 0x0010: // DHKEM(P-256, HKDF-SHA256) - k, err := ecdh.P256().NewPrivateKey(keyBytes) - if err != nil { - t.Fatal(err) - } - return k, nil - case 0x0050: // QSF-P256-MLKEM768-SHAKE256-SHA3256 - s := sha3.NewSHAKE256() - s.Write(keyBytes) - exp := make([]byte, mlkem.SeedSize+48) - s.Read(exp) - - pq, err := mlkem.NewDecapsulationKey768(exp[:mlkem.SeedSize]) - if err != nil { - t.Fatal(err) - } - k := p256KeyFromSeedQSF(t, exp[mlkem.SeedSize:]) - return k, pq - case 0x647a: // QSF-X25519-MLKEM768-SHAKE256-SHA3256 - s := sha3.NewSHAKE256() - s.Write(keyBytes) - exp := make([]byte, mlkem.SeedSize+32) - s.Read(exp) - - pq, err := mlkem.NewDecapsulationKey768(exp[:mlkem.SeedSize]) - if err != nil { - t.Fatal(err) - } - k, err := ecdh.X25519().NewPrivateKey(exp[mlkem.SeedSize:]) - if err != nil { - t.Fatal(err) - } - return k, pq - default: - t.Fatalf("unsupported KEM %04x", kemID) - panic("unreachable") - } -} - -func getKDF(kdfID uint16) (KDF, error) { - switch kdfID { - case 0x0001: // HKDF-SHA256 - return HKDFSHA256(), nil - default: - return nil, errors.New("unsupported KDF") - } -} - -func getAEAD(aeadID uint16) (AEAD, error) { - switch aeadID { - case 0x0003: // ChaCha20Poly1305 - return ChaCha20Poly1305(), nil - default: - return nil, errors.New("unsupported AEAD") - } -} diff --git a/tag/internal/hpke/testdata/hpke-pq.json b/tag/internal/hpke/testdata/hpke-pq.json deleted file mode 100644 index caabcdca..00000000 --- a/tag/internal/hpke/testdata/hpke-pq.json +++ /dev/null @@ -1,320 +0,0 @@ -[ - { - "mode": 0, - "kem_id": 25722, - "kdf_id": 1, - "aead_id": 3, - "info": "34663634363532303666366532303631323034373732363536333639363136653230353537323665", - "encap_rand": "19f270b5955d8c21d2033111b9d16d0c06c282a75eea4f7dc945e0939d6fa8d983985a0d098204532afa26bb0df2442d900999c8f6d53d1e619633a2270ea622", - "ikmR": "b78c64611bd91ab62f5d75855092796fae54f28863d47c58d3973b3748b0196b", - "skRm": "b78c64611bd91ab62f5d75855092796fae54f28863d47c58d3973b3748b0196b", - "pkRm": "193c149214bfa9d3c14ee192e5844cea7a0a3066b196a6527feba3f41370d65572bc65a5a80709867a299b5877a6a94f6e034576f58d3d534e05bba480e41ac5a63110c67e0bd4bea5e82fa2d0a5cbe6905001be7b267d3e283163c49569f9b343f56c4acaa55e5b24667b45aa86503c2780f5b97df0b29561790ad4f2118ca12daaeb9385d61e2703b6880c0acf8b9b19ec1a578c97f7aa036db2488f840094187dfd6067e6f744d9e50e4b441fc564308426ca92dcc89d41b28125a9544c4a2dbaacd43b8817699ebb104c1937be19e349cf5918a2d35c8ba301d4691b3d657b1d032971835b3a7cb4d554171f83a55f3455d1d34e44cb2f8ce699416c5a4eb79adefc3525b42adb433500c2987c0459e736a05399628b4179fd246ea06b090be610064856be191369b93d900a88fc39c93a941c37583529ec5757ab21004c3f97fc11ead8a015d75a5d9355c1287c84a36086e209b05a3038c2a64e21672bea1ff7c385904b099f747791161ca8b09bf222b3a4f615679b959f6424cf520078c42e7545ad80cb6a17d5be8f711a208653293782cc3c94a091cac50bb3b55b13f8c234f4c70b6bfa72e09357548386e785b523571a0c51a6874567a35b037e01b83340a575275c022a2426b0380a4a0f9f0491b7bacee32bbf8eab8a70e62abe31292b7503f274063415c7d676a862d2175738069a0644e8ab95bef87b1568192edc7a05a77843c70bbebb98f24cce20cc0aceb3713e6065b69b9e29987eaba0c669d3c7c3e8221f4680bf45825042892bb0556a694b7d954498435949917093581ee09a0c874c6a323c7d7447472c192cd5e3007742092369c606ab9e2279bbd06c7d2bcc8e43cc69487604294a457580af5962125fe25664e5c2abeb4be3b1943e9b5dfd14a68b873c7b3049647c2188d1b93b7947b38bc7665043193252f1f6450d640832b33cc7274eaa72734720afc0b9b2fed7c838a3778769299761508d507b76ccce76525c822b9a4f8ca3165c3fcd35b0af2c4d3d34c5aec05dd1fcc0222093f7ca5fec1a9ae0c48fc37862c1582d9c042fbdfca0de8550dbda839cf48c1e816bff17c8d7f5470f2463ea77c9c84a42737b3117e2a3086607543514927bac51b5b5d258bc71204519ec980927c2d2c31fe9d86a5b3869654230013583bff3a7c99210817588853acf6350c1976bbb9df0a82ee6a7a2d8305c575a46b570bfd39dde7754ef6273a1b30b78ec85dc6c1bcc49c6a7bc1591e4384acb13d2dbbd58a7153bdcb07e727fb765cd0b57667da0c32ec254c393a171c6abf3b88531827307d95f40b78f4c152183544f77f548e6c064a7aa567e40ae2ce0912e09b3f24169c78528121b10618c6e17341e7b8b26c9d821e584503c786102fb9110dac136fa52ce3acb9b492c9632aa84f7674898620119c1bf1103a6405fdcb25b40928857bb54a9297d80bc1cca7639b9f4422a666b3bd122e9c9baabe1a04dd950c3919784c647823b6f472673fc0012b960b546d17b7fa94d27d836c5709f86a9022a382518172fd6aa5be0a373ab12bd93687f6a52743f685f62b1472d5b0b59a740d484c56a2bcf0df47133dc95aa37529dda6b36679e1a924df2c88ee180134f41bdb90c4e77b2fc9be781a77f7e9beeb7badd04f884b7547b0279ece1cef0486d045bf812a32d802ff5f5a54549e93ebcab143e7b70c46148421f2d", - "enc": "c40b97a1af0f4066fa9626ba68b1980185dac3a7cb1ad6ca63650e78b4d305ed2ee557ae5dbd3df9c807cce1aa88d739d35feb4d06735484cd8507cd4eeb4c0fb2c1abef0c7e0cb1177841ba7397d8f6a1a2d226de046659903df93cd26322786b1626cd86579d03cc9d5c568bdb6123826380f46e2990fa9bae9dffb0126ca61da6326528a215c84bbd401999b9861cb81b8cca0ac72298dfa400589ba91c87dc1bc48981439bf02120637fac354e5bfea3b0c6de84b2e726a450b791ab38b96fadc74f6348118f9359b9eaf860463c76f7d1d9aafb213230bdf4dd9cb536c8a5c913307fea36dbffc53b59dbb6b88fb71c4526508eed79cb33d59c996e828e89ef29383a6236605020fdb1202b5aa1e7b30c54da7490e8228cfa460f95a0c17778609acac11ae969c54543f05078568b330ea6795aab1d049ebf871881a3192da35d045b3bbd6e5542fff3d060e880e6dc10cc27c123b003aa8c5e0ca36fea6bd20c34ad8ac8b759df0c87e3fa780a1c75b543a7d85ec7ee187eab34d53477cde9dd22503e602ccc9fa9bf725a0c6a176058ae05b2e44d5b790a46b432ca3f9c69b83f49c1b71b8585cdc0a4bc676f1d8744bf9d8a036c2628b11281ac2f243113a08dd81716f88c697a2b123c2e8bd61c74ce4d64edaca7313aaaa877f6f8c9c1f766aabf8734e6ac6117b7c3e8c1dfc1cd6f082f675cf28507cc09162b441180daaf1784f702c173f914478e1bef288e0d5ed644fa4e3d66e8e1de8fd9fc32a4f0cf31791c84d4a362ffd730caa6bbc0ee9d2d2e41422b5f4337b55566512580b5866bcd5390fa72b6a07396281818e2cfefbdef7c9859d4cde52ec098ef802c835eb7eeb453f4ad819a90929b2664533f427f56709f238753d284894bb0393d2dd7c3f31c80535764f5d4e20062b8fa7a025f1583aa3b8c145d064ce22730e68bc322d78d225110b0851f193555c3e07e517f3e79b445f5d7356f1ca681f7c678a018f250b18a08c6b87d83415707c33404609c969b6865cb5f0bdbc78f736b790ef588fee12fb38ea7f083608ab362e3c72a5e8b6c086223ecd234e1358f50823e11099337134bbec85a203e32b5915ca100c9fcd505b9f36055fff011e2f691f57fc6aa567b79492e8cbca09e174c2d2446b240ad20c9d9c16b321c1cdb16c709b1130ff0b3456abe305a9e2c0a514ce41251044fdc83b99d20c03b62b7bfd5a3e3bc4df60e5cbc7544de9d34c97d0bf9248d30b7eb686f0c520624eff3a60cd9b5999a3fef79184d0c63d1dde140de5a92d3f997ca4ceb0f002e5437e7ce6dd6640668dbc5afeb5795b65720f87f363fda1a0f9fcc38ba21e8f9b280083049a3378c6c119fe761759faf6e79d1fb487447b87e4daff98f36521bff863f9556ba7f0fbcc6c99c9b6c1db9f278fa6bf9dcc24790825947ed8b1b9404372e38fe07dee698b47c449b608718f92ef66dc19658db870997e21c1c262ab89a68504286214a03cd291d2a41d749c5f56633542f50ebdeaccbdfcbc7999ab8880319b9ce0ef73d15958f5f168b8ad29e108a20186d906567497f5b2218d06a35", - "shared_secret": "51d7c1b3038e92e6e402de53a915c490c0a3333f7a56fd4c5ab9e2d9e29c8e80", - "suite_id": "48504b45647a00010003", - "key": "edce21c81f84b213a0997c408a566fa901f97dcd27427d2bb94ca660119a8683", - "base_nonce": "2fda4392a0b23a352d70f0e0", - "exporter_secret": "0f2816531c4db55d8d2e45c0d55ff00fa07cfa98216e4dbe58632b8e0160d5c9", - "encryptions": [ - { - "aad": "436f756e742d30", - "ct": "b177c7b2d4d4dd604722231ee430f4c6334bfef1fe7bd9e95782f7995e37dcc0a2b902a9675cde8fd05f6d9a0f2f109de0533e16c34ed557516050eb9d620d2768e05278d451aeb390c9", - "nonce": "2fda4392a0b23a352d70f0e0", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d31", - "ct": "c4cc0bf69d0a6f059a61f32243955851757274af96075ba473cafa2615bbc1abc0fd1b8522213ea7b93a77881ce531818644266d2deb9c9ec2cea9c2922fceaa7e79b2d32678590a450c", - "nonce": "2fda4392a0b23a352d70f0e1", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d32", - "ct": "249a52262884fd861965d335eab7f6674460177390f607b83b9ac26c126d28141bffd5538607c73e9b1a3f2931e1e65f00034189a062d80f2560c00f24b506cab0d02d4ab95a3260d58e", - "nonce": "2fda4392a0b23a352d70f0e2", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d33", - "ct": "829ec3fb97f05b6ca0c392add4fa3ff518256c739072b84fc78315e15fc0cca9b129f02313729af796d27f0155fdb0e65dbc1c1bb68022a0ee47545d88fbcc3ec60e978f01656faa1a11", - "nonce": "2fda4392a0b23a352d70f0e3", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d34", - "ct": "bab611de27d0d7f951e4f9368d6486d51104da8e887ad8e20637b9db506fde90034f29610f88e0ef70358f836366b9c20b83999cd3a5076a6256e20223e172ed54b15e4b2548caeffdff", - "nonce": "2fda4392a0b23a352d70f0e4", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d35", - "ct": "fb480f295b8f3e41c5cfa7aa3064f727e648c2d43982dcbcc1d04f8c2f538055bda8aac03e9c7a77eb50eef87a3dd3f0a47fb7230433e13d91ff194912846f69effe3f4a223eb7e965fc", - "nonce": "2fda4392a0b23a352d70f0e5", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d36", - "ct": "0da3d3112e53368b006bbd510d33769f3487fec9563f62abac563287a3a53ad032591cb57128a12fcbe689be33e92b02e96ecb5c12fed23aa517ef7be59f4dca9060ca359c971e014a2f", - "nonce": "2fda4392a0b23a352d70f0e6", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d37", - "ct": "8c3a4ca5373704e664f5266cff459b40f0d2e1b855134b7e668453eb95bce1b7a09eefffcae866b7bd886a9aaba10dc1b012cf1037b0d80963c71aaf2c619ac877144dbf01a9d57390df", - "nonce": "2fda4392a0b23a352d70f0e7", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d38", - "ct": "144e33c04ab411a116253b6858ea27d57ccb0b247f83c013d10193e50010504155b4b8b4572b83410b8f008749b873d5c159027334c074762f860f4d60e41c207838e0f444545af1152c", - "nonce": "2fda4392a0b23a352d70f0e8", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d39", - "ct": "d92a59325eac0496a2c33c48e12bc76b637150d4db794ad5c8ea14bf8c742351b6e74b65464dc50af0ec4d364219ed36f56ece2b5260e850d5c24660e2240f440e07fdb462be20fac811", - "nonce": "2fda4392a0b23a352d70f0e9", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - } - ], - "exports": [ - { - "exporter_context": "70736575646f72616e646f6d30", - "L": 32, - "exported_value": "dfe6bdeea3a82eef95414480fdda8ecf9ea8d43846dd86072348a07183b52215" - }, - { - "exporter_context": "70736575646f72616e646f6d31", - "L": 32, - "exported_value": "cef76709dd0e384a97a5babcb77b221c2c30b223d3926b4b353b64767dbabedf" - }, - { - "exporter_context": "70736575646f72616e646f6d32", - "L": 32, - "exported_value": "b12381e05242f2a609545b9a3d826ecf2183f871314ee52c6f64760c6f636c86" - }, - { - "exporter_context": "70736575646f72616e646f6d33", - "L": 32, - "exported_value": "f0245f260ba33d995197bf06e6b8330cdac8b72dc3fc6e712ed4d3f6241b93dd" - }, - { - "exporter_context": "70736575646f72616e646f6d34", - "L": 32, - "exported_value": "2b279cb0c27ecce59d305e369b31b2c467a60946014278ca8bdca12c384f7a8d" - } - ] - }, - { - "mode": 0, - "kem_id": 16, - "kdf_id": 1, - "aead_id": 3, - "info": "34663634363532303666366532303631323034373732363536333639363136653230353537323665", - "encap_rand": "ae3c5f0e0d711f220f174b948620b6a9a84931f1510a1e78fe75735ffa585c29", - "ikmR": "f60dafbaa4dae9c499d09cedd84143297a66c23097bc4e69d1e5c89d1d6d7fc2", - "skRm": "5d1e0a06d9d5159783d89efb66b82fdf81f16f1ff5cd81e39a117275312a80d0", - "pkRm": "0469d46c0d5acbf0813fec4cbf81309675e822b6740983a55d5eb905d5e07a86dc70378bbfa6a1d9aa7269f98eeed2d9882346d5b0f5477e84918445853c267065", - "enc": "04885fb4ad2c5088593ee72afb295a709684ba2c016561b27d62d4fc39d2c884e5df85d77d3366d922726ebe95fd3aa2b8019fc1cde75b53684f21e8612ff48f6c", - "shared_secret": "3b26aca70d3510ae3acab4c117ede13249de20dc1fad0b0c6262137457c333e8", - "suite_id": "48504b45001000010003", - "key": "7aaed1082c439b9520fdaf4b7da76a52e53f92b592f35266b3683a72297436d2", - "base_nonce": "4e5d41b58438f9ddd494a510", - "exporter_secret": "c01ee22ac8dd68260f02c9e29aa0745819a8327443eadcf6a4e57c69c613ffb8", - "encryptions": [ - { - "aad": "436f756e742d30", - "ct": "1e26c3096c6c768dcc2ec1ec41d188bbeaf6bc6c6bd24e43ba313fac8d9eb3140592204e29cd1ee40aa5ccfacdd2c36e6cad773baf7bb9cfa8f53e626a4728d910043eda957fc01b04c8", - "nonce": "4e5d41b58438f9ddd494a510", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d31", - "ct": "e3a595f805e18a948dc5a84cdf693f8903ead0ab73c7cc9bdab7f749d607967c7672f2ffced518116bf55a1db2157b73079ea4cb1a07b3df6826858e609e0e1a1900e6a6a867ff33d8f3", - "nonce": "4e5d41b58438f9ddd494a511", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d32", - "ct": "0dd72194de92d55f648f029ccf7665a4635356c3c0ad9d9877ca99bfeb0c26f0a9194c00e025019dccb2015bfabed58542798caf305a25d03b934add6c8894632c9490cab1d5d8b8fb52", - "nonce": "4e5d41b58438f9ddd494a512", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d33", - "ct": "053ef6c31808e8c250debc14794e135b077441746b4760f13fe4a90cbeffcaf63ae284c1156aa0db6a9f8d5dc8189e6e9d284f0dbe96b8dea196a631d0f287ff3bb3df185a136b6e4a12", - "nonce": "4e5d41b58438f9ddd494a513", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d34", - "ct": "d8add82d80ba3e65c72d5b34a9edd19980ca2fc28b473867c4205d4fc7b4f0a3d84070624d4f6d922311e80dc4de402f90a745cd58bf022b00852ecc4187c27da22518b4132121601562", - "nonce": "4e5d41b58438f9ddd494a514", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d35", - "ct": "d13fbdda5d6048bbdef281fb2207487f71144c0e4025c439eac0a73f679e0c2f3ac2cf748f1a3af8607434507f85f63f06f9ee1891a951b3d5222c807acbad3759fe49e0b0cd86fa4872", - "nonce": "4e5d41b58438f9ddd494a515", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d36", - "ct": "f05bcdce13576896bc8044965477e1c2450cec84ceaad9a04c93fae1ee2863fbe9c6d1944f681b7aa44ae803e9849b8a5f47ea6464f26f1b5e19af28daa56ff5cc969f5d21ce061fc446", - "nonce": "4e5d41b58438f9ddd494a516", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d37", - "ct": "5c6fe7f4e7f61305e99f8de862102d00bb0300f5342c78b535a2159359d5fe7512bd3232988e97e6b46a988e3e0cf1207e749e3d2206631c994792dc1075f8d63f49ec8f02956239dce0", - "nonce": "4e5d41b58438f9ddd494a517", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d38", - "ct": "e7af3bea35ed4d63b9a4a9a9125112e13e783620f6767c92cf1daad63a27c6dd68f87d17009e70094b2ca9eee4ffaabc9a45d966fd74c7ac2cae4aa3bf4007dae88d7929a2623569ce04", - "nonce": "4e5d41b58438f9ddd494a518", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d39", - "ct": "082552f1ca06613e68279ef115742dd353259260ca055b38436d570f3dc404e335baddd4368ae21cc24e1e1850e97d7580659c0796221ed37b561732a01bc2234bc4dc653af483e88955", - "nonce": "4e5d41b58438f9ddd494a519", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - } - ], - "exports": [ - { - "exporter_context": "70736575646f72616e646f6d30", - "L": 32, - "exported_value": "4d06b6f07867c5031ba504e4f5467c316fca853f4f79b8246311af7d4bb77263" - }, - { - "exporter_context": "70736575646f72616e646f6d31", - "L": 32, - "exported_value": "304ca02f1e562aff01e6059955a8c1aff4189e5b274e8d2f4f46cb56333fa48b" - }, - { - "exporter_context": "70736575646f72616e646f6d32", - "L": 32, - "exported_value": "25003705b62981a81cb6a6b7c5766f62163f2e52bac488ae47e10a2eb439a68e" - }, - { - "exporter_context": "70736575646f72616e646f6d33", - "L": 32, - "exported_value": "c787490330f5c4b667f2f51a03117c36e983b87e1cc7c2c8def1e0df42cbd034" - }, - { - "exporter_context": "70736575646f72616e646f6d34", - "L": 32, - "exported_value": "157124564aab5e3ec50342ed8b76cff77ff589314b636b66df4d8aff5f96716e" - } - ] - }, - { - "mode": 0, - "kem_id": 80, - "kdf_id": 1, - "aead_id": 3, - "info": "34663634363532303666366532303631323034373732363536333639363136653230353537323665", - "encap_rand": "bd31d63122a4c4cf37244a31ba6acec390ce06f412ad3cbef973c03f3a32602e899409cbd7b4f9ea2a29d5f45952dc1368836b7d1b2a627e1fa94bcc799fcdd20d63f763872837ffe279632acc12d85f", - "ikmR": "9ed4e7555bc2c65f43e2ff3a5beb826daca1a79e7bc89ddae587659ac87e82fb", - "skRm": "9ed4e7555bc2c65f43e2ff3a5beb826daca1a79e7bc89ddae587659ac87e82fb", - "pkRm": "65070243e439adeac9e3bc7bb3c569d22027d1009640b7ae404a6ed0849c2f9599e16943d105b81a196f1f2990ac0204f8e35041032cddb1170a39c79c79568b01bcb62549fcc78bde6546387ac5e64767a1ca9f65cc2fd8704ad199c90d8184257cac4e853d0a0c89eed239f3d5bb52a4005f11cce49bb7569b8553968923056902fa5ac080bc80e404116143eba0210f2b43231a28058824f7136fcb1b14759a3e81e5ca292505747659c488abcf25219ac7cfa5887b2bba7a9754922148cd45054e3b5b57ac9b6666b17f13787284359eb695109923491513930d173dc8c2708dabc6303cac77eb51f349a31ed3c108fb8a3f8537247bc7fae93c706b264ca0323e122c5b5b78b4f0c385867fdb6440ecd804eff8be81362043156740a183016a4f418573db6ccbedc9b9dcc9347eca8912f46bcf02a115c67b206994becc3104c45f04154a0aac9391d57e2d835479385eea571db7921ccbb35575ca8bae196a9784a4de76ac6d003d6be1a090dbc993795e39f5243924717b8462e2e7b564b7770e81abcf47a96abb872c758d68d7a0cb1ba00ee43fab1abd3435b31bc33f2c3b2a0b51c4c60c5e2c280bbfc423d07c31e4e6475ee138daab3d8c799d59d04eca814204e58168798e4188b53685c0df2085f006c709e3b63e7a16c3027a12ac081be6cc822376bdf8168e8253b1271d63501480b535127a068eeab7a7da2c26764dbb73b1f749a5ab572ef0904d7ed072782206df76a3e27570be88a33f364d65e5679d3c3ff3522f843396aa71536b3578fb8336f7f5adf1a59308007e5da949fc80332b40098da8611a0139e3315fc90c2454a2243759b8d1f6a5925685637a43a0f14199e9bce0213d5d20c808694680a01c75c057f3b19fb8218e18d3cad6743616e8814381a25a54a1a32a5f1f045288f5b567f03d7a052cd54400da215c2d7a0acc7c4fb0f06c1024a7100940e328843037449b04a0c2114937fa279ab94ec83a7e3b232df9b5a947c0a3bdf06b31544d253bbc0439050fd352149119d5526cf8c9abe565b6414b721b76422c752a4176205f82a3d89a0a4b5b20b56bb9bd0bb43c51814843ab8f402f34820cfadc71558713e41023373ab5bf0ac5a54541bfb0ad54e17e177a9b8ddb284741beb188c1e396006479044a12afe0281096a0c554b4a843c97d45ca4def0bb68e8345453c6899fa63c3208b98b60b48424cc2926e96514ca1d5bb09d2c3db043f29fcbeee064c8b860d9a01d094aacb2f351f76c70f2774b55a24a74fd29007f206a1acbde0044820cbb708e2a9b08a11910989accab8d001c7d83cb3063393e5bc1450577658b6b5ae1b9fe3c0c36e39658f33abba40ce7a357706186af3f621db8952fa7311d03c05a50274159009951233e6c15cfc2151b7a6c44596beb4090d9bb39c6b1aa75d3c638ad0768b2180dd672ab179654b144f8f1c237a5785315b162858570f1a2bd7a5201133cfa9eb0725f154011a960862bbf48bba699288966a4543a99f26ca44fa65aef8c4bb02f833b3685def4b270ecb470c3c7b0bc0ceb74137c64449963751989c63196acb64bc63ea9a48d96672f44035949b3c6439a5cec59433c335d832bbffc92a1f3e03e8a5d3272e35b725657f724711059361818c23285f53dca378840a4b5e04486919d414ea7b6203a1e71a8204b78696220a9f385232f097e28505c93b963ac7d2a3d6cc53794d14e70319332bc67c9b93c787fef6808dd84e10a208bb3890", - "enc": "0ec138280990dd2deeeb9ceba63f944e12e551e788dd268e8680622ec47a9500ad1f83f21f3ffffd764e318c86fce86ca75e5de9c9124d8ab5b4baa36b64743ed449738166677a6564c2ee9b4abd33d42c2c7e66bc9bf1fef96028214934deedd763be2e2567f6021aad99ba3a477d1565f4ae35a129549e620464daa564f7569db5a5d24ac6a01f43e7879885349c547d288fe253c3feadb244510b38cd65344477b2bff0e2b12db4f69b3cc0219b868d11a1a6d61ee2e76c1b598920c30149004b77d523d3991863df21011bd4ca589c1081880c00fce107292d0bfd770dd42a68e7b12ebb173766421986c0015ad7cabc1c191a26e84d692f167deac56cae31fc992a9fc6b2fd15e7c161f926d46e5d9ee478c90ea5195005f62de0d75beafeb828c2336c6070fc254e5aacd4ae74ff148b614468aaeeccd0c0089de312524e343b72805d71bd534c4e3daadeac64a1cc683dc311c917663d81e0a937f553e59c8f17ec754b476212c6a4e155c05ee2c2b79dcd3bc0e75aa2b7185a2345b981c710083574389f2710f2c658d6ae236ed0e8e75ccf3aaeb9bc34fd7251306128d2b3a3cd921c51f93ede6ae7a68192775efd4c242f80fa87142394615dd1a27e92fa4c9a7030f416e183c42b63e73ab2dd42775fbbc26e0040defd97530d1142da3f5bd0a3f021478e1c46f45f0ff520a474c067544fc3ead1d7782ee666082a40c1dfe12a7d679ca19e5a0775c444e48a7e1c6a4223178dabaa5f99b202cddf0834373e90f54368a7892b17784b4ec04ae7546d0cf29914d1672c9e67cae3fc92943487053f00ad23c1adfa4f70c4297dc68914eefcf9f0b37f06832651766a67ba5bce8a020433ae1fad356cdccecb9b8d37b8fc41217e1d5093ffd156aaca4fe7821151c03e730806ec770be5977e7e68ce1c02ffcfd20f773dece498f1683d568eb171f5122fa1e2ae1aa186a4782ca2adc40927f05b59b577fe0c7e95591b7936517780fdec2177eb1061347716e931f97f0d7da89fee1ea790564af8eaf7314aaf5196af51e8590d1bda044b0f504f0040f83798b22c1722bcf7a0bc713efed24dcbe2c30c77cdf4f22fda3a96ddebcf7c3546be2daf728bc7f312cff4b5706af353d74a519652cd274a0bd4116168a138d311da2877aa190803aef91455d9faa733eb0912177739f47b1951f7da23104f5b9e249f25270bdb0e58c290d3c64f681ab9202b42987d392d3f94d58c1e81a4f4e62293e95fb011cb2b8f49f5ca2fd85f399a386f91ead63bbd97cf2bb68b372cbd1b081ac9bcf14109ab0298f4b0d7c9b16f75090b1c49721b71747a357065c4833cccd52a2c7406f1c141e8cc669678b62fdfe119610dbdec0ec87b0ea54c71f64ea0598672b40becd957594e2c07d5cb0e90dc4378ed110a1b4450f42497c2efd699f91a774ca29a370f76d4fb04dd1ac3fa0779805587819ab6f1785a3330ef60aec8e707e945c776611f530f245e6f554ab408fc1193d0f08dd7773629132e65ae4f3e70bcb0a71004494def2db94f30c53f4c17e39ebf15efbae07810dce16868256c9abfb5b1c339e670c223739a81057db8e7d8bda8ecd9a7cdadaf4350f050ecd10e679137eb1e", - "shared_secret": "61765d0dc46c62192c9800d74ab8ec77c633810720055663fbe6a0d57bded97c", - "suite_id": "48504b45005000010003", - "key": "9b8e5d4f868de455a2f29cd8dc127513be97ec9e0f616e45b7ece8d8e55ac17d", - "base_nonce": "599544220e8706a477505030", - "exporter_secret": "dbfe1ea45410d7b5c857e41c5ce41fad4ba0d9a3ad5617b9a13e0fd26c62b6b9", - "encryptions": [ - { - "aad": "436f756e742d30", - "ct": "def109ce3c4d4d489e585b1cebf86c679665121425c729754c034036b914f7f0ca14c52da51419e2c9a677f1186994c8eb6e707f42acd66f07d15bba920eb87d6687158ebe8f9615215e", - "nonce": "599544220e8706a477505030", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d31", - "ct": "677528b4d1a8eb24b9964c95f7a844bd97e6b1e40eed75575326336e4d6fc57d9b41fc42bf2bd61152a76578ff5729279c5d975e2f4f1fae7263a9370a40c9ae28105064487ad4feb3ff", - "nonce": "599544220e8706a477505031", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d32", - "ct": "0d11a1ac1f6b6d29702c358793f440685b74505f8b0d318997a2fb6c451ccd633e342690120a9c767eb0614fe3cfd518484c974df0c1459881c897b7ed590785ccdea03baa086d5da33b", - "nonce": "599544220e8706a477505032", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d33", - "ct": "0f23a23ae250f808c2a29ba52c214b10ca6cc95fbbc99e48d35ac8bb38f9a251bceaac0d73ef168ec68f645e1cf3ef56175fd63608e4cf0eeb02aaa2005535530d77da6a80ab63aac334", - "nonce": "599544220e8706a477505033", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d34", - "ct": "5e3c3d049535375c3a6de075c1cc5b619b734c99b9c2be9e9413087fe66576a9ca7629094e3293fc8fb443c21464e9070dd8b14d31701bc61a9aa8dbe0a6c45531322f6616d96197c79c", - "nonce": "599544220e8706a477505034", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d35", - "ct": "f8ffd8ad031ea2e8dac99d810b2f969db2ca7c1a5d581f8a8f16a9db6b3cc7a8620ea16ff438568100c40b094cf951c53abc30c5ff6f2e2a25b3242ed04b193d29138de02c792d0b883c", - "nonce": "599544220e8706a477505035", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d36", - "ct": "fee465b871efa4e2f428f9a66458cfa2a7b99774ebeb630ed548f2fa22caaf5ab50e9fed77293aa312c7c5209100a97455dc92f4b4cb4c4e07b3547e7e73228e18f152c67915c71cdcac", - "nonce": "599544220e8706a477505036", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d37", - "ct": "582afc61ffb3ffd7704d76088d2c74cc2530f6bdd6593cbb2977239b6f484716bdedb0d6b1b129f45e1d4afc8f407d17fadd3a3d971c82f8369fd6772d5f2d5274cfff48c3d2db63a44d", - "nonce": "599544220e8706a477505037", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d38", - "ct": "81ce8e9ae429b85b4dd4b40d7f7bb426565d23f2c3d63f74ea96dbba881dfcfe33b95c8202ff37b15bb14f85d52de1a506c3e2b42d650e850fe97a63017670ee52815705dd61421589fc", - "nonce": "599544220e8706a477505038", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - }, - { - "aad": "436f756e742d39", - "ct": "691608a0e0e29d0c2def20bf6f0991bb7eccf57f26722975f3640b4dc23f4be29cfa60352ef3831e6b560e896c766a0126746c380dd3f695b82e4039549202ef01809ee5fde406f2c8a1", - "nonce": "599544220e8706a477505039", - "pt": "34323635363137353734373932303639373332303734373237353734363832633230373437323735373436383230363236353631373537343739" - } - ], - "exports": [ - { - "exporter_context": "70736575646f72616e646f6d30", - "L": 32, - "exported_value": "fe89a3b25515355f1e831529a2306040e6a9edb1643acab8bb9182dc09a029f5" - }, - { - "exporter_context": "70736575646f72616e646f6d31", - "L": 32, - "exported_value": "d79a2633bd36ff4220e355f381303398abc48ceca0491410791afb733c5fc882" - }, - { - "exporter_context": "70736575646f72616e646f6d32", - "L": 32, - "exported_value": "60a3cfd587ce2bf68235328399ca47ef336f63a2c16a57129aa824b8238e8e85" - }, - { - "exporter_context": "70736575646f72616e646f6d33", - "L": 32, - "exported_value": "678cbb9bda711aa6e4f1108db0fa9e9e0585764895d9adbd3b5c633d92dacdc0" - }, - { - "exporter_context": "70736575646f72616e646f6d34", - "L": 32, - "exported_value": "1df9fd502558a98bdd8c80b54d2811e9537850d6b8254567a23c6f049818fc17" - } - ] - } -] diff --git a/tag/internal/tagtest/tagtest.go b/tag/internal/tagtest/tagtest.go new file mode 100644 index 00000000..9c43718f --- /dev/null +++ b/tag/internal/tagtest/tagtest.go @@ -0,0 +1,157 @@ +// Copyright 2025 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tagtest + +import ( + "crypto/ecdh" + "crypto/hkdf" + "crypto/sha256" + "crypto/subtle" + "fmt" + "testing" + + "filippo.io/age" + "filippo.io/age/internal/format" + "filippo.io/age/tag" + "filippo.io/hpke" + "filippo.io/nistec" +) + +type ClassicIdentity struct { + t *testing.T + k hpke.PrivateKey +} + +var _ age.Identity = &ClassicIdentity{} + +func NewClassicIdentity(t *testing.T) *ClassicIdentity { + k, err := hpke.DHKEM(ecdh.P256()).GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + return &ClassicIdentity{k: k} +} + +func (i *ClassicIdentity) Recipient() *tag.Recipient { + uncompressed := i.k.PublicKey().Bytes() + p, err := nistec.NewP256Point().SetBytes(uncompressed) + if err != nil { + i.t.Fatalf("failed to parse public key: %v", err) + } + r, err := tag.NewClassicRecipient(p.BytesCompressed()) + if err != nil { + i.t.Fatalf("failed to create recipient: %v", err) + } + return r +} + +func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { + for _, s := range ss { + if s.Type != "p256tag" { + continue + } + if len(s.Args) != 2 { + return nil, fmt.Errorf("malformed stanza") + } + tagArg, err := format.DecodeString(s.Args[0]) + if err != nil { + return nil, fmt.Errorf("malformed tag: %v", err) + } + if len(tagArg) != 4 { + return nil, fmt.Errorf("invalid tag length: %d", len(tagArg)) + } + enc, err := format.DecodeString(s.Args[1]) + if err != nil { + return nil, fmt.Errorf("malformed encapsulated key: %v", err) + } + if len(enc) != 65 { + return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc)) + } + if len(s.Body) != 32 { + return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body)) + } + + expTag, err := hkdf.Extract(sha256.New, append(enc, i.k.PublicKey().Bytes()...), []byte("age-encryption.org/p256tag")) + if err != nil { + return nil, fmt.Errorf("failed to compute tag: %v", err) + } + if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 { + return nil, age.ErrIncorrectIdentity + } + + r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/p256tag")) + if err != nil { + return nil, fmt.Errorf("failed to unwrap file key: %v", err) + } + return r.Open(nil, s.Body) + } + return nil, age.ErrIncorrectIdentity +} + +type HybridIdentity struct { + t *testing.T + k hpke.PrivateKey +} + +var _ age.Identity = &HybridIdentity{} + +func NewHybridIdentity(t *testing.T) *HybridIdentity { + k, err := hpke.MLKEM768P256().GenerateKey() + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + return &HybridIdentity{k: k} +} + +func (i *HybridIdentity) Recipient() *tag.Recipient { + r, err := tag.NewHybridRecipient(i.k.PublicKey().Bytes()) + if err != nil { + i.t.Fatalf("failed to create recipient: %v", err) + } + return r +} + +func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { + for _, s := range ss { + if s.Type != "mlkem768p256tag" { + continue + } + if len(s.Args) != 2 { + return nil, fmt.Errorf("malformed stanza") + } + tagArg, err := format.DecodeString(s.Args[0]) + if err != nil { + return nil, fmt.Errorf("malformed tag: %v", err) + } + if len(tagArg) != 4 { + return nil, fmt.Errorf("invalid tag length: %d", len(tagArg)) + } + enc, err := format.DecodeString(s.Args[1]) + if err != nil { + return nil, fmt.Errorf("malformed encapsulated key: %v", err) + } + if len(enc) != 1153 { + return nil, fmt.Errorf("invalid encapsulated key length: %d", len(enc)) + } + if len(s.Body) != 32 { + return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body)) + } + + expTag, err := hkdf.Extract(sha256.New, append(enc[1088:], i.k.PublicKey().Bytes()[1184:]...), []byte("age-encryption.org/mlkem768p256tag")) + if err != nil { + return nil, fmt.Errorf("failed to compute tag: %v", err) + } + if subtle.ConstantTimeCompare(tagArg, expTag[:4]) != 1 { + return nil, age.ErrIncorrectIdentity + } + + r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte("age-encryption.org/mlkem768p256tag")) + if err != nil { + return nil, fmt.Errorf("failed to unwrap file key: %v", err) + } + return r.Open(nil, s.Body) + } + return nil, age.ErrIncorrectIdentity +} diff --git a/tag/tag.go b/tag/tag.go index ec8d9c73..1db3354f 100644 --- a/tag/tag.go +++ b/tag/tag.go @@ -2,6 +2,14 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. +// Package tag implements tagged P-256 or hybrid P-256 + ML-KEM-768 recipients, +// which can be used with identities stored on hardware keys, usually supported +// by dedicated plugins. +// +// The tag reduces privacy, by allowing an observer to correlate files with a +// recipient (but not files amongst them without knowledge of the recipient), +// but this is also a desirable property for hardware keys that require user +// interaction for each decryption operation. package tag import ( @@ -14,16 +22,17 @@ import ( "filippo.io/age" "filippo.io/age/internal/format" "filippo.io/age/plugin" - "filippo.io/age/tag/internal/hpke" + "filippo.io/hpke" "filippo.io/nistec" ) +// Recipient is a tagged P-256 or hybrid P-256 + ML-KEM-768 recipient. +// +// The latter recipient is safe against future cryptographically-relevant +// quantum computers, and can only be used along with other post-quantum +// recipients. type Recipient struct { - kem hpke.KEMSender - - mlkem *mlkem.EncapsulationKey768 - compressed [compressedPointSize]byte - uncompressed [uncompressedPointSize]byte + pk hpke.PublicKey } var _ age.Recipient = &Recipient{} @@ -37,7 +46,7 @@ func ParseRecipient(s string) (*Recipient, error) { } switch t { case "tag": - r, err := NewRecipient(k) + r, err := NewClassicRecipient(k) if err != nil { return nil, fmt.Errorf("malformed recipient %q: %v", s, err) } @@ -54,10 +63,9 @@ func ParseRecipient(s string) (*Recipient, error) { } const compressedPointSize = 1 + 32 -const uncompressedPointSize = 1 + 32 + 32 -// NewRecipient returns a new [Recipient] from a raw public key. -func NewRecipient(publicKey []byte) (*Recipient, error) { +// NewClassicRecipient returns a new P-256 [Recipient] from a raw public key. +func NewClassicRecipient(publicKey []byte) (*Recipient, error) { if len(publicKey) != compressedPointSize { return nil, fmt.Errorf("invalid tag recipient public key size %d", len(publicKey)) } @@ -65,70 +73,64 @@ func NewRecipient(publicKey []byte) (*Recipient, error) { if err != nil { return nil, fmt.Errorf("invalid tag recipient public key: %v", err) } - k, err := ecdh.P256().NewPublicKey(p.Bytes()) + k, err := hpke.DHKEM(ecdh.P256()).NewPublicKey(p.Bytes()) if err != nil { return nil, fmt.Errorf("invalid tag recipient public key: %v", err) } - kem, err := hpke.DHKEMSender(k) - if err != nil { - return nil, fmt.Errorf("failed to create DHKEM sender: %v", err) - } - r := &Recipient{kem: kem} - copy(r.compressed[:], publicKey) - copy(r.uncompressed[:], p.Bytes()) - return r, nil + return &Recipient{k}, nil } -// NewHybridRecipient returns a new [Recipient] from raw concatenated public keys. +// NewHybridRecipient returns a new hybrid P-256 + ML-KEM-768 [Recipient] from +// raw concatenated public keys. func NewHybridRecipient(publicKey []byte) (*Recipient, error) { - if len(publicKey) != compressedPointSize+mlkem.EncapsulationKeySize768 { - return nil, fmt.Errorf("invalid tagpq recipient public key size %d", len(publicKey)) - } - p, err := nistec.NewP256Point().SetBytes(publicKey) - if err != nil { - return nil, fmt.Errorf("invalid tagpq recipient DH public key: %v", err) - } - k, err := ecdh.P256().NewPublicKey(p.Bytes()) - if err != nil { - return nil, fmt.Errorf("invalid tagpq recipient DH public key: %v", err) - } - pq, err := mlkem.NewEncapsulationKey768(publicKey[compressedPointSize:]) + k, err := hpke.MLKEM768P256().NewPublicKey(publicKey) if err != nil { - return nil, fmt.Errorf("invalid tagpq recipient PQ public key: %v", err) + return nil, fmt.Errorf("invalid tagpq recipient public key: %v", err) } - kem, err := hpke.QSFSender(k, pq) - if err != nil { - return nil, fmt.Errorf("failed to create DHKEM sender: %v", err) - } - r := &Recipient{kem: kem, mlkem: pq} - copy(r.compressed[:], publicKey[:compressedPointSize]) - copy(r.uncompressed[:], p.Bytes()) - return r, nil + return &Recipient{k}, nil } -var p256TagLabel = []byte("age-encryption.org/p256tag") -var p256MLKEM768TagLabel = []byte("age-encryption.org/p256mlkem768tag") +// Hybrid reports whether r is a hybrid P-256 + ML-KEM-768 recipient. +func (r *Recipient) Hybrid() bool { + return r.pk.KEM().ID() == hpke.MLKEM768P256().ID() +} func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { - label, arg := p256TagLabel, "p256tag" - if r.mlkem != nil { - label, arg = p256MLKEM768TagLabel, "p256mlkem768tag" + s, _, err := r.WrapWithLabels(fileKey) + return s, err +} + +// WrapWithLabels implements [age.RecipientWithLabels], returning a single +// "postquantum" label if r is a hybrid P-256 + ML-KEM-768 recipient. This +// ensures a hybrid Recipient can't be mixed with other recipients that would +// defeat its post-quantum security. +// +// To unsafely bypass this restriction, wrap Recipient in an [age.Recipient] +// type that doesn't expose WrapWithLabels. +func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, error) { + label, arg := "age-encryption.org/p256tag", "p256tag" + if r.Hybrid() { + label, arg = "age-encryption.org/mlkem768p256tag", "mlkem768p256tag" } - enc, s, err := hpke.SetupSender(r.kem, - hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), label) + enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(label)) if err != nil { - return nil, fmt.Errorf("failed to set up HPKE sender: %v", err) + return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err) } ct, err := s.Seal(nil, fileKey) if err != nil { - return nil, fmt.Errorf("failed to encrypt file key: %v", err) + return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err) } - tag, err := hkdf.Extract(sha256.New, - append(enc[:uncompressedPointSize], r.uncompressed[:]...), label) + tagEnc, tagRecipient := enc, r.pk.Bytes() + if r.Hybrid() { + // In hybrid mode, the tag is computed over just the P-256 part. + tagEnc = enc[mlkem.CiphertextSize768:] + tagRecipient = tagRecipient[mlkem.EncapsulationKeySize768:] + } + tag, err := hkdf.Extract(sha256.New, append(tagEnc, tagRecipient...), []byte(label)) if err != nil { - return nil, fmt.Errorf("failed to compute tag: %v", err) + return nil, nil, fmt.Errorf("failed to compute tag: %v", err) } l := &age.Stanza{ @@ -140,13 +142,20 @@ func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { Body: ct, } - return []*age.Stanza{l}, nil + if r.Hybrid() { + return []*age.Stanza{l}, []string{"postquantum"}, nil + } + return []*age.Stanza{l}, nil, nil } // String returns the Bech32 public key encoding of r. func (r *Recipient) String() string { - if r.mlkem != nil { - return plugin.EncodeRecipient("tagpq", append(r.compressed[:], r.mlkem.Bytes()...)) + if r.Hybrid() { + return plugin.EncodeRecipient("tagpq", r.pk.Bytes()) + } + p, err := nistec.NewP256Point().SetBytes(r.pk.Bytes()) + if err != nil { + panic("internal error: invalid P-256 public key") } - return plugin.EncodeRecipient("tag", r.compressed[:]) + return plugin.EncodeRecipient("tag", p.BytesCompressed()) } diff --git a/tag/tag_test.go b/tag/tag_test.go new file mode 100644 index 00000000..dcbe931f --- /dev/null +++ b/tag/tag_test.go @@ -0,0 +1,109 @@ +// Copyright 2025 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package tag_test + +import ( + "bytes" + "io" + "testing" + + "filippo.io/age" + "filippo.io/age/tag" + "filippo.io/age/tag/internal/tagtest" +) + +func TestClassicRoundTrip(t *testing.T) { + i := tagtest.NewClassicIdentity(t) + r := i.Recipient() + + if r.Hybrid() { + t.Error("classic recipient incorrectly reports as hybrid") + } + + r1, err := tag.ParseRecipient(r.String()) + if err != nil { + t.Fatal(err) + } + if r1.String() != r.String() { + t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1.String(), r.String()) + } + if r1.Hybrid() { + t.Error("parsed classic recipient incorrectly reports as hybrid") + } + + plaintext := []byte("hello world") + + encrypted := &bytes.Buffer{} + w, err := age.Encrypt(encrypted, r) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + decrypted, err := age.Decrypt(encrypted, i) + if err != nil { + t.Fatal(err) + } + out, err := io.ReadAll(decrypted) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(plaintext, out) { + t.Errorf("invalid output: %q, expected %q", out, plaintext) + } +} + +func TestHybridRoundTrip(t *testing.T) { + i := tagtest.NewHybridIdentity(t) + r := i.Recipient() + + if !r.Hybrid() { + t.Error("hybrid recipient incorrectly reports as classic") + } + + r1, err := tag.ParseRecipient(r.String()) + if err != nil { + t.Fatal(err) + } + if r1.String() != r.String() { + t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1.String(), r.String()) + } + if !r1.Hybrid() { + t.Error("parsed hybrid recipient incorrectly reports as classic") + } + + plaintext := []byte("hello world") + + encrypted := &bytes.Buffer{} + w, err := age.Encrypt(encrypted, r) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(plaintext); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + decrypted, err := age.Decrypt(encrypted, i) + if err != nil { + t.Fatal(err) + } + out, err := io.ReadAll(decrypted) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(plaintext, out) { + t.Errorf("invalid output: %q, expected %q", out, plaintext) + } +} From 528881ec43c807b0a26f06b6b9476f129991f060 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Mon, 17 Nov 2025 12:32:50 +0100 Subject: [PATCH 04/14] age,cmd/age,cmd/age-keygen: add post-quantum hybrid keys --- README.md | 28 ++++- age.go | 32 ++++-- agessh/agessh.go | 2 +- cmd/age-keygen/keygen.go | 63 ++++++++---- cmd/age-plugin-pq/plugin-pq.go | 148 +++++++++++++++++++++++++++ cmd/age/age.go | 2 + cmd/age/age_test.go | 15 +++ cmd/age/parse.go | 11 +- cmd/age/testdata/hybrid.txt | 47 +++++++++ cmd/age/testdata/keygen.txt | 25 +++++ doc/age-keygen.1.ronn | 22 +++- doc/age.1.ronn | 39 ++++--- parse.go | 39 +++++-- plugin/encode.go | 28 +++++ plugin/encode_go1.20.go | 24 ----- pq.go | 181 +++++++++++++++++++++++++++++++++ recipients_test.go | 62 +++++++++++ scrypt.go | 2 +- tag/tag_test.go | 31 ++++++ x25519.go | 10 +- 20 files changed, 720 insertions(+), 91 deletions(-) create mode 100644 cmd/age-plugin-pq/plugin-pq.go create mode 100644 cmd/age/testdata/hybrid.txt create mode 100644 cmd/age/testdata/keygen.txt delete mode 100644 plugin/encode_go1.20.go create mode 100644 pq.go diff --git a/README.md b/README.md index 3232002f..ea70f098 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ age is a simple, modern and secure file encryption tool, format, and Go library. -It features small explicit keys, no config options, and UNIX-style composability. +It features small explicit keys, post-quantum support, no config options, and UNIX-style composability. ``` $ age-keygen -o key.txt @@ -25,13 +25,13 @@ $ age --decrypt -i key.txt data.tar.gz.age > data.tar.gz 🦀 An alternative interoperable Rust implementation is available at [github.com/str4d/rage](https://github.com/str4d/rage). -🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, in Node.js, and in Bun. +🌍 [Typage](https://github.com/FiloSottile/typage) is a TypeScript implementation. It works in the browser, Node.js, Deno, and Bun. 🔑 Hardware PIV tokens such as YubiKeys are supported through the [age-plugin-yubikey](https://github.com/str4d/age-plugin-yubikey) plugin. ✨ For more plugins, implementations, tools, and integrations, check out the [awesome age](https://github.com/FiloSottile/awesome-age) list. -💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and is always spelled lowercase. +💬 The author pronounces it `[aɡe̞]` [with a hard *g*](https://translate.google.com/?sl=it&text=aghe), like GIF, and it's always spelled lowercase. ## Installation @@ -229,6 +229,28 @@ $ age -R recipients.txt example.jpg > example.jpg.age If the argument to `-R` (or `-i`) is `-`, the file is read from standard input. +### Post-quantum keys + +To generate hybrid post-quantum keys, which are secure against future quantum +computer attacks, use the `-pq` flag with `age-keygen`. This may become the +default in the future. + +Post-quantum identities start with `AGE-SECRET-KEY-PQ-1...` and recipients with +`age1pq1...`. The recipients are unfortunately ~2000 characters long. + +``` +$ age-keygen -pq -o key.txt +$ age-keygen -y key.txt > recipient.txt +$ age -R recipient.txt example.jpg > example.jpg.age +$ age -d -i key.txt example.jpg.age > example.jpg +``` + +Support for post-quantum keys is built into age v1.3.0 and later. Alternatively, +the `age-plugin-pq` binary can be installed and placed in `$PATH` to add support +to any version and implementation of age that supports plugins. Recipients will +work out of the box, while identities will have to be converted to plugin +identities with `age-plugin-pq -identity`. + ### Passphrases Files can be encrypted with a passphrase by using `-p/--passphrase`. By default age will automatically generate a secure passphrase. Passphrase protected files are automatically detected at decrypt time. diff --git a/age.go b/age.go index eb34c3e4..1942db7a 100644 --- a/age.go +++ b/age.go @@ -6,9 +6,9 @@ // specification. // // For most use cases, use the [Encrypt] and [Decrypt] functions with -// [X25519Recipient] and [X25519Identity]. If passphrase encryption is required, use -// [ScryptRecipient] and [ScryptIdentity]. For compatibility with existing SSH keys -// use the filippo.io/age/agessh package. +// [HybridRecipient] and [HybridIdentity]. If passphrase encryption is +// required, use [ScryptRecipient] and [ScryptIdentity]. For compatibility with +// existing SSH keys use the filippo.io/age/agessh package. // // age encrypted files are binary and not malleable. For encoding them as text, // use the filippo.io/age/armor package. @@ -26,13 +26,13 @@ // There is no default path for age keys. Instead, they should be stored at // application-specific paths. The CLI supports files where private keys are // listed one per line, ignoring empty lines and lines starting with "#". These -// files can be parsed with ParseIdentities. +// files can be parsed with [ParseIdentities]. // // When integrating age into a new system, it's recommended that you only -// support X25519 keys, and not SSH keys. The latter are supported for manual -// encryption operations. If you need to tie into existing key management -// infrastructure, you might want to consider implementing your own Recipient -// and Identity. +// support native (X25519 and hybrid) keys, and not SSH keys. The latter are +// supported for manual encryption operations. If you need to tie into existing +// key management infrastructure, you might want to consider implementing your +// own [Recipient] and [Identity]. // // # Backwards compatibility // @@ -52,6 +52,7 @@ import ( "errors" "fmt" "io" + "slices" "sort" "filippo.io/age/internal/format" @@ -59,7 +60,7 @@ import ( ) // An Identity is passed to [Decrypt] to unwrap an opaque file key from a -// recipient stanza. It can be for example a secret key like [X25519Identity], a +// recipient stanza. It can be for example a secret key like [HybridIdentity], a // plugin, or a custom implementation. type Identity interface { // Unwrap must return an error wrapping [ErrIncorrectIdentity] if none of @@ -76,7 +77,7 @@ type Identity interface { var ErrIncorrectIdentity = errors.New("incorrect identity for recipient block") // A Recipient is passed to [Encrypt] to wrap an opaque file key to one or more -// recipient stanza(s). It can be for example a public key like [X25519Recipient], +// recipient stanza(s). It can be for example a public key like [HybridRecipient], // a plugin, or a custom implementation. type Recipient interface { // Most age API users won't need to interact with this method directly, and @@ -142,7 +143,7 @@ func Encrypt(dst io.Writer, recipients ...Recipient) (io.WriteCloser, error) { if i == 0 { labels = l } else if !slicesEqual(labels, l) { - return nil, fmt.Errorf("incompatible recipients") + return nil, incompatibleLabelsError(labels, l) } for _, s := range stanzas { hdr.Recipients = append(hdr.Recipients, (*format.Stanza)(s)) @@ -188,6 +189,15 @@ func slicesEqual(s1, s2 []string) bool { return true } +func incompatibleLabelsError(l1, l2 []string) error { + hasPQ1 := slices.Contains(l1, "postquantum") + hasPQ2 := slices.Contains(l2, "postquantum") + if hasPQ1 != hasPQ2 { + return fmt.Errorf("incompatible recipients: can't mix post-quantum and classic recipients, or the file would be vulnerable to quantum computers") + } + return fmt.Errorf("incompatible recipients: %q and %q can't be mixed", l1, l2) +} + // NoIdentityMatchError is returned by [Decrypt] when none of the supplied // identities match the encrypted file. type NoIdentityMatchError struct { diff --git a/agessh/agessh.go b/agessh/agessh.go index ec2ccdda..9af9a7c8 100644 --- a/agessh/agessh.go +++ b/agessh/agessh.go @@ -7,7 +7,7 @@ // encryption with age-encryption.org/v1. // // These recipient types should only be used for compatibility with existing -// keys, and native X25519 keys should be preferred otherwise. +// keys, and native keys should be preferred otherwise. // // Note that these recipient types are not anonymous: the encrypted message will // include a short 32-bit ID of the public key. diff --git a/cmd/age-keygen/keygen.go b/cmd/age-keygen/keygen.go index 0913de6c..269b5609 100644 --- a/cmd/age-keygen/keygen.go +++ b/cmd/age-keygen/keygen.go @@ -18,15 +18,18 @@ import ( ) const usage = `Usage: - age-keygen [-o OUTPUT] + age-keygen [-pq] [-o OUTPUT] age-keygen -y [-o OUTPUT] [INPUT] Options: + -pq Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair. + (This might become the default in the future.) -o, --output OUTPUT Write the result to the file at path OUTPUT. -y Convert an identity file to a recipients file. -age-keygen generates a new native X25519 key pair, and outputs it to -standard output or to the OUTPUT file. +age-keygen generates a new native X25519 or, with the -pq flag, post-quantum +hybrid ML-KEM-768 + X25519 key pair, and outputs it to standard output or to +the OUTPUT file. If an OUTPUT file is specified, the public key is printed to standard error. If OUTPUT already exists, it is not overwritten. @@ -42,6 +45,11 @@ Examples: # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 + $ age-keygen -pq + # created: 2025-11-17T12:15:17+01:00 + # public key: age1pq1pd[... 1950 more characters ...] + AGE-SECRET-KEY-PQ-1XXC4XS9DXHZ6TREKQTT3XECY8VNNU7GJ83C3Y49D0GZ3ZUME4JWS6QC3EF + $ age-keygen -o key.txt Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p @@ -52,12 +60,11 @@ func main() { log.SetFlags(0) flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } - var ( - versionFlag, convertFlag bool - outFlag string - ) + var outFlag string + var pqFlag, versionFlag, convertFlag bool flag.BoolVar(&versionFlag, "version", false, "print the version") + flag.BoolVar(&pqFlag, "pq", false, "generate a post-quantum key pair") flag.BoolVar(&convertFlag, "y", false, "convert identities to recipients") flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)") flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)") @@ -68,6 +75,9 @@ func main() { if len(flag.Args()) > 1 && convertFlag { errorf("too many arguments") } + if pqFlag && convertFlag { + errorf("-pq cannot be used with -y") + } if versionFlag { if buildInfo, ok := debug.ReadBuildInfo(); ok { fmt.Println(buildInfo.Main.Version) @@ -107,23 +117,36 @@ func main() { if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 { warning("writing secret key to a world-readable file") } - generate(out) + generate(out, pqFlag) } } -func generate(out *os.File) { - k, err := age.GenerateX25519Identity() - if err != nil { - errorf("internal error: %v", err) +func generate(out *os.File, pq bool) { + var i age.Identity + var r age.Recipient + if pq { + k, err := age.GenerateHybridIdentity() + if err != nil { + errorf("internal error: %v", err) + } + i = k + r = k.Recipient() + } else { + k, err := age.GenerateX25519Identity() + if err != nil { + errorf("internal error: %v", err) + } + i = k + r = k.Recipient() } if !term.IsTerminal(int(out.Fd())) { - fmt.Fprintf(os.Stderr, "Public key: %s\n", k.Recipient()) + fmt.Fprintf(os.Stderr, "Public key: %s\n", r) } fmt.Fprintf(out, "# created: %s\n", time.Now().Format(time.RFC3339)) - fmt.Fprintf(out, "# public key: %s\n", k.Recipient()) - fmt.Fprintf(out, "%s\n", k) + fmt.Fprintf(out, "# public key: %s\n", r) + fmt.Fprintf(out, "%s\n", i) } func convert(in io.Reader, out io.Writer) { @@ -135,11 +158,15 @@ func convert(in io.Reader, out io.Writer) { errorf("no identities found in the input") } for _, id := range ids { - id, ok := id.(*age.X25519Identity) - if !ok { + switch id := id.(type) { + case *age.X25519Identity: + fmt.Fprintf(out, "%s\n", id.Recipient()) + case *age.HybridIdentity: + fmt.Fprintf(out, "%s\n", id.Recipient()) + default: errorf("internal error: unexpected identity type: %T", id) } - fmt.Fprintf(out, "%s\n", id.Recipient()) + } } diff --git a/cmd/age-plugin-pq/plugin-pq.go b/cmd/age-plugin-pq/plugin-pq.go new file mode 100644 index 00000000..0773e620 --- /dev/null +++ b/cmd/age-plugin-pq/plugin-pq.go @@ -0,0 +1,148 @@ +package main + +import ( + "flag" + "fmt" + "io" + "log" + "os" + "runtime/debug" + + "filippo.io/age" + "filippo.io/age/internal/bech32" + "filippo.io/age/plugin" +) + +const usage = `Usage: + age-plugin-pq -identity [-o OUTPUT] [INPUT] + +Options: + -identity Convert one or more native post-quantum identities from + INPUT or from standard input to plugin identities. + -o, --output OUTPUT Write the result to the file at path OUTPUT instead of + standard output. + +age-plugin-pq is an age plugin for post-quantum hybrid ML-KEM-768 + X25519 +recipients and identities. These are supported natively by age v1.3.0 and later, +but this plugin can be placed in $PATH to add support to any version and +implementation of age that supports plugins. + +Recipients work out of the box, while identities need to be converted to plugin +identities with -identity. If OUTPUT already exists, it is not overwritten.` + +func main() { + log.SetFlags(0) + + p, err := plugin.New("pq") + if err != nil { + log.Fatal(err) + } + p.RegisterFlags(nil) + + flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } + + var outFlag string + var versionFlag, identityFlag bool + flag.BoolVar(&versionFlag, "version", false, "print the version") + flag.BoolVar(&identityFlag, "identity", false, "convert identities to plugin identities") + flag.StringVar(&outFlag, "o", "", "output to `FILE` (default stdout)") + flag.StringVar(&outFlag, "output", "", "output to `FILE` (default stdout)") + flag.Parse() + + if versionFlag { + if buildInfo, ok := debug.ReadBuildInfo(); ok { + fmt.Println(buildInfo.Main.Version) + return + } + fmt.Println("(unknown)") + return + } + + if identityFlag { + if len(flag.Args()) > 1 { + errorf("too many arguments") + } + + out := os.Stdout + if outFlag != "" { + f, err := os.OpenFile(outFlag, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + errorf("failed to open output file %q: %v", outFlag, err) + } + defer func() { + if err := f.Close(); err != nil { + errorf("failed to close output file %q: %v", outFlag, err) + } + }() + out = f + } + if fi, err := out.Stat(); err == nil && fi.Mode().IsRegular() && fi.Mode().Perm()&0004 != 0 { + warning("writing secret key to a world-readable file") + } + + in := os.Stdin + if inFile := flag.Arg(0); inFile != "" && inFile != "-" { + f, err := os.Open(inFile) + if err != nil { + errorf("failed to open input file %q: %v", inFile, err) + } + defer f.Close() + in = f + } + + convert(in, out) + return + } + + p.HandleRecipientEncoding(func(s string) (age.Recipient, error) { + return age.ParseHybridRecipient(s) + }) + p.HandleIdentity(func(data []byte) (age.Identity, error) { + // Convert from a AGE-PLUGIN-PQ-1... payload to a + // AGE-SECRET-KEY-PQ-1... identity encoding. + s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data) + if err != nil { + return nil, err + } + return age.ParseHybridIdentity(s) + }) + p.HandleIdentityAsRecipient(func(data []byte) (age.Recipient, error) { + s, err := bech32.Encode("AGE-SECRET-KEY-PQ-", data) + if err != nil { + return nil, err + } + i, err := age.ParseHybridIdentity(s) + if err != nil { + return nil, err + } + return i.Recipient(), nil + }) + os.Exit(p.Main()) +} + +func convert(in io.Reader, out io.Writer) { + ids, err := age.ParseIdentities(in) + if err != nil { + errorf("failed to parse identities: %v", err) + } + for i, id := range ids { + hybridID, ok := id.(*age.HybridIdentity) + if !ok { + errorf("identity #%d is not a post-quantum hybrid identity", i+1) + } + _, data, err := bech32.Decode(hybridID.String()) + if err != nil { + errorf("failed to decode identity #%d: %v", i+1, err) + } + fmt.Fprintln(out, plugin.EncodeIdentity("pq", data)) + } +} + +func errorf(format string, v ...interface{}) { + log.Printf("age-plugin-pq: error: "+format, v...) + log.Fatalf("age-plugin-pq: report unexpected or unhelpful errors at https://filippo.io/age/report") +} + +func warning(msg string) { + log.Printf("age-plugin-pq: warning: %s", msg) +} diff --git a/cmd/age/age.go b/cmd/age/age.go index 58a75fee..cebcc82f 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -494,6 +494,8 @@ func identitiesToRecipients(ids []age.Identity) ([]age.Recipient, error) { switch id := id.(type) { case *age.X25519Identity: recipients = append(recipients, id.Recipient()) + case *age.HybridIdentity: + recipients = append(recipients, id.Recipient()) case *plugin.Identity: recipients = append(recipients, id.Recipient()) case *agessh.RSAIdentity: diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 35412322..fcd7fdcd 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -6,6 +6,8 @@ package main import ( "os" + "os/exec" + "path/filepath" "testing" "filippo.io/age" @@ -58,6 +60,19 @@ func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) { func TestScript(t *testing.T) { testscript.Run(t, testscript.Params{ Dir: "testdata", + Setup: func(e *testscript.Env) error { + bindir := filepath.SplitList(os.Getenv("PATH"))[0] + // Build age-keygen and age-plugin-pq into the test binary directory + cmd := exec.Command("go", "build", "-o", bindir) + if testing.CoverMode() != "" { + cmd.Args = append(cmd.Args, "-cover") + } + cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen") + cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-pq") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + }, // TODO: enable AGEDEBUG=plugin without breaking stderr checks. }) } diff --git a/cmd/age/parse.go b/cmd/age/parse.go index 70dcd86d..9d1a5ff8 100644 --- a/cmd/age/parse.go +++ b/cmd/age/parse.go @@ -33,6 +33,8 @@ func parseRecipient(arg string) (age.Recipient, error) { switch { case strings.HasPrefix(arg, "age1tag1") || strings.HasPrefix(arg, "age1tagpq1"): return tag.ParseRecipient(arg) + case strings.HasPrefix(arg, "age1pq1"): + return age.ParseHybridRecipient(arg) case strings.HasPrefix(arg, "age1") && strings.Count(arg, "1") > 1: return plugin.NewRecipient(arg, pluginTerminalUI) case strings.HasPrefix(arg, "age1"): @@ -124,8 +126,9 @@ func sshKeyType(s string) (string, bool) { } // parseIdentitiesFile parses a file that contains age or SSH keys. It returns -// one or more of *age.X25519Identity, *agessh.RSAIdentity, *agessh.Ed25519Identity, -// *agessh.EncryptedSSHIdentity, or *EncryptedIdentity. +// one or more of *[age.X25519Identity], *[age.HybridIdentity], +// *[agessh.RSAIdentity], *[agessh.Ed25519Identity], +// *[agessh.EncryptedSSHIdentity], or *[EncryptedIdentity]. func parseIdentitiesFile(name string) ([]age.Identity, error) { var f *os.File if name == "-" { @@ -204,12 +207,14 @@ func parseIdentity(s string) (age.Identity, error) { return plugin.NewIdentity(s, pluginTerminalUI) case strings.HasPrefix(s, "AGE-SECRET-KEY-1"): return age.ParseX25519Identity(s) + case strings.HasPrefix(s, "AGE-SECRET-KEY-PQ-1"): + return age.ParseHybridIdentity(s) default: return nil, fmt.Errorf("unknown identity type") } } -// parseIdentities is like age.ParseIdentities, but supports plugin identities. +// parseIdentities is like [age.ParseIdentities], but supports plugin identities. func parseIdentities(f io.Reader) ([]age.Identity, error) { const privateKeySizeLimit = 1 << 24 // 16 MiB var ids []age.Identity diff --git a/cmd/age/testdata/hybrid.txt b/cmd/age/testdata/hybrid.txt new file mode 100644 index 00000000..3e8f971a --- /dev/null +++ b/cmd/age/testdata/hybrid.txt @@ -0,0 +1,47 @@ +# encrypt and decrypt a file with -r +age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input +age -d -i key.txt test.age +cmp stdout input +! stderr . + +# encrypt and decrypt a file with -i +age -e -i key.txt -o test.age input +age -d -i key.txt test.age +cmp stdout input +! stderr . + +# encrypt and decrypt a file with the wrong key +age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input +! age -d -i key.txt test.age +stderr 'no identity matched any of the recipients' + +age -r age1pq13tgqupu990y5qlwwjy4zrzwvxg54cq89x9pxzdjenv4cl7affsqeerksrc6ufw3ndp4h5p3l5hm2jekkd7uay2mlh9eqg3sjcwmv5dcrwguc8djf83qr8gswtdrpav3jpw47kgkh6z0rnfk92xyqeyce48t02n4qz68augry3r3nd4rpdsw998vnnxzd2vepypktx2wxhfvd56jw7p32te6ttcyhc7tkqy2ks77jtjl8cgyal43pcd3g9wkcsgytccqtcnzcucjjdadpr0v7ye8u9fqusq6cq2ah0e4ejrltc8sk6763d7mjkuchmc5tkx6xztygqjx4ldxqh7jzkkyuv6ywt07ys4x09eq7gat4wuznrvgs3pkxjju8t9n0gpfqrsv658m5v09ltqc3c5z30n43ld43dwe5dn8nsk89f43tzsw2zta5kvl95zv0a94tc5y9r4ncj8g63fgljzhdxl96eg39rgd56yuvr2xdvavkk93a85n99jpjeqkfveprqc7eqft0wp7qgxcgsrwcdymgp0cqaj93jp4ckln36ylcwdzew234esa2awty25zc6pmc0jcpkrfuuptr9z5a4kx9ff4ppfff2lw95um853tcg47fa444er9k9j4e8vy5hfjx626j3tz3kc94jl8r66ahfprwtcyxae3swe9xxjhx5dfnt53n5fs2ut83t597wscjg9jc4f6h4uen2ll2ne30w7uvts8vuc5mpcfpp9espa78y0f8588m62kzez5kxxtcxykdkrnjx08w5az3pr6g6dsykwazcxtetpkq85mg7kay84z58slmvxmq56342pm6v8y3evr5wvc9g4j87jfhmryymkz9wk2en2v6tfy83rz657t0w7p3va46jt22f29u9ggzt9nu6qtguw4cp7cpj572ccyenw5rdh43h6s54g73gvxapsq3hw5yfkgttcyhkrjf9ykzd3wm5zsqgp5fca0nr56dnqe3kqrszasagqne0a83cguzwj9aw456jaxg9rurq4n4f6qec4srd0rxqfqc6rw22c2p4xyg25pls4dmxvce2fah8m6shue84preky2h0c4sa9y9tjuzn20gr9aav96e689nqx23s7zz0lvhq925jdkn04cdpzfscvhh93vh9f2tmwkzsq7pq0ncjk8g8cu75u77lyej46a4m2fefy8v2x4nzay05kulhyzk69520r7fdg3fzh0z7ysequcltr94hyf88h46hfujk30x9jp8u0pcjywh06g7tv483ypmc3pm3x3z4h9ph66lx228t3r96hyt764yxe2rn5clnfxkj3k5wf5hffehj58y56qyykwayk6qt9skvctxw20v9xy5ppnld3lushqpsutxht7cqygypdn5d2ppdgaerqzgehyrpdwzkhu0qe8w3u5h6htz8aa5zfpzy4s7sl8zdv3q0gq8ez08p86e4v3m82882yvawrvyrcxewrznwkvvez8m5aj6ktgvynyy0kc2trmtjzvjlxdf06e3rjmf55lwxrucfwu2sxdtnte83fgq0tvr2juv3pfqp8ddsrnckzqcfcvjp02mfg5y4aqlsunxpfcrdm46fphwsslrudwrfh542xg62kphca6h3xxqn538pprkknt35y00ygvrse5mxpvnstvcrnak5qduhf5dqslkn0yeadgpq6tv4wzy98kdjdzp22cpq6dy3kve856y2qlk7elyqyzj7ezpnh3vjwwmcm7ctp23k2sct86jd46ztplsq5vdjg9lyspxt0k8qx5udu9lwgulzapn3kdg7yd2zdz5dqf9933mpzwc32x8uxn8h2v5hlhdd4qk0uvwxhxyul8keaw39pz2avywk3wfly6veet9pjnj7nqecrgz824whs9sf7wl2shxk9kvkteht4z9x3w2gc6hqz5y -o test.age input +! age -d -i key.txt test.age +stderr 'no identity matched any of the recipients' + +# cannot mix hybrid and X25519 recipients +! age -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -o test.age input +stderr 'incompatible' + +! age -r age12phkzssndd5axajas2h74vtge62c86xjhd6u9anyanqhzvdg6sps0xthgl -r age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 -o test.age input +stderr 'incompatible' + +# convert to plugin identity and use plugin +exec age-plugin-pq -identity -o key-plugin.txt key.txt + +age -e -i key.txt -o test.age input +age -d -i key-plugin.txt test.age +cmp stdout input +! stderr . + +age -e -i key-plugin.txt -o test.age input +age -d -i key.txt test.age +cmp stdout input +! stderr . + +-- input -- +test +-- key.txt -- +# created: 2025-11-17T13:27:37+01:00 +# public key: age1pq1044e3hfmq2tltgck54m0gnpv8rfrwkdxjgl38psnwg8zfyv4uqdu63kk0u94ztt0q35k9dzuwgv405kxq6due2sre2kfsjftf7k2vz2mdpetku9pufy9d6ny6k5ss0cghpxdxrux0yd5xy40f8fu8ch9xn2rczyek2pqetqk0l9fqj0e3hfhkyfmcf9j6cpe5mf5stt9cymfq3xm276x4sramhn8srr42g78q9rkmqesevy33p4kdhkuggl99x9zx7j05uflx3s8q558xeaq9q9ctxqx52rzg6yffwykxngvxf7g3n83ddawagq2lnk59kcnexm54jrsmkjk9nemlge9v6p7fxu5z6yvq3ghpet8ww2c3h9fylvjx6j9njwz744376ap5r3xcpzh7qlk0kq3gknzfqhmxmjqypf6xz6geffdmy25apzsfr4ce0x825njkf62mgzx0x9n3zt0490u4dnt4468zse4zvd9sv0ycx2u2wsseuy4yymrg0nmfuxcwq9q0jykq7g5f59vunf95vnmcw336ksrh43fgapsd40u4227ymx56xtudtrte39twnq6fdfdj4p9pgx7pcqdk953za76fqcnvatx2cmywurag7ky4y8ecq5lvf25rydhxg6gmcn6vf78vtsqw79hp3mzv2u4v4jzn0nk50kl4gpv7k60cgz79c6k30jzy8dyetqtwg04tguxkv2p4hqj02yvsz2szaqdn9flu24rhth3v0t33023nwy27gt0x32eqpa5tpz6ygz3yzcsa3w889drpynzd5hlywuk9dwgafuax7upfnnxch4znrx4vactasg32u7vl886pxfswskk7wjgqkggrewtnaecfusl3nmg0xxh8e5nl5eft0xqwj5rk9ajn2j6yxehhgg5k6kpw5pk382hxmpu8wcd3rqx6m8905c7f8v0x2z9gyduxtqp2fdy9w2z4g2rrgu4d5cqes5tnwag007h2f326s3j9p6970xyd73rv5awx27zzezrax467c6vuye62ha88pzhw9xytjpezw3vg3l6x4k9rxwm9f6lwj6j7r9makg59rhgt0355s3jx9vw3raz5qdk5cnpny56q5h3dyd75suskuz63wmmfp9333tx4ry38vnh7jxy0j0cka99y7v2dmyjldjy0mqjetcefsmmg3pw0h9ll0rtskpmxz2cs8vmx6lu4v5rjtzpzgaqt46cwvnmhy5e3ye0tv6sre342lwg0tfd0yjkfz2haqc3hprpqf5ce8c9lx076dnk5cukn3tu38jgprrxt9sk9nqzt8grvkc4qzlg8jjf638v07ej9cf4eu48kphrgyjy83qm822xq8v9k22zwa7txr439x74j2f9ay7frkwtfdwn02enyx8stjpr4wgg25n5tj7uxyx0dqt3hnrqf7dcgt9fqnkjgemjfzyck6pawewptcayxm49sq3477f4cvm2p8fmg27rcmwm5l2vmzvdmymvkrr3aywu04y3zlhs7awu3r9u9lseheqpffvm2xf8y6w2qaj9g22pcu0sdsnqseusrxq9awru5lyqxnzftlslgpe3njegxkze98rveswp3u0xgx69wkg38vgp4k55lwwgv5l7ccv503mh9q23nud4xtm499xq276u40rxyrzaqfyuz2f3v5qr2vq00xs4g3ruynmq0c6nprvjcwqc5zrdn4cnzeeuwumjvqfjdpxr739n4cqcwytc8pjmfcj2qs3l2a8p5fzq7nvtng2jvvklx0x5ypnlkeuegsm587f6d4dkwel2es0lwwt88y2jdg8enjyph02tnjyndlx34h52fngjk96ggtu6vcxqkreqcxs4gwa8ww40t0kzlxfzyfhtjmfdm5fcrr5pt2xm4yslfynr0h9wkranhyadxf33kjn9xpg2fq5hnlq0 +AGE-SECRET-KEY-PQ-16XDSLZ3XCSZ3236YJ5J0T9NAPLZTP96LJKQCVHJYUDTQVJR5J5PQTDPQCX diff --git a/cmd/age/testdata/keygen.txt b/cmd/age/testdata/keygen.txt new file mode 100644 index 00000000..a34db827 --- /dev/null +++ b/cmd/age/testdata/keygen.txt @@ -0,0 +1,25 @@ +exec age-keygen +stdout '# created: 20' +stdout '# public key: age1' +stdout 'AGE-SECRET-KEY-1' +stderr 'Public key: age1' + +exec age-keygen -pq +stdout '# created: 20' +stdout '# public key: age1pq1' +stdout 'AGE-SECRET-KEY-PQ-1' +stderr 'Public key: age1pq1' + +exec age-keygen -pq -o key.txt +! stdout . +stderr 'Public key: age1pq1' +grep '# created: 20' key.txt +grep '# public key: age1pq1' key.txt +grep 'AGE-SECRET-KEY-PQ-1' key.txt + +stdin key.txt +exec age-keygen -y +stdout age1pq1 + +exec age-keygen -y key.txt +stdout age1pq1 diff --git a/doc/age-keygen.1.ronn b/doc/age-keygen.1.ronn index fef248c8..6bef7b65 100644 --- a/doc/age-keygen.1.ronn +++ b/doc/age-keygen.1.ronn @@ -3,7 +3,7 @@ age-keygen(1) -- generate age(1) key pairs ## SYNOPSIS -`age-keygen` [`-o` ]
+`age-keygen` [`-pq`] [`-o` ]
`age-keygen` `-y` [`-o` ] []
## DESCRIPTION @@ -17,6 +17,11 @@ standard error. ## OPTIONS +* `-pq`: + Generate a post-quantum hybrid ML-KEM-768 + X25519 key pair. + + In the future, this might become the default. + * `-o`, `--output`=: Write the identity to instead of standard output. @@ -31,22 +36,29 @@ standard error. ## EXAMPLES -Generate a new identity: +Generate a new post-quantum identity: + + $ age-keygen -pq + # created: 2025-11-17T13:39:06+01:00 + # public key: age1pq167[... 1950 more characters ...] + AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T + +Generate a new traditional identity: $ age-keygen # created: 2021-01-02T15:30:45+01:00 # public key: age1lvyvwawkr0mcnnnncaghunadrqkmuf9e6507x9y920xxpp866cnql7dp2z AGE-SECRET-KEY-1N9JEPW6DWJ0ZQUDX63F5A03GX8QUW7PXDE39N8UYF82VZ9PC8UFS3M7XA9 -Write a new identity to `key.txt`: +Write a new post-quantum identity to `key.txt`: $ age-keygen -o key.txt - Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + Public key: age1pq1cd[... 1950 more characters ...] Convert an identity to a recipient: $ age-keygen -y key.txt - age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + age1pq1cd[... 1950 more characters ...] ## SEE ALSO diff --git a/doc/age.1.ronn b/doc/age.1.ronn index 1d71a4b4..64d0c4b9 100644 --- a/doc/age.1.ronn +++ b/doc/age.1.ronn @@ -148,21 +148,35 @@ overhead per recipient, plus 16 bytes every 64KiB of plaintext. to. `IDENTITIES` are private values, like a private key, that allow decrypting a file encrypted to the corresponding `RECIPIENT`. -### Native X25519 keys +### Native keys Native `age` key pairs are generated with age-keygen(1), and provide small -encodings and strong encryption based on X25519. They are the recommended -recipient type for most applications. +encodings and strong encryption based on X25519 for classic keys, and X25519 + +ML-KEM-768 for post-quantum hybrid keys. The post-quantum hybrid keys are secure +against future quantum computers and are the recommended recipient type for most +applications. -A `RECIPIENT` encoding begins with `age1` and looks like the following: +A hybrid `RECIPIENT` encoding begins with `age1pq1` and looks like the following: + + age1pq167[... 1950 more characters ...] + +A hybrid `IDENTITY` encoding begins with `AGE-SECRET-KEY-PQ-1` and looks like +the following: + + AGE-SECRET-KEY-PQ-1K30MYPZAHAXHR22YHH27EGDVLU0QNSUH3DSV7J7NR3X6D9LHXNWSDLTV4T + +A classic `RECIPIENT` encoding begins with `age1` and looks like the following: age1gde3ncmahlqd9gg50tanl99r960llztrhfapnmx853s4tjum03uqfssgdh -An `IDENTITY` encoding begins with `AGE-SECRET-KEY-1` and looks like the +A classic `IDENTITY` encoding begins with `AGE-SECRET-KEY-1` and looks like the following: AGE-SECRET-KEY-1KTYK6RVLN5TAPE7VF6FQQSKZ9HWWCDSKUGXXNUQDWZ7XXT5YK5LSF3UTKQ +A file can't be encrypted to both post-quantum and classic keys, as that would +defeat the post-quantum security of the encryption. + An encrypted file can't be linked to the native recipient it's encrypted to without access to the corresponding identity. @@ -243,27 +257,26 @@ by default. In this case, a flag will be provided to force the operation. ## EXAMPLES -Generate a new identity, encrypt data, and decrypt: +Generate a new post-quantum identity, encrypt data, and decrypt: - $ age-keygen -o key.txt - Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + $ age-keygen -pq -o key.txt + Public key: age1pq167[... 1950 more characters ...] - $ tar cvz ~/data | age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p > data.tar.gz.age + $ tar cvz ~/data | age -r age1pq167[...] > data.tar.gz.age $ age -d -o data.tar.gz -i key.txt data.tar.gz.age Encrypt `example.jpg` to multiple recipients and output to `example.jpg.age`: - $ age -o example.jpg.age -r age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \ - -r age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg example.jpg + $ age -o example.jpg.age -r age1pq167[...] -r age1pq1e3[...] example.jpg Encrypt to a list of recipients: $ cat > recipients.txt # Alice - age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p + age1pq167[... 1950 more characters ...] # Bob - age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg + age1pq1e3[... 1950 more characters ...] $ age -R recipients.txt example.jpg > example.jpg.age diff --git a/parse.go b/parse.go index 373d1a89..73615654 100644 --- a/parse.go +++ b/parse.go @@ -16,10 +16,10 @@ import ( // // This is the same syntax as the private key files accepted by the CLI, except // the CLI also accepts SSH private keys, which are not recommended for the -// average application. +// average application, and plugins, which involve invoking external programs. // -// Currently, all returned values are of type *X25519Identity, but different -// types might be returned in the future. +// Currently, all returned values are of type *[X25519Identity] or +// *[HybridIdentity], but different types might be returned in the future. func ParseIdentities(f io.Reader) ([]Identity, error) { const privateKeySizeLimit = 1 << 24 // 16 MiB var ids []Identity @@ -31,7 +31,7 @@ func ParseIdentities(f io.Reader) ([]Identity, error) { if strings.HasPrefix(line, "#") || line == "" { continue } - i, err := ParseX25519Identity(line) + i, err := parseIdentity(line) if err != nil { return nil, fmt.Errorf("error at line %d: %v", n, err) } @@ -46,15 +46,27 @@ func ParseIdentities(f io.Reader) ([]Identity, error) { return ids, nil } +func parseIdentity(arg string) (Identity, error) { + switch { + case strings.HasPrefix(arg, "AGE-SECRET-KEY-1"): + return ParseX25519Identity(arg) + case strings.HasPrefix(arg, "AGE-SECRET-KEY-PQ-1"): + return ParseHybridIdentity(arg) + default: + return nil, fmt.Errorf("unknown identity type: %q", arg) + } +} + // ParseRecipients parses a file with one or more public key encodings, one per // line. Empty lines and lines starting with "#" are ignored. // // This is the same syntax as the recipients files accepted by the CLI, except // the CLI also accepts SSH recipients, which are not recommended for the -// average application. +// average application, tagged recipients, which have different privacy +// properties, and plugins, which involve invoking external programs. // -// Currently, all returned values are of type *X25519Recipient, but different -// types might be returned in the future. +// Currently, all returned values are of type *[X25519Recipient] or +// *[HybridRecipient] but different types might be returned in the future. func ParseRecipients(f io.Reader) ([]Recipient, error) { const recipientFileSizeLimit = 1 << 24 // 16 MiB var recs []Recipient @@ -66,7 +78,7 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) { if strings.HasPrefix(line, "#") || line == "" { continue } - r, err := ParseX25519Recipient(line) + r, err := parseRecipient(line) if err != nil { // Hide the error since it might unintentionally leak the contents // of confidential files. @@ -82,3 +94,14 @@ func ParseRecipients(f io.Reader) ([]Recipient, error) { } return recs, nil } + +func parseRecipient(arg string) (Recipient, error) { + switch { + case strings.HasPrefix(arg, "age1pq1"): + return ParseHybridRecipient(arg) + case strings.HasPrefix(arg, "age1"): + return ParseX25519Recipient(arg) + default: + return nil, fmt.Errorf("unknown recipient type: %q", arg) + } +} diff --git a/plugin/encode.go b/plugin/encode.go index 0a59fbe0..628d6f22 100644 --- a/plugin/encode.go +++ b/plugin/encode.go @@ -5,10 +5,13 @@ package plugin import ( + "crypto/ecdh" + "crypto/mlkem" "fmt" "strings" "filippo.io/age/internal/bech32" + "filippo.io/hpke" ) // EncodeIdentity encodes a plugin identity string for a plugin with the given @@ -78,3 +81,28 @@ func validPluginName(name string) bool { } return true } + +// EncodeX25519Recipient encodes a native X25519 recipient from a +// [crypto/ecdh.X25519] public key. It's meant for plugins that implement +// identities that are compatible with native recipients. +func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) { + if pk.Curve() != ecdh.X25519() { + return "", fmt.Errorf("wrong ecdh Curve") + } + return bech32.Encode("age", pk.Bytes()) +} + +// EncodeHybridRecipient encodes a native MLKEM768-X25519 recipient from a +// [crypto/mlkem.EncapsulationKey768] and a [crypto/ecdh.X25519] public key. +// It's meant for plugins that implement identities that are compatible with +// native recipients. +func EncodeHybridRecipient(pq *mlkem.EncapsulationKey768, t *ecdh.PublicKey) (string, error) { + if t.Curve() != ecdh.X25519() { + return "", fmt.Errorf("wrong ecdh Curve") + } + pk, err := hpke.NewHybridPublicKey(pq, t) + if err != nil { + return "", fmt.Errorf("failed to create hybrid public key: %v", err) + } + return bech32.Encode("age1pq", pk.Bytes()) +} diff --git a/plugin/encode_go1.20.go b/plugin/encode_go1.20.go deleted file mode 100644 index 6b171660..00000000 --- a/plugin/encode_go1.20.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright 2023 The age Authors. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -//go:build go1.20 - -package plugin - -import ( - "crypto/ecdh" - "fmt" - - "filippo.io/age/internal/bech32" -) - -// EncodeX25519Recipient encodes a native X25519 recipient from a -// [crypto/ecdh.X25519] public key. It's meant for plugins that implement -// identities that are compatible with native recipients. -func EncodeX25519Recipient(pk *ecdh.PublicKey) (string, error) { - if pk.Curve() != ecdh.X25519() { - return "", fmt.Errorf("wrong ecdh Curve") - } - return bech32.Encode("age", pk.Bytes()) -} diff --git a/pq.go b/pq.go new file mode 100644 index 00000000..46a5b067 --- /dev/null +++ b/pq.go @@ -0,0 +1,181 @@ +// Copyright 2025 The age Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package age + +import ( + "errors" + "fmt" + "strings" + + "filippo.io/age/internal/bech32" + "filippo.io/age/internal/format" + "filippo.io/hpke" + "golang.org/x/crypto/chacha20poly1305" +) + +const pqLabel = "age-encryption.org/mlkem768x25519" + +// HybridRecipient is the standard age public key. Messages encrypted to +// this recipient can be decrypted with the corresponding [HybridIdentity]. +// +// This recipient is safe against future cryptographically-relevant quantum +// computers, and can only be used along with other post-quantum recipients. +// +// This recipient is anonymous, in the sense that an attacker can't tell from +// the message alone if it is encrypted to a certain recipient. +type HybridRecipient struct { + pk hpke.PublicKey +} + +var _ Recipient = &HybridRecipient{} + +// newHybridRecipient returns a new [HybridRecipient] from a raw HPKE public key. +func newHybridRecipient(publicKey []byte) (*HybridRecipient, error) { + pk, err := hpke.MLKEM768X25519().NewPublicKey(publicKey) + if err != nil { + return nil, errors.New("invalid MLKEM768-X25519 public key") + } + return &HybridRecipient{pk: pk}, nil +} + +// ParseHybridRecipient returns a new [HybridRecipient] from a Bech32 public key +// encoding with the "age1pq1" prefix. +func ParseHybridRecipient(s string) (*HybridRecipient, error) { + t, k, err := bech32.Decode(s) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + if t != "age1pq" { + return nil, fmt.Errorf("malformed recipient %q: invalid type %q", s, t) + } + r, err := newHybridRecipient(k) + if err != nil { + return nil, fmt.Errorf("malformed recipient %q: %v", s, err) + } + return r, nil +} + +func (r *HybridRecipient) Wrap(fileKey []byte) ([]*Stanza, error) { + s, _, err := r.WrapWithLabels(fileKey) + return s, err +} + +// WrapWithLabels implements [RecipientWithLabels], returning a single +// "postquantum" label. This ensures a HybridRecipient can't be mixed with other +// recipients that would defeat its post-quantum security. +// +// To unsafely bypass this restriction, wrap HybridRecipient in a [Recipient] +// type that doesn't expose WrapWithLabels. +func (r *HybridRecipient) WrapWithLabels(fileKey []byte) ([]*Stanza, []string, error) { + enc, s, err := hpke.NewSender(r.pk, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel)) + if err != nil { + return nil, nil, fmt.Errorf("failed to set up HPKE sender: %v", err) + } + ct, err := s.Seal(nil, fileKey) + if err != nil { + return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err) + } + + l := &Stanza{ + Type: "mlkem768x25519", + Args: []string{format.EncodeToString(enc)}, + Body: ct, + } + + return []*Stanza{l}, []string{"postquantum"}, nil +} + +// String returns the Bech32 public key encoding of r. +func (r *HybridRecipient) String() string { + s, _ := bech32.Encode("age1pq", r.pk.Bytes()) + return s +} + +// HybridIdentity is the standard age private key, which can decrypt messages +// encrypted to the corresponding [HybridRecipient]. +type HybridIdentity struct { + k hpke.PrivateKey +} + +var _ Identity = &HybridIdentity{} + +// newHybridIdentity returns a new [HybridIdentity] from a raw HPKE private key. +func newHybridIdentity(secretKey []byte) (*HybridIdentity, error) { + k, err := hpke.MLKEM768X25519().NewPrivateKey(secretKey) + if err != nil { + return nil, errors.New("invalid MLKEM768-X25519 secret key") + } + return &HybridIdentity{k: k}, nil +} + +// GenerateHybridIdentity randomly generates a new [HybridIdentity]. +func GenerateHybridIdentity() (*HybridIdentity, error) { + k, err := hpke.MLKEM768X25519().GenerateKey() + if err != nil { + return nil, fmt.Errorf("failed to generate post-quantum identity: %v", err) + } + return &HybridIdentity{k: k}, nil +} + +// ParseHybridIdentity returns a new [HybridIdentity] from a Bech32 private key +// encoding with the "AGE-SECRET-KEY-PQ-1" prefix. +func ParseHybridIdentity(s string) (*HybridIdentity, error) { + t, k, err := bech32.Decode(s) + if err != nil { + return nil, fmt.Errorf("malformed secret key: %v", err) + } + if t != "AGE-SECRET-KEY-PQ-" { + return nil, fmt.Errorf("malformed secret key: unknown type %q", t) + } + r, err := newHybridIdentity(k) + if err != nil { + return nil, fmt.Errorf("malformed secret key: %v", err) + } + return r, nil +} + +func (i *HybridIdentity) Unwrap(stanzas []*Stanza) ([]byte, error) { + return multiUnwrap(i.unwrap, stanzas) +} + +func (i *HybridIdentity) unwrap(block *Stanza) ([]byte, error) { + if block.Type != "mlkem768x25519" { + return nil, ErrIncorrectIdentity + } + if len(block.Args) != 1 { + return nil, errors.New("invalid mlkem768x25519 recipient block") + } + enc, err := format.DecodeString(block.Args[0]) + if err != nil { + return nil, fmt.Errorf("failed to parse mlkem768x25519 recipient: %v", err) + } + if len(block.Body) != fileKeySize+chacha20poly1305.Overhead { + return nil, errIncorrectCiphertextSize + } + + r, err := hpke.NewRecipient(enc, i.k, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), []byte(pqLabel)) + if err != nil { + // MLKEM768-X25519 does implicit rejection, so a mismatched key does not + // hit this error path, but is only detected later when trying to open. + return nil, fmt.Errorf("invalid mlkem768x25519 recipient: %v", err) + } + fileKey, err := r.Open(nil, block.Body) + if err != nil { + return nil, ErrIncorrectIdentity + } + return fileKey, nil +} + +// Recipient returns the public [HybridRecipient] value corresponding to i. +func (i *HybridIdentity) Recipient() *HybridRecipient { + return &HybridRecipient{pk: i.k.PublicKey()} +} + +// String returns the Bech32 private key encoding of i. +func (i *HybridIdentity) String() string { + b, _ := i.k.Bytes() + s, _ := bech32.Encode("AGE-SECRET-KEY-PQ-", b) + return strings.ToUpper(s) +} diff --git a/recipients_test.go b/recipients_test.go index 52ceb580..b8372483 100644 --- a/recipients_test.go +++ b/recipients_test.go @@ -7,6 +7,7 @@ package age_test import ( "bytes" "crypto/rand" + "io" "testing" "filippo.io/age" @@ -49,6 +50,67 @@ func TestX25519RoundTrip(t *testing.T) { } } +func TestHybridRoundTrip(t *testing.T) { + i, err := age.GenerateHybridIdentity() + if err != nil { + t.Fatal(err) + } + r := i.Recipient() + + if r1, err := age.ParseHybridRecipient(r.String()); err != nil { + t.Fatal(err) + } else if r1.String() != r.String() { + t.Errorf("recipient did not round-trip through parsing: got %q, want %q", r1, r) + } + if i1, err := age.ParseHybridIdentity(i.String()); err != nil { + t.Fatal(err) + } else if i1.String() != i.String() { + t.Errorf("identity did not round-trip through parsing: got %q, want %q", i1, i) + } + + fileKey := make([]byte, 16) + if _, err := rand.Read(fileKey); err != nil { + t.Fatal(err) + } + stanzas, err := r.Wrap(fileKey) + if err != nil { + t.Fatal(err) + } + + out, err := i.Unwrap(stanzas) + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(fileKey, out) { + t.Errorf("invalid output: %x, expected %x", out, fileKey) + } +} + +func TestHybridMixingRestrictions(t *testing.T) { + x25519, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + hybrid, err := age.GenerateHybridIdentity() + if err != nil { + t.Fatal(err) + } + + // Hybrid recipients can be used together. + if _, err := age.Encrypt(io.Discard, hybrid.Recipient(), hybrid.Recipient()); err != nil { + t.Errorf("expected two hybrid recipients to work, got %v", err) + } + + // Hybrid and X25519 recipients cannot be mixed. + if _, err := age.Encrypt(io.Discard, hybrid.Recipient(), x25519.Recipient()); err == nil { + t.Error("expected hybrid mixed with X25519 to fail") + } + if _, err := age.Encrypt(io.Discard, x25519.Recipient(), hybrid.Recipient()); err == nil { + t.Error("expected X25519 mixed with hybrid to fail") + } +} + func TestScryptRoundTrip(t *testing.T) { password := "twitch.tv/filosottile" diff --git a/scrypt.go b/scrypt.go index 73d13b7f..0ed28592 100644 --- a/scrypt.go +++ b/scrypt.go @@ -27,7 +27,7 @@ const scryptLabel = "age-encryption.org/v1/scrypt" // for the same file. // // Its use is not recommended for automated systems, which should prefer -// X25519Recipient. +// [HybridRecipient] or [X25519Recipient]. type ScryptRecipient struct { password []byte workFactor int diff --git a/tag/tag_test.go b/tag/tag_test.go index dcbe931f..cf094d19 100644 --- a/tag/tag_test.go +++ b/tag/tag_test.go @@ -107,3 +107,34 @@ func TestHybridRoundTrip(t *testing.T) { t.Errorf("invalid output: %q, expected %q", out, plaintext) } } + +func TestTagHybridMixingRestrictions(t *testing.T) { + x25519, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + tagHybrid := tagtest.NewHybridIdentity(t).Recipient() + + // Hybrid tag recipients can be used together with hybrid recipients. + hybrid, err := age.GenerateHybridIdentity() + if err != nil { + t.Fatal(err) + } + if _, err := age.Encrypt(io.Discard, tagHybrid, hybrid.Recipient()); err != nil { + t.Errorf("expected hybrid tag + hybrid to work, got %v", err) + } + + // Hybrid tag and X25519 recipients cannot be mixed. + if _, err := age.Encrypt(io.Discard, tagHybrid, x25519.Recipient()); err == nil { + t.Error("expected hybrid tag mixed with X25519 to fail") + } + if _, err := age.Encrypt(io.Discard, x25519.Recipient(), tagHybrid); err == nil { + t.Error("expected X25519 mixed with hybrid tag to fail") + } + + // Classic tag and X25519 recipients can be mixed (both are non-PQ). + tagClassic := tagtest.NewClassicIdentity(t).Recipient() + if _, err := age.Encrypt(io.Discard, tagClassic, x25519.Recipient()); err != nil { + t.Errorf("expected classic tag + X25519 to work, got %v", err) + } +} diff --git a/x25519.go b/x25519.go index 6cd87a8d..6c0814db 100644 --- a/x25519.go +++ b/x25519.go @@ -21,8 +21,9 @@ import ( const x25519Label = "age-encryption.org/v1/X25519" -// X25519Recipient is the standard age public key. Messages encrypted to this -// recipient can be decrypted with the corresponding X25519Identity. +// X25519Recipient is the standard age pre-quantum public key. Messages +// encrypted to this recipient can be decrypted with the corresponding +// [X25519Identity]. For post-quantum resistance, use [HybridRecipient]. // // This recipient is anonymous, in the sense that an attacker can't tell from // the message alone if it is encrypted to a certain recipient. @@ -105,8 +106,9 @@ func (r *X25519Recipient) String() string { return s } -// X25519Identity is the standard age private key, which can decrypt messages -// encrypted to the corresponding X25519Recipient. +// X25519Identity is the standard pre-quantum age private key, which can decrypt +// messages encrypted to the corresponding [X25519Recipient]. For post-quantum +// resistance, use [HybridIdentity]. type X25519Identity struct { secretKey, ourPublicKey []byte } From c567a818f1399cf54be724c2f8ca52bdf4222237 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 7 Dec 2025 18:51:19 +0100 Subject: [PATCH 05/14] age: use native identities first in Decrypt --- age.go | 19 +++++++++++++++++++ age_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/age.go b/age.go index 1942db7a..00c39f61 100644 --- a/age.go +++ b/age.go @@ -214,6 +214,7 @@ func (*NoIdentityMatchError) Error() string { // // It returns a Reader reading the decrypted plaintext of the age file read // from src. All identities will be tried until one successfully decrypts the file. +// Native, non-interactive identities are tried before any other identities. // // If no identity matches the encrypted file, the returned error will be of type // [NoIdentityMatchError]. @@ -240,6 +241,24 @@ func decryptHdr(hdr *format.Header, identities ...Identity) ([]byte, error) { if len(identities) == 0 { return nil, errors.New("no identities specified") } + slices.SortStableFunc(identities, func(a, b Identity) int { + var aIsNative, bIsNative bool + switch a.(type) { + case *X25519Identity, *HybridIdentity, *ScryptIdentity: + aIsNative = true + } + switch b.(type) { + case *X25519Identity, *HybridIdentity, *ScryptIdentity: + bIsNative = true + } + if aIsNative && !bIsNative { + return -1 + } + if !aIsNative && bIsNative { + return 1 + } + return 0 + }) stanzas := make([]*Stanza, 0, len(hdr.Recipients)) for _, s := range hdr.Recipients { diff --git a/age_test.go b/age_test.go index ef870d47..dfc753ba 100644 --- a/age_test.go +++ b/age_test.go @@ -285,6 +285,50 @@ func TestLabels(t *testing.T) { } } +// testIdentity is a non-native identity that records if Unwrap is called. +type testIdentity struct { + called bool +} + +func (ti *testIdentity) Unwrap(stanzas []*age.Stanza) ([]byte, error) { + ti.called = true + return nil, age.ErrIncorrectIdentity +} + +func TestDecryptNativeIdentitiesFirst(t *testing.T) { + correct, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + unrelated, err := age.GenerateX25519Identity() + if err != nil { + t.Fatal(err) + } + + buf := &bytes.Buffer{} + w, err := age.Encrypt(buf, correct.Recipient()) + if err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + nonNative := &testIdentity{} + + // Pass identities: unrelated native, non-native, correct native. + // Native identities should be tried first, so correct should match + // before nonNative is ever called. + _, err = age.Decrypt(bytes.NewReader(buf.Bytes()), unrelated, nonNative, correct) + if err != nil { + t.Fatal(err) + } + + if nonNative.called { + t.Error("non-native identity was called, but native identities should be tried first") + } +} + func TestDetachedHeader(t *testing.T) { i, err := age.GenerateX25519Identity() if err != nil { From ef4e7efa94a15eaffb69d5bba5d0f4dab2b64245 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 7 Dec 2025 20:32:06 +0100 Subject: [PATCH 06/14] cmd/age-plugin-tag,cmd/age-plugin-tagpq: new backward compatibility plugins --- cmd/age-plugin-tag/plugin-tag.go | 32 +++++++++++++++++++++++++++ cmd/age-plugin-tagpq/plugin-tagpq.go | 33 ++++++++++++++++++++++++++++ doc/age.1.ronn | 14 ++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 cmd/age-plugin-tag/plugin-tag.go create mode 100644 cmd/age-plugin-tagpq/plugin-tagpq.go diff --git a/cmd/age-plugin-tag/plugin-tag.go b/cmd/age-plugin-tag/plugin-tag.go new file mode 100644 index 00000000..a3c5269b --- /dev/null +++ b/cmd/age-plugin-tag/plugin-tag.go @@ -0,0 +1,32 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "filippo.io/age" + "filippo.io/age/plugin" + "filippo.io/age/tag" +) + +const usage = `age-plugin-tag is an age plugin for P-256 tagged recipients. These are supported +natively by age v1.3.0 and later, but this plugin can be placed in $PATH to add +support to any version and implementation of age that supports plugins. + +Usually, tagged recipients are the public side of private keys held in hardware, +where the identity side is handled by a different plugin.` + +func main() { + flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } + + p, err := plugin.New("tag") + if err != nil { + log.Fatal(err) + } + p.HandleRecipient(func(b []byte) (age.Recipient, error) { + return tag.NewClassicRecipient(b) + }) + os.Exit(p.Main()) +} diff --git a/cmd/age-plugin-tagpq/plugin-tagpq.go b/cmd/age-plugin-tagpq/plugin-tagpq.go new file mode 100644 index 00000000..8577f65b --- /dev/null +++ b/cmd/age-plugin-tagpq/plugin-tagpq.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "filippo.io/age" + "filippo.io/age/plugin" + "filippo.io/age/tag" +) + +const usage = `age-plugin-tagpq is an age plugin for ML-KEM-768 + P-256 post-quantum hybrid +tagged recipients. These are supported natively by age v1.3.0 and later, but +this plugin can be placed in $PATH to add support to any version and +implementation of age that supports plugins. + +Usually, tagged recipients are the public side of private keys held in hardware, +where the identity side is handled by a different plugin.` + +func main() { + flag.Usage = func() { fmt.Fprintf(os.Stderr, "%s\n", usage) } + + p, err := plugin.New("tagpq") + if err != nil { + log.Fatal(err) + } + p.HandleRecipient(func(b []byte) (age.Recipient, error) { + return tag.NewHybridRecipient(b) + }) + os.Exit(p.Main()) +} diff --git a/doc/age.1.ronn b/doc/age.1.ronn index 64d0c4b9..ef76785f 100644 --- a/doc/age.1.ronn +++ b/doc/age.1.ronn @@ -237,6 +237,20 @@ instruct the user to perform encryption with the `-e`/`--encrypt` and doesn't make sense (such as a password-encryption plugin) may instruct the user to use the `-j` flag. +#### Tagged recipients + +`age` can natively encrypt to recipients starting with `age1tag1` (using P-256 +ECDH) or `age1tagpq1` (using the ML-KEM-768 + P-256 post-quantum hybrid). These +are intended to be the public side of private keys held in hardware. + +They are directly supported to avoid the need to install the plugin, which may +be platform-specific, on the encrypting side. + +The tag reduces privacy, by allowing an observer to correlate files with a +recipient (but not files amongst them without knowledge of the recipient), +but this is also a desirable property for hardware keys that require user +interaction for each decryption operation. + ## EXIT STATUS `age` will exit 0 if and only if encryption or decryption are successful for the From 5bec186e1158d38e68cfe127cde483c621f9a7b7 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 7 Dec 2025 20:59:14 +0100 Subject: [PATCH 07/14] .github/workflows: update and harden GitHub Actions workflows --- .github/workflows/build.yml | 5 +- .github/workflows/ronn.yml | 42 +++++++-------- .github/workflows/test.yml | 102 ++++++++++++++++++++++-------------- 3 files changed, 87 insertions(+), 62 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 836abae6..61b922db 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,19 +16,20 @@ jobs: - {GOOS: linux, GOARCH: amd64} - {GOOS: linux, GOARCH: arm, GOARM: 6} - {GOOS: linux, GOARCH: arm64} - - {GOOS: darwin, GOARCH: amd64} - {GOOS: darwin, GOARCH: arm64} - {GOOS: windows, GOARCH: amd64} - {GOOS: freebsd, GOARCH: amd64} steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 + persist-credentials: false - name: Install Go uses: actions/setup-go@v5 with: go-version: 1.x + cache: false - name: Build binary run: | cp LICENSE "$RUNNER_TEMP/LICENSE" diff --git a/.github/workflows/ronn.yml b/.github/workflows/ronn.yml index 9d4d0ae3..a0001070 100644 --- a/.github/workflows/ronn.yml +++ b/.github/workflows/ronn.yml @@ -13,23 +13,23 @@ jobs: name: Ronn runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Install ronn - run: sudo apt-get update && sudo apt-get install -y ronn - - name: Run ronn - run: bash -O globstar -c 'ronn **/*.ronn' - - name: Undo email mangling - # rdiscount randomizes the output for no good reason, which causes - # changes to always get committed. Sigh. - # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795 - run: |- - for f in doc/*.html; do - awk '/Filippo Valsorda/ { $0 = "

Filippo Valsorda age@filippo.io

" } { print }' "$f" > "$f.tmp" - mv "$f.tmp" "$f" - done - - name: Upload generated files - uses: actions/upload-artifact@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: geomys/sandboxed-step@v1.2.1 + with: + persist-workspace-changes: true + run: | + sudo apt-get update && sudo apt-get install -y ronn + bash -O globstar -c 'ronn **/*.ronn' + # rdiscount randomizes the output for no good reason, which causes + # changes to always get committed. Sigh. + # https://github.com/davidfstr/rdiscount/blob/6b1471ec3/ext/generate.c#L781-L795 + for f in doc/*.html; do + awk '/Filippo Valsorda/ { $0 = "

Filippo Valsorda age@filippo.io

" } { print }' "$f" > "$f.tmp" + mv "$f.tmp" "$f" + done + - uses: actions/upload-artifact@v4 with: name: man-pages path: | @@ -42,10 +42,10 @@ jobs: contents: write runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Download generated files - uses: actions/download-artifact@v4 + - uses: actions/checkout@v5 + with: + persist-credentials: true + - uses: actions/download-artifact@v4 with: name: man-pages path: doc/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4331fa34..18c656e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,55 +1,79 @@ name: Go tests -on: [push, pull_request] +on: + push: + pull_request: + schedule: # daily at 09:42 UTC + - cron: '42 9 * * *' + workflow_dispatch: permissions: contents: read jobs: test: - name: Test strategy: fail-fast: false matrix: - go: [1.19.x, 1.x] - os: [ubuntu-latest, macos-latest, windows-latest] + go: + - { go-version: stable } + - { go-version: oldstable } + - { go-version-file: go.mod } + os: + - ubuntu-latest + - macos-latest + - windows-latest runs-on: ${{ matrix.os }} steps: - - name: Install Go ${{ matrix.go }} - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go }} - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Run tests - run: go test -race ./... - gotip: - name: Test (Go tip) + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go.go-version }} + go-version-file: ${{ matrix.go.go-version-file }} + - run: | + go test -race ./... + test-latest: + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} + go: + - { go-version: stable } + - { go-version: oldstable } + - { go-version-file: go.mod } + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version: ${{ matrix.go.go-version }} + go-version-file: ${{ matrix.go.go-version-file }} + - uses: geomys/sandboxed-step@v1.2.1 + with: + run: | + go get -u -t ./... + go test -race ./... + staticcheck: + runs-on: ubuntu-latest steps: - - name: Install bootstrap Go - uses: actions/setup-go@v5 + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 with: go-version: stable - - name: Install Go tip (UNIX) - if: runner.os != 'Windows' - run: | - git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip - cd $HOME/gotip/src && ./make.bash - echo "$HOME/gotip/bin" >> $GITHUB_PATH - - name: Install Go tip (Windows) - if: runner.os == 'Windows' - run: | - git clone --filter=tree:0 https://go.googlesource.com/go $HOME/gotip - cd $HOME/gotip/src && ./make.bat - echo "$HOME/gotip/bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - run: go version - - name: Run tests - run: go test -race ./... + - uses: geomys/sandboxed-step@v1.2.1 + with: + run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version: stable + - uses: geomys/sandboxed-step@v1.2.1 + with: + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... From bc2784c5a02653d7337b44b9899215fe37d9f39b Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 7 Dec 2025 21:15:38 +0100 Subject: [PATCH 08/14] armor: reject empty lines in armored data Caught by the new CCTV test vectors! --- armor/armor.go | 3 +++ go.mod | 2 +- go.sum | 4 ++-- testkit_test.go | 21 +++++++++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/armor/armor.go b/armor/armor.go index ac6397e3..4ba94738 100644 --- a/armor/armor.go +++ b/armor/armor.go @@ -140,6 +140,9 @@ func (r *armoredReader) Read(p []byte) (int, error) { if string(line) == Footer { return 0, r.setErr(drainTrailing()) } + if len(line) == 0 { + return 0, r.setErr(errors.New("empty line in armored data")) + } if len(line) > format.ColumnsPerLine { return 0, r.setErr(errors.New("column limit exceeded")) } diff --git a/go.mod b/go.mod index 8579734a..50385bd2 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( // Test dependencies. require ( - c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 + c2sp.org/CCTV/age v0.0.0-20250426113718-46fad5b26cb2 github.com/rogpeppe/go-internal v1.12.0 golang.org/x/tools v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 13c692de..7a7e2ee8 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0= -c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w= +c2sp.org/CCTV/age v0.0.0-20250426113718-46fad5b26cb2 h1:CgfUtBNKpcGa3dLCktwniIKTMkxlELJcvS+EQRlGeGs= +c2sp.org/CCTV/age v0.0.0-20250426113718-46fad5b26cb2/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= diff --git a/testkit_test.go b/testkit_test.go index 78ccc43d..35caeae5 100644 --- a/testkit_test.go +++ b/testkit_test.go @@ -9,6 +9,7 @@ package age_test import ( "bytes" + "compress/zlib" "crypto/sha256" "encoding/hex" "errors" @@ -55,6 +56,7 @@ type vector struct { } func parseVector(t *testing.T, test []byte) *vector { + var z bool v := &vector{file: test} for { line, rest, ok := bytes.Cut(v.file, []byte("\n")) @@ -105,12 +107,31 @@ func parseVector(t *testing.T, test []byte) *vector { v.identities = append(v.identities, i) case "armored": v.armored = true + case "compressed": + if value != "zlib" { + t.Fatal("invalid test file: unknown compression:", value) + } + z = true case "comment": t.Log(value) default: t.Fatal("invalid test file: unknown header key:", key) } } + if z { + r, err := zlib.NewReader(bytes.NewReader(v.file)) + if err != nil { + t.Fatal(err) + } + b, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if err := r.Close(); err != nil { + t.Fatal(err) + } + v.file = b + } return v } From 2178873b11cc8e4c6ea7b60f25ceaf8acbd7cd58 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 7 Dec 2025 21:30:36 +0100 Subject: [PATCH 09/14] plugin: avoid using deprecated math/rand.Read --- plugin/client.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/plugin/client.go b/plugin/client.go index 28ccbf1f..cd3a8526 100644 --- a/plugin/client.go +++ b/plugin/client.go @@ -8,9 +8,10 @@ package plugin import ( "bufio" + "crypto/rand" "fmt" "io" - "math/rand" + mathrand "math/rand/v2" "os" "path/filepath" "strconv" @@ -468,15 +469,15 @@ func writeStanzaWithBody(conn io.Writer, t string, body []byte) error { } func writeGrease(conn io.Writer) (sent bool, err error) { - if rand.Intn(3) == 0 { + if mathrand.IntN(3) == 0 { return false, nil } - s := &format.Stanza{Type: fmt.Sprintf("grease-%x", rand.Int())} - for i := 0; i < rand.Intn(3); i++ { - s.Args = append(s.Args, fmt.Sprintf("%d", rand.Intn(100))) + s := &format.Stanza{Type: fmt.Sprintf("grease-%x", mathrand.Int())} + for i := 0; i < mathrand.IntN(3); i++ { + s.Args = append(s.Args, fmt.Sprintf("%d", mathrand.IntN(100))) } - if rand.Intn(2) == 0 { - s.Body = make([]byte, rand.Intn(100)) + if mathrand.IntN(2) == 0 { + s.Body = make([]byte, mathrand.IntN(100)) rand.Read(s.Body) } return true, s.Marshal(conn) From 9700b6a9b28a7f6a22764f56fa331924db670ccf Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 7 Dec 2025 21:38:44 +0100 Subject: [PATCH 10/14] all: upgrade dependencies Closes #610 --- cmd/age/age.go | 2 +- cmd/age/age_test.go | 17 +++++------------ cmd/age/tui.go | 18 ++---------------- go.mod | 6 +++--- go.sum | 12 ++++++------ 5 files changed, 17 insertions(+), 38 deletions(-) diff --git a/cmd/age/age.go b/cmd/age/age.go index cebcc82f..9b51f6b2 100644 --- a/cmd/age/age.go +++ b/cmd/age/age.go @@ -99,7 +99,7 @@ func main() { if len(os.Args) == 1 { flag.Usage() - exit(1) + os.Exit(1) } var ( diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index fcd7fdcd..1781cf35 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -16,22 +16,15 @@ import ( ) func TestMain(m *testing.M) { - os.Exit(testscript.RunMain(m, map[string]func() int{ - "age": func() (exitCode int) { - testOnlyPanicInsteadOfExit = true - defer func() { - if testOnlyDidExit { - exitCode = recover().(int) - } - }() + testscript.Main(m, map[string]func(){ + "age": func() { testOnlyConfigureScryptIdentity = func(r *age.ScryptRecipient) { r.SetWorkFactor(10) } testOnlyFixedRandomWord = "four" main() - return 0 }, - "age-plugin-test": func() (exitCode int) { + "age-plugin-test": func() { p, _ := plugin.New("test") p.HandleRecipient(func(data []byte) (age.Recipient, error) { return testPlugin{}, nil @@ -39,9 +32,9 @@ func TestMain(m *testing.M) { p.HandleIdentity(func(data []byte) (age.Identity, error) { return testPlugin{}, nil }) - return p.Main() + os.Exit(p.Main()) }, - })) + }) } type testPlugin struct{} diff --git a/cmd/age/tui.go b/cmd/age/tui.go index 3d26d918..ac364f0b 100644 --- a/cmd/age/tui.go +++ b/cmd/age/tui.go @@ -37,7 +37,7 @@ func printf(format string, v ...interface{}) { func errorf(format string, v ...interface{}) { l.Printf("age: error: "+format, v...) l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") - exit(1) + os.Exit(1) } func warningf(format string, v ...interface{}) { @@ -50,21 +50,7 @@ func errorWithHint(error string, hints ...string) { l.Printf("age: hint: %s", hint) } l.Printf("age: report unexpected or unhelpful errors at https://filippo.io/age/report") - exit(1) -} - -// If testOnlyPanicInsteadOfExit is true, exit will set testOnlyDidExit and -// panic instead of calling os.Exit. This way, the wrapper in TestMain can -// recover the panic and return the exit code only if it was originated in exit. -var testOnlyPanicInsteadOfExit bool -var testOnlyDidExit bool - -func exit(code int) { - if testOnlyPanicInsteadOfExit { - testOnlyDidExit = true - panic(code) - } - os.Exit(code) + os.Exit(1) } // clearLine clears the current line on the terminal, or opens a new line if diff --git a/go.mod b/go.mod index 50385bd2..ff7d17e3 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.24.0 require ( filippo.io/edwards25519 v1.1.0 filippo.io/hpke v0.4.0 - filippo.io/nistec v0.0.3 + filippo.io/nistec v0.0.4 golang.org/x/crypto v0.45.0 golang.org/x/sys v0.38.0 golang.org/x/term v0.37.0 @@ -14,6 +14,6 @@ require ( // Test dependencies. require ( c2sp.org/CCTV/age v0.0.0-20250426113718-46fad5b26cb2 - github.com/rogpeppe/go-internal v1.12.0 - golang.org/x/tools v0.22.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 + golang.org/x/tools v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index 7a7e2ee8..85497848 100644 --- a/go.sum +++ b/go.sum @@ -4,15 +4,15 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= -filippo.io/nistec v0.0.3 h1:h336Je2jRDZdBCLy2fLDUd9E2unG32JLwcJi0JQE9Cw= -filippo.io/nistec v0.0.3/go.mod h1:84fxC9mi+MhC2AERXI4LSa8cmSVOzrFikg6hZ4IfCyw= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +filippo.io/nistec v0.0.4 h1:F14ZHT5htWlMnQVPndX9ro9arf56cBhQxq4LnDI491s= +filippo.io/nistec v0.0.4/go.mod h1:PK/lw8I1gQT4hUML4QGaqljwdDaFcMyFKSXN7kjrtKI= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= -golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= From b73ab22a0f7d60902bb717440c7681833dbbe41f Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 7 Dec 2025 21:58:13 +0100 Subject: [PATCH 11/14] cmd/age: fix testscript setup races --- cmd/age/age_test.go | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/cmd/age/age_test.go b/cmd/age/age_test.go index 1781cf35..52133842 100644 --- a/cmd/age/age_test.go +++ b/cmd/age/age_test.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "sync" "testing" "filippo.io/age" @@ -50,21 +51,25 @@ func (testPlugin) Unwrap(ss []*age.Stanza) ([]byte, error) { return nil, age.ErrIncorrectIdentity } +var buildExtraCommands = sync.OnceValue(func() error { + bindir := filepath.SplitList(os.Getenv("PATH"))[0] + // Build age-keygen and age-plugin-pq into the test binary directory. + cmd := exec.Command("go", "build", "-o", bindir) + if testing.CoverMode() != "" { + cmd.Args = append(cmd.Args, "-cover") + } + cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen") + cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-pq") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +}) + func TestScript(t *testing.T) { testscript.Run(t, testscript.Params{ Dir: "testdata", Setup: func(e *testscript.Env) error { - bindir := filepath.SplitList(os.Getenv("PATH"))[0] - // Build age-keygen and age-plugin-pq into the test binary directory - cmd := exec.Command("go", "build", "-o", bindir) - if testing.CoverMode() != "" { - cmd.Args = append(cmd.Args, "-cover") - } - cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-keygen") - cmd.Args = append(cmd.Args, "filippo.io/age/cmd/age-plugin-pq") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + return buildExtraCommands() }, // TODO: enable AGEDEBUG=plugin without breaking stderr checks. }) From 8162219d5cc8295839a8ad6ce842448d3f7f3424 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Mon, 8 Dec 2025 02:55:37 +0100 Subject: [PATCH 12/14] age: update c2sp.org/CCTV/age testkit to include hybrid identities --- go.mod | 2 +- go.sum | 4 ++-- testkit_test.go | 4 ++++ 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ff7d17e3..20b6eca4 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( // Test dependencies. require ( - c2sp.org/CCTV/age v0.0.0-20250426113718-46fad5b26cb2 + c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd github.com/rogpeppe/go-internal v1.14.1 golang.org/x/tools v0.39.0 // indirect ) diff --git a/go.sum b/go.sum index 85497848..baad7f46 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -c2sp.org/CCTV/age v0.0.0-20250426113718-46fad5b26cb2 h1:CgfUtBNKpcGa3dLCktwniIKTMkxlELJcvS+EQRlGeGs= -c2sp.org/CCTV/age v0.0.0-20250426113718-46fad5b26cb2/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= diff --git a/testkit_test.go b/testkit_test.go index 35caeae5..8c29b24d 100644 --- a/testkit_test.go +++ b/testkit_test.go @@ -94,7 +94,11 @@ func parseVector(t *testing.T, test []byte) *vector { } v.fileKey = (*[16]byte)(h) case "identity": + var i age.Identity i, err := age.ParseX25519Identity(value) + if err != nil { + i, err = age.ParseHybridIdentity(value) + } if err != nil { t.Fatal(err) } From c539d05d701ab24c26136af099f2a79b5aa920e1 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Thu, 11 Dec 2025 14:48:53 +0100 Subject: [PATCH 13/14] tag: add Recipient.Tag and Bytes methods, and update tag scheme --- tag/internal/tagtest/tagtest.go | 6 ++-- tag/tag.go | 50 +++++++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/tag/internal/tagtest/tagtest.go b/tag/internal/tagtest/tagtest.go index 9c43718f..bd9f6d7a 100644 --- a/tag/internal/tagtest/tagtest.go +++ b/tag/internal/tagtest/tagtest.go @@ -6,8 +6,6 @@ package tagtest import ( "crypto/ecdh" - "crypto/hkdf" - "crypto/sha256" "crypto/subtle" "fmt" "testing" @@ -73,7 +71,7 @@ func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body)) } - expTag, err := hkdf.Extract(sha256.New, append(enc, i.k.PublicKey().Bytes()...), []byte("age-encryption.org/p256tag")) + expTag, err := i.Recipient().Tag(enc) if err != nil { return nil, fmt.Errorf("failed to compute tag: %v", err) } @@ -139,7 +137,7 @@ func (i *HybridIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { return nil, fmt.Errorf("invalid encrypted file key length: %d", len(s.Body)) } - expTag, err := hkdf.Extract(sha256.New, append(enc[1088:], i.k.PublicKey().Bytes()[1184:]...), []byte("age-encryption.org/mlkem768p256tag")) + expTag, err := i.Recipient().Tag(enc) if err != nil { return nil, fmt.Errorf("failed to compute tag: %v", err) } diff --git a/tag/tag.go b/tag/tag.go index 1db3354f..3b307091 100644 --- a/tag/tag.go +++ b/tag/tag.go @@ -18,6 +18,7 @@ import ( "crypto/mlkem" "crypto/sha256" "fmt" + "slices" "filippo.io/age" "filippo.io/age/internal/format" @@ -63,6 +64,7 @@ func ParseRecipient(s string) (*Recipient, error) { } const compressedPointSize = 1 + 32 +const uncompressedPointSize = 1 + 32 + 32 // NewClassicRecipient returns a new P-256 [Recipient] from a raw public key. func NewClassicRecipient(publicKey []byte) (*Recipient, error) { @@ -100,6 +102,30 @@ func (r *Recipient) Wrap(fileKey []byte) ([]*age.Stanza, error) { return s, err } +// Tag computes the 4-byte tag for the given ciphertext enc. +// +// This is a low-level method exposed for use by plugins that implement +// identities compatible with tagged recipients. +func (r *Recipient) Tag(enc []byte) ([]byte, error) { + label, tagRecipient := "age-encryption.org/p256tag", r.Bytes() + if r.Hybrid() { + label = "age-encryption.org/mlkem768p256tag" + // In hybrid mode, the tag is computed over just the P-256 part. + tagRecipient = tagRecipient[mlkem.EncapsulationKeySize768:] + if len(enc) != mlkem.CiphertextSize768+uncompressedPointSize { + return nil, fmt.Errorf("invalid ciphertext size") + } + } else if len(enc) != uncompressedPointSize { + return nil, fmt.Errorf("invalid ciphertext size") + } + rh := sha256.Sum256(tagRecipient) + tag, err := hkdf.Extract(sha256.New, append(slices.Clip(enc), rh[:4]...), []byte(label)) + if err != nil { + return nil, fmt.Errorf("failed to compute tag: %v", err) + } + return tag[:4], nil +} + // WrapWithLabels implements [age.RecipientWithLabels], returning a single // "postquantum" label if r is a hybrid P-256 + ML-KEM-768 recipient. This // ensures a hybrid Recipient can't be mixed with other recipients that would @@ -122,13 +148,7 @@ func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, err return nil, nil, fmt.Errorf("failed to encrypt file key: %v", err) } - tagEnc, tagRecipient := enc, r.pk.Bytes() - if r.Hybrid() { - // In hybrid mode, the tag is computed over just the P-256 part. - tagEnc = enc[mlkem.CiphertextSize768:] - tagRecipient = tagRecipient[mlkem.EncapsulationKeySize768:] - } - tag, err := hkdf.Extract(sha256.New, append(tagEnc, tagRecipient...), []byte(label)) + tag, err := r.Tag(enc) if err != nil { return nil, nil, fmt.Errorf("failed to compute tag: %v", err) } @@ -148,14 +168,22 @@ func (r *Recipient) WrapWithLabels(fileKey []byte) ([]*age.Stanza, []string, err return []*age.Stanza{l}, nil, nil } -// String returns the Bech32 public key encoding of r. -func (r *Recipient) String() string { +// Bytes returns the raw recipient encoding. +func (r *Recipient) Bytes() []byte { if r.Hybrid() { - return plugin.EncodeRecipient("tagpq", r.pk.Bytes()) + return r.pk.Bytes() } p, err := nistec.NewP256Point().SetBytes(r.pk.Bytes()) if err != nil { panic("internal error: invalid P-256 public key") } - return plugin.EncodeRecipient("tag", p.BytesCompressed()) + return p.BytesCompressed() +} + +// String returns the Bech32 public key encoding of r. +func (r *Recipient) String() string { + if r.Hybrid() { + return plugin.EncodeRecipient("tagpq", r.Bytes()) + } + return plugin.EncodeRecipient("tag", r.Bytes()) } From 0ccd8b469654f64dd29f0937c330b98ae672fc27 Mon Sep 17 00:00:00 2001 From: Filippo Valsorda Date: Sun, 21 Dec 2025 21:11:46 +0100 Subject: [PATCH 14/14] tag/internal/age-plugin-tagtest: add plugin for testing tag recipients --- .../age-plugin-tagtest/plugin-tagtest.go | 61 +++++++++++++++++++ tag/internal/tagtest/tagtest.go | 21 +++---- tag/tag_test.go | 8 +-- 3 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 tag/internal/age-plugin-tagtest/plugin-tagtest.go diff --git a/tag/internal/age-plugin-tagtest/plugin-tagtest.go b/tag/internal/age-plugin-tagtest/plugin-tagtest.go new file mode 100644 index 00000000..82e4c7c8 --- /dev/null +++ b/tag/internal/age-plugin-tagtest/plugin-tagtest.go @@ -0,0 +1,61 @@ +// Command age-plugin-tagtest is a that decrypts files encrypted to fixed +// age1tag1... or age1tagpq1... recipients for testing purposes. +// +// It can be used with the "-j" flag: +// +// go install ./tag/internal/age-plugin-tagtest +// age -d -j tagtest file.age +package main + +import ( + "errors" + "fmt" + "log" + "os" + + "filippo.io/age" + "filippo.io/age/plugin" + "filippo.io/age/tag/internal/tagtest" +) + +const classicRecipient = "age1tag1qwe0kafsjrar4txm6heqnhpfuggzr0gvznz7fvygxrlq90u5mq2pysxtw6h" + +const hybridRecipient = "age1tagpq14h4z7cks9sxftfc8tq4xektt4854ur9rv76tvujdvtzk2fmyywkvh9z2emz3x4epvhz7qdt2v7uksyyq2cdzf3k04ny0g5sc3u4heqh3r9v4cnwhfjw0a2azpgmnk9xk02wvywt5szcq6q3jvwsjxvkn3tsk52vqjczdcvc398ym4j6cvqas4w99gkgt7ur3fmt4g873phr23tgxw3f7wgsz9zxz7m8cp27vpq3h5vc8nssjemtr2etmtmqkg4fzn2u9x9zvtysuya5yrytgx482ftx9864h8a6pprarxd3d0qe8nw2at5ekg3tsahtef7kawasxjamyckw2ans6v933vuypcfrra32f89r2v72mka9hhc55s49xe2khfsq7w9r2zynuzfx4fg6v7jjncsc87rw2yy8qp8hr27edus6zw5xd6m3hax2nxhl2dys9792z3wp5c034sfkrxe86guj7pfdh7sytzrufl9euhuhyf9w6c7z2nwf7v5v8f2s4gplgvfx4jj4le22k2qn242qkqkcwx7llfyrct7jm2wcv0ytypeh6h93ezgtd7q6zr428qze3dec5jxlc5xxjetyephp42fljft3s02p0570kjwyfeyjcnks2vglkvyus5g9l4z6m0gf8wu22ygfm40028txwjlxvvgnn7c36z783c6tmc9k5nef8nucj6u3ustff5vhtnzhnscsvsz79wzrkv3sujtntx4wezucy6lp49flmnyydn3khk2xsesw0ekn4u44nzqw2g2rjyrrl7crshlzttgpe0jvqycjzp9kmtz23t3yu0w9j4n344nnrf88k2jqqfpjxte38pcn0epr879pqsuvajxrkmsas89pvfrzwcewneujn08guj5pvvrtn5hzzg2y6u4wwjqqxx4x8w65yc4dchf750dft8kgcttt2f6j0j5v8s7tkaua78tte7artdfar544vl0rau79h95mc4ghp887z82s6rq93txpkvan86n963kagqkldngnkjcn28zdrh38vdxj002zqs9mx7zjvg3ynzdfhfakkynt9fyqpaxpsdrsqrycuhw5ykwgjz6wldef7xtu6p689234hstxe7v8e5422f2dy8ystn57z3fvy9yfrm4t3lye6ejk5n6x8zqexmql7lx965xcxuuy38xzyt8j9qprnwgfqgx54l4tnjdpzdde6xgmwtnpkfwvyr7rkgnavvjn6a3e56wtvjx3evmhjjxvukpq5zqrj0s4sntkz3yeszs5dty8q0q6m7dgp6mjpvaer0c4343g72eycfqzkjupeaemh0n8e935hqs8fh3jgk7fzyxctzuqlx6d2q9jaf8r9wu4sjxj5w6ppw7m9c3hxrzpcv2uek3kxnndgf2hd99q9v2ux8pjkv29ntslvnvhy09dvcy9578rt89gf4cj4cu79zjxtlj3dpct6rjme02zj3qspsade96njkkufu9zuq2lk3qwvddpxjkqm2hnpqwck54zug7ctvkgvk325lwkg4q5rf73zkgys5e9y8jqc96ntdyl4r78lgtw4k5uljk5ttf46s3gc0rq0jwmddnxt875twwq92505zh3zkse5ag2dhjjxyfzkn7xv3j0kv9r3jzpvgep8fq6z8mar509u4fvnhvthp2ah0r45lsyq0mm6fwkcs30v8k9wzvgt6uvcty6qsjvarjs3htym69zu43m4jd3k4tllrr8c05v6p6spuhup4hkk2p9fp9lxafe3pntcn4nk83gzhjjpcjwyg7jcyz5uancu0fakgz27up7ymzp2xv3sqyqewkkqynskw9qkvysrncxj0cy7dt6q8dsseuwmc2urfmcvkykf82wfa54t85hqx8gywhmhzunm2x0d66a4pwl0xl78fhkces5dpq8pfnp35m5a3u8vdam64zx5s5x9cmnrx3zr066f4f8hlecqnq2fd5quw79ljg3q5nvs6ggmm4gkc" + +func init() { + c := tagtest.NewClassicIdentity("age-plugin-tagtest").Recipient().String() + if c != classicRecipient { + log.Fatalf("unexpected classic recipient: %s", c) + } + h := tagtest.NewHybridIdentity("age-plugin-tagtest").Recipient().String() + if h != hybridRecipient { + log.Fatalf("unexpected hybrid recipient: %s", h) + } +} + +func main() { + p, err := plugin.New("tagtest") + if err != nil { + log.Fatal(err) + } + p.HandleIdentity(func(b []byte) (age.Identity, error) { + if len(b) != 0 { + return nil, fmt.Errorf("unexpected identity data") + } + return &tagtestIdentity{}, nil + }) + os.Exit(p.Main()) +} + +type tagtestIdentity struct{} + +func (i *tagtestIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { + classic := tagtest.NewClassicIdentity("age-plugin-tagtest") + if key, err := classic.Unwrap(ss); err == nil { + return key, nil + } else if !errors.Is(err, age.ErrIncorrectIdentity) { + return nil, err + } + hybrid := tagtest.NewHybridIdentity("age-plugin-tagtest") + return hybrid.Unwrap(ss) +} diff --git a/tag/internal/tagtest/tagtest.go b/tag/internal/tagtest/tagtest.go index bd9f6d7a..ed9d11be 100644 --- a/tag/internal/tagtest/tagtest.go +++ b/tag/internal/tagtest/tagtest.go @@ -8,7 +8,6 @@ import ( "crypto/ecdh" "crypto/subtle" "fmt" - "testing" "filippo.io/age" "filippo.io/age/internal/format" @@ -18,16 +17,15 @@ import ( ) type ClassicIdentity struct { - t *testing.T k hpke.PrivateKey } var _ age.Identity = &ClassicIdentity{} -func NewClassicIdentity(t *testing.T) *ClassicIdentity { - k, err := hpke.DHKEM(ecdh.P256()).GenerateKey() +func NewClassicIdentity(seed string) *ClassicIdentity { + k, err := hpke.DHKEM(ecdh.P256()).DeriveKeyPair([]byte(seed)) if err != nil { - t.Fatalf("failed to generate key: %v", err) + panic(fmt.Sprintf("failed to generate key: %v", err)) } return &ClassicIdentity{k: k} } @@ -36,11 +34,11 @@ func (i *ClassicIdentity) Recipient() *tag.Recipient { uncompressed := i.k.PublicKey().Bytes() p, err := nistec.NewP256Point().SetBytes(uncompressed) if err != nil { - i.t.Fatalf("failed to parse public key: %v", err) + panic(fmt.Sprintf("failed to parse public key: %v", err)) } r, err := tag.NewClassicRecipient(p.BytesCompressed()) if err != nil { - i.t.Fatalf("failed to create recipient: %v", err) + panic(fmt.Sprintf("failed to create recipient: %v", err)) } return r } @@ -89,16 +87,15 @@ func (i *ClassicIdentity) Unwrap(ss []*age.Stanza) ([]byte, error) { } type HybridIdentity struct { - t *testing.T k hpke.PrivateKey } var _ age.Identity = &HybridIdentity{} -func NewHybridIdentity(t *testing.T) *HybridIdentity { - k, err := hpke.MLKEM768P256().GenerateKey() +func NewHybridIdentity(seed string) *HybridIdentity { + k, err := hpke.MLKEM768P256().DeriveKeyPair([]byte(seed)) if err != nil { - t.Fatalf("failed to generate key: %v", err) + panic(fmt.Sprintf("failed to generate key: %v", err)) } return &HybridIdentity{k: k} } @@ -106,7 +103,7 @@ func NewHybridIdentity(t *testing.T) *HybridIdentity { func (i *HybridIdentity) Recipient() *tag.Recipient { r, err := tag.NewHybridRecipient(i.k.PublicKey().Bytes()) if err != nil { - i.t.Fatalf("failed to create recipient: %v", err) + panic(fmt.Sprintf("failed to create recipient: %v", err)) } return r } diff --git a/tag/tag_test.go b/tag/tag_test.go index cf094d19..a77e4a63 100644 --- a/tag/tag_test.go +++ b/tag/tag_test.go @@ -15,7 +15,7 @@ import ( ) func TestClassicRoundTrip(t *testing.T) { - i := tagtest.NewClassicIdentity(t) + i := tagtest.NewClassicIdentity("test") r := i.Recipient() if r.Hybrid() { @@ -62,7 +62,7 @@ func TestClassicRoundTrip(t *testing.T) { } func TestHybridRoundTrip(t *testing.T) { - i := tagtest.NewHybridIdentity(t) + i := tagtest.NewHybridIdentity("test") r := i.Recipient() if !r.Hybrid() { @@ -113,7 +113,7 @@ func TestTagHybridMixingRestrictions(t *testing.T) { if err != nil { t.Fatal(err) } - tagHybrid := tagtest.NewHybridIdentity(t).Recipient() + tagHybrid := tagtest.NewHybridIdentity("test").Recipient() // Hybrid tag recipients can be used together with hybrid recipients. hybrid, err := age.GenerateHybridIdentity() @@ -133,7 +133,7 @@ func TestTagHybridMixingRestrictions(t *testing.T) { } // Classic tag and X25519 recipients can be mixed (both are non-PQ). - tagClassic := tagtest.NewClassicIdentity(t).Recipient() + tagClassic := tagtest.NewClassicIdentity("test").Recipient() if _, err := age.Encrypt(io.Discard, tagClassic, x25519.Recipient()); err != nil { t.Errorf("expected classic tag + X25519 to work, got %v", err) }