tag: use filippo.io/hpke

This commit is contained in:
Filippo Valsorda
2025-09-07 14:11:07 +02:00
committed by Filippo Valsorda
parent e2d30695f2
commit 6ece9e45ee
8 changed files with 344 additions and 1203 deletions

8
go.mod
View File

@@ -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
)

16
go.sum
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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"
}
]
}
]

View File

@@ -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
}

View File

@@ -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)
k, err := hpke.MLKEM768P256().NewPublicKey(publicKey)
if err != nil {
return nil, fmt.Errorf("invalid tagpq recipient DH public key: %v", err)
return nil, fmt.Errorf("invalid tagpq recipient 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
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())
}
return plugin.EncodeRecipient("tag", r.compressed[:])
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())
}

109
tag/tag_test.go Normal file
View File

@@ -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)
}
}