Рыба проекта. Минимальная функциональность
This commit is contained in:
@@ -0,0 +1,624 @@
|
||||
// Package composite implements the v2 .pqenc artifact format: a dual-KEM
|
||||
// (post-quantum + classical) hybrid AEAD with inherent downgrade resistance.
|
||||
//
|
||||
// Layout of an artifact (all multi-byte fields are big-endian):
|
||||
//
|
||||
// [magic u32 = 0x47535051 "GSPQ"] [0:4]
|
||||
// [version u16 = 0x0002] [4:6]
|
||||
// [flags u32 = 0x00000000 (reserved, must==0)] [6:10]
|
||||
// [nRecipients u8] [10]
|
||||
//
|
||||
// for each recipient slot i (positional: slot 0 = PQ, slot 1 = classical):
|
||||
// [schemeID u16] [keyID 8B] [ctLen u32] [ciphertext ctLen bytes]
|
||||
//
|
||||
// [wrapNonce 12B] [wrappedCEK 48B] [firstPayloadNonce 12B]
|
||||
//
|
||||
// chunk records (until a chunk with flags&0x01==1 is seen):
|
||||
// [len u32 = ciphertext length incl. 16B tag] [flags u8] [ciphertext]
|
||||
//
|
||||
// The content key (CEK, 32B) is wrapped with kekFinal — the HKDF combiner
|
||||
// of the two KEM shared secrets — using AES-256-GCM (AAD = version u16 BE).
|
||||
// Payload is encrypted with AES-256-GCM under CEK in 64 KiB chunks; a
|
||||
// zero-length final marker chunk (flags&0x01==1) is ALWAYS emitted on EOF
|
||||
// regardless of the previous chunk's fullness, providing explicit AEAD
|
||||
// integrity for the logical end-of-stream.
|
||||
package composite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"io"
|
||||
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
|
||||
)
|
||||
|
||||
// Sentinel errors surfaced by the composite Encryptor/Decryptor.
|
||||
var (
|
||||
// ErrMalformedHeader indicates the artifact header is structurally
|
||||
// invalid (bad magic, reserved flags, out-of-range nRecipients, oversized
|
||||
// recipient ciphertext length, or premature EOF while reading fixed
|
||||
// metadata).
|
||||
ErrMalformedHeader = errors.New("composite: malformed header")
|
||||
|
||||
// ErrUnsupportedVersion indicates the artifact's version field is not
|
||||
// 0x0002. Decryption stops at the version check — no AES/GCM operations
|
||||
// are attempted and no recipient state is allocated.
|
||||
ErrUnsupportedVersion = errors.New("composite: unsupported artifact version")
|
||||
|
||||
// ErrWrongKeys indicates one of the supplied private keys does not match
|
||||
// the recipient slot it was routed to (positional). Signal: the priv's
|
||||
// KeyID() does not equal the slot's keyID, OR the KEM Decapsulate failed
|
||||
// for the slot's ciphertext.
|
||||
ErrWrongKeys = errors.New("composite: wrong recipient keys")
|
||||
|
||||
// ErrTamperingDetected indicates the wrappedCEK or a payload chunk failed
|
||||
// AES-GCM authentication: the cancellation or modification of ciphertext
|
||||
// bytes is cryptographically rejected.
|
||||
ErrTamperingDetected = errors.New("composite: tampering detected")
|
||||
|
||||
// ErrNonceCounterWrapped indicates the per-chunk 64-bit counter
|
||||
// (chunkNonce[4:12]) wrapped around to zero while encrypting or
|
||||
// decrypting an additional chunk — the nonce sequence is exhausted.
|
||||
ErrNonceCounterWrapped = errors.New("composite: nonce counter wrapped")
|
||||
|
||||
// ErrMalformedChunk indicates a chunk record failed structural
|
||||
// validation: zero-length non-final chunk (infinite-loop DoS) or
|
||||
// oversized ciphertext (over the 64 KiB+16 maximum).
|
||||
ErrMalformedChunk = errors.New("composite: malformed chunk")
|
||||
|
||||
// ErrUnexpectedEOF indicates the chunk stream ended before any chunk
|
||||
// with flags&0x01==1 (logical end-of-stream marker) was observed.
|
||||
ErrUnexpectedEOF = errors.New("composite: unexpected end of stream")
|
||||
)
|
||||
|
||||
// Format constants.
|
||||
const (
|
||||
magic uint32 = 0x47535051 // "GSPQ"
|
||||
version uint16 = 0x0002
|
||||
flags uint32 = 0x00000000
|
||||
maxRecipients int = 2
|
||||
chunkSize int = 64 * 1024
|
||||
gcmTagLen int = 16
|
||||
maxRecipientCiphertextLen int = 1 << 20 // MiB cap on a single recipient ciphertext
|
||||
wrapNonceLen int = 12
|
||||
wrappedCekLen int = 48 // 32-byte CEK + 16-byte GCM tag
|
||||
firstPayloadNonceLen int = 12
|
||||
kekLen int = 32
|
||||
infoPq string = "git.tswf.io/infra/go-synapse-backupper/v2/kek/pq"
|
||||
infoComposite string = "git.tswf.io/infra/go-synapse-backupper/v2/kek/composite"
|
||||
)
|
||||
|
||||
// Chunk flag bits.
|
||||
const (
|
||||
flagFinal byte = 0x01
|
||||
)
|
||||
|
||||
// encryptor is the composite Encryptor implementation backed by a Registry
|
||||
// of KEM factories. Recipients are routed positionally: slot 0 = PQ, slot 1
|
||||
// = classical.
|
||||
type encryptor struct {
|
||||
registry crypto.Registry
|
||||
}
|
||||
|
||||
// NewEncryptor returns a composite Encryptor that resolves KEM schemes via
|
||||
// the provided Registry (Registry.Lookup(schemeID)).
|
||||
func NewEncryptor(
|
||||
registry crypto.Registry,
|
||||
) crypto.Encryptor {
|
||||
return &encryptor{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt encrypts plaintext to multiple recipients under the v2 artifact
|
||||
// format and streams the result to sink. Exactly two recipients must be
|
||||
// supplied — slot 0 (PQ) and slot 1 (classical).
|
||||
func (e *encryptor) Encrypt(
|
||||
plaintext io.Reader,
|
||||
recipients []crypto.RecipientPub,
|
||||
sink io.Writer,
|
||||
rand io.Reader,
|
||||
) error {
|
||||
if len(recipients) != maxRecipients {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
pqPub, classicalPub := recipients[0], recipients[1]
|
||||
|
||||
// Generate the per-message content key (32B for AES-256).
|
||||
cek := make([]byte, kekLen)
|
||||
if _, err := io.ReadFull(rand, cek); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bind KEM adapters through the registry — no direct adapter imports.
|
||||
pqFactory, err := e.registry.Lookup(pqPub.SchemeID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
classicalFactory, err := e.registry.Lookup(classicalPub.SchemeID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pqKem := pqFactory()
|
||||
classicalKem := classicalFactory()
|
||||
|
||||
// Encapsulate to each recipient.
|
||||
// ADAPTER CONTRACT (pinned verbatim): adapters return (ct, ss) —
|
||||
// composite unpacks in that order at each call site.
|
||||
pqCt, ssPq, err := pqKem.Encapsulate(pqPub, rand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
classicalCt, ssClassical, err := classicalKem.Encapsulate(classicalPub, rand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// HKDF combiner (verbatim per plan Metis B1):
|
||||
// PRK1 = HKDF-Extract(ssPq, salt=nil)
|
||||
// kek1 = HKDF-Expand(prk1, infoPq, 32)
|
||||
// IKM = kek1 || ssClassical (with defensive copy of kek1)
|
||||
// PRK2 = HKDF-Extract(IKM, salt=nil)
|
||||
// kekFinal = HKDF-Expand(prk2, infoComposite, 32)
|
||||
kekFinal, err := deriveCompositeKEK(ssPq, ssClassical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Wrap the CEK via AES-256-GCM with AAD = version u16 BE = {0x00, 0x02}.
|
||||
wrapNonce := make([]byte, wrapNonceLen)
|
||||
if _, err := io.ReadFull(rand, wrapNonce); err != nil {
|
||||
return err
|
||||
}
|
||||
kekBlock, err := aes.NewCipher(kekFinal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrapGcm, err := cipher.NewGCM(kekBlock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrappedCek := wrapGcm.Seal(nil, wrapNonce, cek, []byte{0x00, 0x02})
|
||||
|
||||
// firstPayloadNonce seeds the per-chunk nonce stream.
|
||||
firstPayloadNonce := make([]byte, firstPayloadNonceLen)
|
||||
if _, err := io.ReadFull(rand, firstPayloadNonce); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Emit the artifact header.
|
||||
if err := writeHeader(
|
||||
sink,
|
||||
pqPub, classicalPub,
|
||||
pqCt, classicalCt,
|
||||
wrapNonce, wrappedCek, firstPayloadNonce,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt the payload into chunks.
|
||||
payloadBlock, err := aes.NewCipher(cek)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadGcm, err := cipher.NewGCM(payloadBlock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return encryptChunks(plaintext, sink, payloadGcm, firstPayloadNonce)
|
||||
}
|
||||
|
||||
// writeHeader serializes the v2 artifact header.
|
||||
//
|
||||
// Layout (see package doc):
|
||||
//
|
||||
// magic(4) + version(2) + flags(4) + nRecipients(1)
|
||||
// + per-recipient: schemeID(2) + keyID(8) + ctLen(4) + ciphertext
|
||||
// + wrapNonce(12) + wrappedCEK(48) + firstPayloadNonce(12)
|
||||
func writeHeader(
|
||||
w io.Writer,
|
||||
pqPub, classicalPub crypto.RecipientPub,
|
||||
pqCt, classicalCt,
|
||||
wrapNonce, wrappedCek, firstPayloadNonce []byte,
|
||||
) error {
|
||||
var buf bytes.Buffer
|
||||
var b4 [4]byte
|
||||
|
||||
binary.BigEndian.PutUint32(b4[:], magic)
|
||||
buf.Write(b4[:]) // [0:4] magic
|
||||
|
||||
binary.BigEndian.PutUint16(b4[:2], version)
|
||||
buf.Write(b4[:2]) // [4:6] version
|
||||
|
||||
binary.BigEndian.PutUint32(b4[:], flags)
|
||||
buf.Write(b4[:]) // [6:10] flags
|
||||
|
||||
// [10] nRecipients — composite v2 always carries exactly two slots.
|
||||
buf.WriteByte(byte(maxRecipients))
|
||||
|
||||
// Slot 0 (PQ).
|
||||
binary.BigEndian.PutUint16(b4[:2], pqPub.SchemeID())
|
||||
buf.Write(b4[:2])
|
||||
if len(pqPub.KeyID()) != 8 {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
buf.Write(pqPub.KeyID())
|
||||
binary.BigEndian.PutUint32(b4[:], uint32(len(pqCt)))
|
||||
buf.Write(b4[:])
|
||||
buf.Write(pqCt)
|
||||
|
||||
// Slot 1 (classical).
|
||||
binary.BigEndian.PutUint16(b4[:2], classicalPub.SchemeID())
|
||||
buf.Write(b4[:2])
|
||||
if len(classicalPub.KeyID()) != 8 {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
buf.Write(classicalPub.KeyID())
|
||||
binary.BigEndian.PutUint32(b4[:], uint32(len(classicalCt)))
|
||||
buf.Write(b4[:])
|
||||
buf.Write(classicalCt)
|
||||
|
||||
// wrapNonce + wrappedCEK + firstPayloadNonce.
|
||||
buf.Write(wrapNonce)
|
||||
buf.Write(wrappedCek)
|
||||
buf.Write(firstPayloadNonce)
|
||||
|
||||
_, err := w.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// encryptChunks encrypts plaintext into 64 KiB AES-256-GCM chunks under CEK
|
||||
// and streams them to w. A zero-length final marker chunk (flags&0x01==1)
|
||||
// is ALWAYS emitted on EOF regardless of the previous chunk's fullness.
|
||||
func encryptChunks(
|
||||
plaintext io.Reader,
|
||||
w io.Writer,
|
||||
gcm cipher.AEAD,
|
||||
firstPayloadNonce []byte,
|
||||
) error {
|
||||
chunkNonce := make([]byte, gcm.NonceSize())
|
||||
copy(chunkNonce, firstPayloadNonce)
|
||||
|
||||
buf := make([]byte, chunkSize)
|
||||
var lenB [4]byte
|
||||
|
||||
for {
|
||||
readN, readErr := io.ReadFull(plaintext, buf)
|
||||
hasData := readN > 0
|
||||
eof := readErr == io.EOF || readErr == io.ErrUnexpectedEOF
|
||||
|
||||
if hasData {
|
||||
// Body chunk, flags = 0x00.
|
||||
ciphertext := gcm.Seal(nil, chunkNonce, buf[:readN], nil)
|
||||
binary.BigEndian.PutUint32(lenB[:], uint32(len(ciphertext)))
|
||||
if _, err := w.Write(lenB[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte{0x00}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(ciphertext); err != nil {
|
||||
return err
|
||||
}
|
||||
// Increment counter for the next chunk; the top 4 bytes of the
|
||||
// nonce ([0:4]) are untouched.
|
||||
if err := incrementCounter(chunkNonce); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if eof {
|
||||
// Final marker chunk — zero-length plaintext, flags = 0x01,
|
||||
// ciphertext is just the 16-byte GCM tag. ALWAYS emitted.
|
||||
ciphertext := gcm.Seal(nil, chunkNonce, nil, nil)
|
||||
binary.BigEndian.PutUint32(lenB[:], uint32(len(ciphertext)))
|
||||
if _, err := w.Write(lenB[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte{flagFinal}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(ciphertext); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// incrementCounter mutates chunkNonce in place: reads the 64-bit big-endian
|
||||
// counter at chunkNonce[4:12], adds one, rejects wrap, writes back.
|
||||
// chunkNonce[0:4] (the random base) is preserved.
|
||||
func incrementCounter(chunkNonce []byte) error {
|
||||
counter := binary.BigEndian.Uint64(chunkNonce[4:12])
|
||||
newCounter := counter + 1
|
||||
if newCounter <= counter {
|
||||
return ErrNonceCounterWrapped
|
||||
}
|
||||
binary.BigEndian.PutUint64(chunkNonce[4:12], newCounter)
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptor is the composite Decryptor implementation. Slot routing is
|
||||
// positional; keyID equality is enforced as a fast wrong-key reject before
|
||||
// any AEAD operation.
|
||||
type decryptor struct {
|
||||
registry crypto.Registry
|
||||
}
|
||||
|
||||
// NewDecryptor returns a composite Decryptor that resolves KEM schemes via
|
||||
// the provided Registry.
|
||||
func NewDecryptor(
|
||||
registry crypto.Registry,
|
||||
) crypto.Decryptor {
|
||||
return &decryptor{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt parses the v2 artifact from src, decapsulates per slot using the
|
||||
// supplied private keys (positional: slot 0 ← privs[0], slot 1 ← privs[1]),
|
||||
// re-derives the composite KEK, unwraps the CEK, and streams decrypted
|
||||
// plaintext chunks to plaintext.
|
||||
func (d *decryptor) Decrypt(
|
||||
src io.Reader,
|
||||
privs []crypto.RecipientPriv,
|
||||
plaintext io.Writer,
|
||||
) error {
|
||||
// Prefix: magic(4) + version(2) + flags(4) + nRecipients(1) = 11 bytes.
|
||||
const prefixLen = 11
|
||||
var prefix [prefixLen]byte
|
||||
if _, err := io.ReadFull(src, prefix[:]); err != nil {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
|
||||
if binary.BigEndian.Uint32(prefix[0:4]) != magic {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
|
||||
fileVersion := binary.BigEndian.Uint16(prefix[4:6])
|
||||
if fileVersion != version {
|
||||
// Stops BEFORE any flags/nRecipients validation, before any GCM
|
||||
// work, before any recipient allocation.
|
||||
return ErrUnsupportedVersion
|
||||
}
|
||||
|
||||
if binary.BigEndian.Uint32(prefix[6:10]) != flags {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
|
||||
nRecipients := int(prefix[10])
|
||||
if nRecipients < 1 || nRecipients > maxRecipients {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
if nRecipients != maxRecipients {
|
||||
// Composite v2 mandates exactly two slots.
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
|
||||
// Per-recipient: each slot is `(meta 14B) (ciphertext ctLen B)` INLINE —
|
||||
// slot0's ciphertext lives BETWEEN slot0's metadata and slot1's metadata
|
||||
// (the inline-ct layout; see package doc). So parse strictly per-slot:
|
||||
// read metadata → validate ctLen ≤ max → read ct → advance to next slot.
|
||||
// The ctLen ≤ maxRecipientCiphertextLen check must fire BEFORE allocating/reading
|
||||
// the per-slot ciphertext (test l: no OOM on malicious oversized value).
|
||||
const perSlotMetaLen = 14
|
||||
type slotMeta struct {
|
||||
schemeID uint16
|
||||
keyID []byte
|
||||
ctLen uint32
|
||||
ct []byte
|
||||
}
|
||||
slots := make([]slotMeta, nRecipients)
|
||||
for slotIndex := 0; slotIndex < nRecipients; slotIndex++ {
|
||||
var meta [perSlotMetaLen]byte
|
||||
if _, err := io.ReadFull(src, meta[:]); err != nil {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
slot := &slots[slotIndex]
|
||||
slot.schemeID = binary.BigEndian.Uint16(meta[0:2])
|
||||
slot.keyID = append([]byte(nil), meta[2:10]...)
|
||||
slot.ctLen = binary.BigEndian.Uint32(meta[10:14])
|
||||
if int(slot.ctLen) > maxRecipientCiphertextLen {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
slot.ct = make([]byte, slot.ctLen)
|
||||
if slot.ctLen > 0 {
|
||||
if _, err := io.ReadFull(src, slot.ct); err != nil {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing fixed region: wrapNonce(12) + wrappedCEK(48) + firstPayloadNonce(12) = 72B.
|
||||
var tail [wrapNonceLen + wrappedCekLen + firstPayloadNonceLen]byte
|
||||
if _, err := io.ReadFull(src, tail[:]); err != nil {
|
||||
return ErrMalformedHeader
|
||||
}
|
||||
wrapNonce := tail[:wrapNonceLen]
|
||||
wrappedCek := tail[wrapNonceLen : wrapNonceLen+wrappedCekLen]
|
||||
firstPayloadNonce := tail[wrapNonceLen+wrappedCekLen:]
|
||||
|
||||
// Decapsulate per slot, routing privs positionally.
|
||||
if len(privs) < nRecipients {
|
||||
return ErrWrongKeys
|
||||
}
|
||||
sharedSecrets := make([][]byte, nRecipients)
|
||||
for slotIndex := 0; slotIndex < nRecipients; slotIndex++ {
|
||||
slot := slots[slotIndex]
|
||||
priv := privs[slotIndex]
|
||||
if priv == nil {
|
||||
return ErrWrongKeys
|
||||
}
|
||||
if priv.SchemeID() != slot.schemeID {
|
||||
return ErrWrongKeys
|
||||
}
|
||||
if !bytes.Equal(priv.KeyID(), slot.keyID) {
|
||||
return ErrWrongKeys
|
||||
}
|
||||
factory, err := d.registry.Lookup(slot.schemeID)
|
||||
if err != nil {
|
||||
return ErrWrongKeys
|
||||
}
|
||||
kem := factory()
|
||||
ss, err := kem.Decapsulate(priv, slot.ct)
|
||||
if err != nil {
|
||||
return ErrWrongKeys
|
||||
}
|
||||
sharedSecrets[slotIndex] = ss
|
||||
}
|
||||
|
||||
kekFinal, err := deriveCompositeKEK(sharedSecrets[0], sharedSecrets[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Unwrap CEK via AES-256-GCM. AAD = version u16 BE.
|
||||
kekBlock, err := aes.NewCipher(kekFinal)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wrapGcm, err := cipher.NewGCM(kekBlock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cek, err := wrapGcm.Open(nil, wrapNonce, wrappedCek, []byte{0x00, 0x02})
|
||||
if err != nil {
|
||||
return ErrTamperingDetected
|
||||
}
|
||||
|
||||
// Setup payload AEAD under CEK.
|
||||
payloadBlock, err := aes.NewCipher(cek)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payloadGcm, err := cipher.NewGCM(payloadBlock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return decryptChunks(src, plaintext, payloadGcm, firstPayloadNonce)
|
||||
}
|
||||
|
||||
// decryptChunks reads and decrypts chunk records until a final chunk
|
||||
// (flags & flagFinal != 0) is observed.
|
||||
func decryptChunks(
|
||||
src io.Reader,
|
||||
plaintext io.Writer,
|
||||
gcm cipher.AEAD,
|
||||
firstPayloadNonce []byte,
|
||||
) error {
|
||||
chunkNonce := make([]byte, gcm.NonceSize())
|
||||
copy(chunkNonce, firstPayloadNonce)
|
||||
|
||||
var lenB [4]byte
|
||||
var flagB [1]byte
|
||||
|
||||
maxChunkCtLen := uint32(chunkSize + gcmTagLen)
|
||||
|
||||
for {
|
||||
// Read chunk length u32 BE.
|
||||
_, err := io.ReadFull(src, lenB[:])
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
// No final marker chunk observed — premature end of stream.
|
||||
return ErrUnexpectedEOF
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
length := binary.BigEndian.Uint32(lenB[:])
|
||||
|
||||
// Read flags u8.
|
||||
_, err = io.ReadFull(src, flagB[:])
|
||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||
return ErrUnexpectedEOF
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
flagsByte := flagB[0]
|
||||
isFinal := flagsByte&flagFinal != 0
|
||||
|
||||
// Structural validation.
|
||||
if length == 0 && !isFinal {
|
||||
// Zero-length non-final chunk — infinite-loop DoS.
|
||||
return ErrMalformedChunk
|
||||
}
|
||||
if length > maxChunkCtLen {
|
||||
return ErrMalformedChunk
|
||||
}
|
||||
|
||||
// Read ciphertext.
|
||||
ciphertext := make([]byte, length)
|
||||
if length > 0 {
|
||||
if _, err := io.ReadFull(src, ciphertext); err != nil {
|
||||
return ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
|
||||
// AEAD open.
|
||||
plaintextChunk, err := gcm.Open(nil, chunkNonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return ErrTamperingDetected
|
||||
}
|
||||
|
||||
if len(plaintextChunk) > 0 {
|
||||
if _, err := plaintext.Write(plaintextChunk); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if isFinal {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := incrementCounter(chunkNonce); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deriveCompositeKEK applies the verbatim HKDF combiner from the plan:
|
||||
//
|
||||
// PRK1 = HKDF-Extract(IKM=ss_pq, salt=nil)
|
||||
// kek1 = HKDF-Expand(prk1, infoPq, 32)
|
||||
// IKM = kek1 || ss_classical // defensive copy of kek1
|
||||
// PRK2 = HKDF-Extract(IKM, salt=nil)
|
||||
// kekFinal = HKDF-Expand(prk2, infoComposite, 32)
|
||||
//
|
||||
// Go 1.26 stdlib crypto/hkdf returns ([]byte, error) directly from Extract
|
||||
// and Expand — no infinite io.Reader is involved, so neither io.ReadAll nor
|
||||
// io.ReadFull is needed; the spirit of the plan's "do not use io.ReadAll on
|
||||
// hkdf.Expand" guidance is preserved trivially.
|
||||
func deriveCompositeKEK(
|
||||
ssPq, ssClassical []byte,
|
||||
) ([]byte, error) {
|
||||
prk1, err := hkdf.Extract(sha256.New, ssPq, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kek1, err := hkdf.Expand(sha256.New, prk1, infoPq, kekLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Defensive copy of kek1 — append([]byte(nil), ...) avoids aliasing
|
||||
// kek1's backing array when concatenating ssClassical (Metis B1).
|
||||
ikmComposite := append(append([]byte(nil), kek1...), ssClassical...)
|
||||
prk2, err := hkdf.Extract(sha256.New, ikmComposite, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kekFinal, err := hkdf.Expand(sha256.New, prk2, infoComposite, kekLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kekFinal, nil
|
||||
}
|
||||
@@ -0,0 +1,949 @@
|
||||
package composite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test KEM harness
|
||||
//
|
||||
// The composite format pins slot 0 = PQ (schemeID 0x0006, ciphertext length
|
||||
// 1088) and slot 1 = classical (schemeID 0x0007, ciphertext length 32),
|
||||
// matching the real mlkem768 + x25519 adapter contracts. Adapters' priv types
|
||||
// are unexported and reject type-asserted impostors at Decapsulate, and the
|
||||
// composite todo's scope forbids touching adapter packages — so tests exercise
|
||||
// the composite with deterministic fake KEMs (registered under the SAME
|
||||
// schemeIDs as the real adapters). The committed golden fixture uses these
|
||||
// fakes; the composite production code is exercised end-to-end on the format,
|
||||
// the HKDF combiner, AES-256-GCM wrapping, and chunked AEAD.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const (
|
||||
fakePqSchemeID uint16 = 0x0006
|
||||
fakeClassicalSchemeID uint16 = 0x0007
|
||||
fakePqCtLen int = 1088 // matches crypto/mlkem EncapsulateKey768 ciphertext length
|
||||
fakeClassicalCtLen int = 32 // matches X25519 ephemeral pubkey length
|
||||
fakeSeedLen int = 32
|
||||
)
|
||||
|
||||
// fakeKem derives a deterministic shared secret per pub/ct pair:
|
||||
//
|
||||
// ss = SHA256(pub_raw_or_priv_raw || ct)
|
||||
//
|
||||
// where pub.raw and priv.raw are both the random seed; randomness lives only
|
||||
// in the ct (the call site's rand supplies ct bytes), so decapsulation with
|
||||
// the matching priv always recovers the encryption-time ss.
|
||||
type fakeKem struct {
|
||||
schemeIDValue uint16
|
||||
ctLenValue int
|
||||
}
|
||||
|
||||
func newFakePqKem() crypto.KEM {
|
||||
return &fakeKem{schemeIDValue: fakePqSchemeID, ctLenValue: fakePqCtLen}
|
||||
}
|
||||
|
||||
func newFakeClassicalKem() crypto.KEM {
|
||||
return &fakeKem{schemeIDValue: fakeClassicalSchemeID, ctLenValue: fakeClassicalCtLen}
|
||||
}
|
||||
|
||||
func (k *fakeKem) SchemeID() uint16 { return k.schemeIDValue }
|
||||
|
||||
func (k *fakeKem) GenerateKeyPair(
|
||||
rand io.Reader,
|
||||
) (
|
||||
crypto.RecipientPub,
|
||||
crypto.RecipientPriv,
|
||||
error,
|
||||
) {
|
||||
seed := make([]byte, fakeSeedLen)
|
||||
if _, err := io.ReadFull(rand, seed); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return newFakePub(k.schemeIDValue, seed), newFakePriv(k.schemeIDValue, seed), nil
|
||||
}
|
||||
|
||||
func (k *fakeKem) Encapsulate(
|
||||
pub crypto.RecipientPub,
|
||||
rand io.Reader,
|
||||
) (
|
||||
ciphertext []byte,
|
||||
sharedSecret []byte,
|
||||
err error,
|
||||
) {
|
||||
p, ok := pub.(*fakePub)
|
||||
if !ok {
|
||||
return nil, nil, errors.New("fakeKem: invalid pub type")
|
||||
}
|
||||
ciphertext = make([]byte, k.ctLenValue)
|
||||
if _, err := io.ReadFull(rand, ciphertext); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return ciphertext, deriveFakeSS(p.raw, ciphertext), nil
|
||||
}
|
||||
|
||||
func (k *fakeKem) Decapsulate(
|
||||
priv crypto.RecipientPriv,
|
||||
ciphertext []byte,
|
||||
) (
|
||||
sharedSecret []byte,
|
||||
err error,
|
||||
) {
|
||||
p, ok := priv.(*fakePriv)
|
||||
if !ok {
|
||||
return nil, errors.New("fakeKem: invalid priv type")
|
||||
}
|
||||
if len(ciphertext) != k.ctLenValue {
|
||||
return nil, errors.New("fakeKem: invalid ciphertext length")
|
||||
}
|
||||
return deriveFakeSS(p.raw, ciphertext), nil
|
||||
}
|
||||
|
||||
func (k *fakeKem) LoadPriv(
|
||||
raw []byte,
|
||||
) (
|
||||
crypto.RecipientPriv,
|
||||
error,
|
||||
) {
|
||||
return newFakePriv(k.schemeIDValue, raw), nil
|
||||
}
|
||||
|
||||
func deriveFakeSS(
|
||||
raw, ciphertext []byte,
|
||||
) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(raw)
|
||||
h.Write(ciphertext)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// fakePub / fakePriv — deterministic raw-bytes-backed recipients.
|
||||
type fakePub struct {
|
||||
scheme uint16
|
||||
raw []byte
|
||||
keyID []byte
|
||||
}
|
||||
|
||||
func newFakePub(
|
||||
scheme uint16,
|
||||
raw []byte,
|
||||
) *fakePub {
|
||||
h := sha256.Sum256(raw)
|
||||
return &fakePub{
|
||||
scheme: scheme,
|
||||
raw: append([]byte(nil), raw...),
|
||||
keyID: h[:8],
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakePub) SchemeID() uint16 { return f.scheme }
|
||||
func (f *fakePub) KeyID() []byte { return f.keyID }
|
||||
func (f *fakePub) Raw() []byte { return f.raw }
|
||||
|
||||
type fakePriv struct {
|
||||
scheme uint16
|
||||
raw []byte
|
||||
keyID []byte
|
||||
}
|
||||
|
||||
func newFakePriv(
|
||||
scheme uint16,
|
||||
raw []byte,
|
||||
) *fakePriv {
|
||||
h := sha256.Sum256(raw)
|
||||
return &fakePriv{
|
||||
scheme: scheme,
|
||||
raw: append([]byte(nil), raw...),
|
||||
keyID: h[:8],
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakePriv) SchemeID() uint16 { return f.scheme }
|
||||
func (f *fakePriv) KeyID() []byte { return f.keyID }
|
||||
func (f *fakePriv) Raw() []byte { return f.raw }
|
||||
|
||||
// fakeRegistry returns a Registry with the two fake KEMs registered under
|
||||
// the v2 slot schemeIDs.
|
||||
func fakeRegistry(
|
||||
t *testing.T,
|
||||
) crypto.Registry {
|
||||
t.Helper()
|
||||
reg := crypto.NewRegistry()
|
||||
if err := reg.Register(fakePqSchemeID, newFakePqKem); err != nil {
|
||||
t.Fatalf("register pq fake: %v", err)
|
||||
}
|
||||
if err := reg.Register(fakeClassicalSchemeID, newFakeClassicalKem); err != nil {
|
||||
t.Fatalf("register classical fake: %v", err)
|
||||
}
|
||||
return reg
|
||||
}
|
||||
|
||||
// generateFakeKeyPair generates (pub, priv) for slot schemeID from rand.
|
||||
func generateFakeKeyPair(
|
||||
t *testing.T,
|
||||
reg crypto.Registry,
|
||||
schemeID uint16,
|
||||
rand io.Reader,
|
||||
) (
|
||||
crypto.RecipientPub,
|
||||
crypto.RecipientPriv,
|
||||
) {
|
||||
t.Helper()
|
||||
factory, err := reg.Lookup(schemeID)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup 0x%04x: %v", schemeID, err)
|
||||
}
|
||||
pub, priv, err := factory().GenerateKeyPair(rand)
|
||||
if err != nil {
|
||||
t.Fatalf("generate 0x%04x: %v", schemeID, err)
|
||||
}
|
||||
return pub, priv
|
||||
}
|
||||
|
||||
// standardHeaderLen returns the fixed artifact-header length given the two
|
||||
// slot ciphertext lengths: 11 (magic+version+flags+nRecipients) +
|
||||
// per-slot (14 + ctLen) + 72 (wrapNonce+wrappedCEK+firstPayloadNonce).
|
||||
func standardHeaderLen(
|
||||
pqCtLen, classicalCtLen int,
|
||||
) int {
|
||||
return 11 + (14 + pqCtLen) + (14 + classicalCtLen) + (12 + 48 + 12)
|
||||
}
|
||||
|
||||
// countingReader wraps an io.Reader and counts how many bytes have been read
|
||||
// — used by the adversarial-parser tests to assert the parser does NOT
|
||||
// consume past the header before bailing out.
|
||||
type countingReader struct {
|
||||
r io.Reader
|
||||
n int64
|
||||
}
|
||||
|
||||
func (c *countingReader) Read(
|
||||
p []byte,
|
||||
) (int, error) {
|
||||
readN, err := c.r.Read(p)
|
||||
c.n += int64(readN)
|
||||
return readN, err
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests (a) through (p)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// (a) Golden format fixture.
|
||||
func TestGoldenFormat(
|
||||
t *testing.T,
|
||||
) {
|
||||
goldenBytes := mustReadFile(t, "testdata/golden-1byte.pqenc")
|
||||
|
||||
// Header byte offsets pinned verbatim — if any of these breaks, the
|
||||
// on-disk format has drifted and old .pqenc files won't decrypt.
|
||||
// [0:4] magic u32 BE = 0x47535051
|
||||
// [4:6] version u16 BE = 0x0002
|
||||
// [6:10] flags u32 BE = 0x00000000
|
||||
// [10] nRecipients u8 = 0x02
|
||||
// [11:13] slot0 schemeID = 0x0006
|
||||
// [13:21] slot0 keyID 8B
|
||||
// [21:25] slot0 ctLen u32 = 1088
|
||||
// [25:1113] slot0 ciphertext (1088 bytes)
|
||||
// [1113:1115] slot1 schemeID = 0x0007
|
||||
if binary.BigEndian.Uint32(goldenBytes[0:4]) != 0x47535051 {
|
||||
t.Errorf("magic = 0x%08x, want 0x47535051", binary.BigEndian.Uint32(goldenBytes[0:4]))
|
||||
}
|
||||
if binary.BigEndian.Uint16(goldenBytes[4:6]) != 0x0002 {
|
||||
t.Errorf("version = 0x%04x, want 0x0002", binary.BigEndian.Uint16(goldenBytes[4:6]))
|
||||
}
|
||||
if binary.BigEndian.Uint32(goldenBytes[6:10]) != 0x00000000 {
|
||||
t.Errorf("flags = 0x%08x, want 0", binary.BigEndian.Uint32(goldenBytes[6:10]))
|
||||
}
|
||||
if goldenBytes[10] != 0x02 {
|
||||
t.Errorf("nRecipients = 0x%02x, want 0x02", goldenBytes[10])
|
||||
}
|
||||
if binary.BigEndian.Uint16(goldenBytes[11:13]) != 0x0006 {
|
||||
t.Errorf("slot0 schemeID = 0x%04x, want 0x0006", binary.BigEndian.Uint16(goldenBytes[11:13]))
|
||||
}
|
||||
if binary.BigEndian.Uint16(goldenBytes[1113:1115]) != 0x0007 {
|
||||
t.Errorf("slot1 schemeID = 0x%04x, want 0x0007", binary.BigEndian.Uint16(goldenBytes[1113:1115]))
|
||||
}
|
||||
|
||||
// Decrypt-equality: reconstruct privs from committed golden-keys.json
|
||||
// and assert Decrypt yields the 0xAA plaintext committed via golden_generate.
|
||||
pqPriv, classicalPriv := loadGoldenPrivs(t, "testdata/golden-keys.json")
|
||||
|
||||
dec := NewDecryptor(fakeRegistry(t))
|
||||
out := &bytes.Buffer{}
|
||||
if err := dec.Decrypt(
|
||||
bytes.NewReader(goldenBytes),
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
out,
|
||||
); err != nil {
|
||||
t.Fatalf("Decrypt(golden) failed: %v", err)
|
||||
}
|
||||
if !bytes.Equal(out.Bytes(), []byte{0xAA}) {
|
||||
t.Errorf("decrypted = %x, want [0xAA]", out.Bytes())
|
||||
}
|
||||
}
|
||||
|
||||
// (b) Round-trip on canonical input sizes.
|
||||
func TestRoundTrip(
|
||||
t *testing.T,
|
||||
) {
|
||||
sizes := []int{0, 1, 64*1024 - 1, 64 * 1024, 64*1024 + 1, 1 << 20}
|
||||
for _, size := range sizes {
|
||||
t.Run(fmt.Sprintf("size=%d", size), func(t *testing.T) {
|
||||
plaintext := make([]byte, size)
|
||||
for i := 0; i < size; i++ {
|
||||
plaintext[i] = byte(i)
|
||||
}
|
||||
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
dec := NewDecryptor(reg)
|
||||
|
||||
pqPub, pqPriv := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, classicalPriv := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader(plaintext),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
if err := dec.Decrypt(
|
||||
bytes.NewReader(encrypted.Bytes()),
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
out,
|
||||
); err != nil {
|
||||
t.Fatalf("Decrypt: %v", err)
|
||||
}
|
||||
if !bytes.Equal(out.Bytes(), plaintext) {
|
||||
t.Errorf("round-trip mismatch: got %d bytes, want %d", out.Len(), len(plaintext))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// (c) Empty plaintext produces exactly ONE chunk with flags=0x01 and a 16B
|
||||
// (tag-only) ciphertext.
|
||||
func TestEmptyPlaintextSingleFinalChunk(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
|
||||
pqPub, _ := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, _ := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader(nil),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
headerLen := standardHeaderLen(fakePqCtLen, fakeClassicalCtLen)
|
||||
chunks := encrypted.Bytes()[headerLen:]
|
||||
|
||||
// Expected record: [len=16 u32 (4B)][flags=0x01 (1B)][ciphertext (16B)].
|
||||
if len(chunks) != 4+1+16 {
|
||||
t.Fatalf("expected 21-byte chunk record, got %d bytes", len(chunks))
|
||||
}
|
||||
if ctLen := binary.BigEndian.Uint32(chunks[0:4]); ctLen != 16 {
|
||||
t.Errorf("ctLen = %d, want 16 (tag-only)", ctLen)
|
||||
}
|
||||
if chunks[4] != 0x01 {
|
||||
t.Errorf("flags = 0x%02x, want 0x01", chunks[4])
|
||||
}
|
||||
if len(chunks[5:]) != 16 {
|
||||
t.Errorf("ciphertext = %d bytes, want 16 (tag-only)", len(chunks[5:]))
|
||||
}
|
||||
}
|
||||
|
||||
// (d) Exactly-64KiB input produces TWO chunks: full body (flags=0x00) and
|
||||
// zero-length final marker (flags=0x01).
|
||||
func TestExactly64KiBTwoChunks(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
|
||||
pqPub, _ := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, _ := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
plaintext := bytes.Repeat([]byte{0xCC}, 64*1024)
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader(plaintext),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
headerLen := standardHeaderLen(fakePqCtLen, fakeClassicalCtLen)
|
||||
chunks := encrypted.Bytes()[headerLen:]
|
||||
|
||||
// Chunk 1: full body. ct = 64 KiB plaintext + 16B tag.
|
||||
const bodyCtLen = 64*1024 + 16
|
||||
if ctLen := binary.BigEndian.Uint32(chunks[0:4]); ctLen != bodyCtLen {
|
||||
t.Errorf("chunk1 ctLen = %d, want %d", ctLen, bodyCtLen)
|
||||
}
|
||||
if chunks[4] != 0x00 {
|
||||
t.Errorf("chunk1 flags = 0x%02x, want 0x00", chunks[4])
|
||||
}
|
||||
|
||||
// Chunk 2: zero-length final marker (ct = 16B tag), flags = 0x01.
|
||||
chunk2Start := 4 + 1 + bodyCtLen
|
||||
if chunk2Start+5 > len(chunks) {
|
||||
t.Fatalf("file truncated before chunk 2: need offset %d, have %d", chunk2Start+5, len(chunks))
|
||||
}
|
||||
if ctLen := binary.BigEndian.Uint32(chunks[chunk2Start : chunk2Start+4]); ctLen != 16 {
|
||||
t.Errorf("chunk2 ctLen = %d, want 16 (zero-length marker)", ctLen)
|
||||
}
|
||||
if chunks[chunk2Start+4] != 0x01 {
|
||||
t.Errorf("chunk2 flags = 0x%02x, want 0x01", chunks[chunk2Start+4])
|
||||
}
|
||||
|
||||
chunk3Start := chunk2Start + 4 + 1 + 16
|
||||
if chunk3Start != len(chunks) {
|
||||
t.Errorf("expected exactly 2 chunks; remaining = %d bytes after chunk 2", len(chunks)-chunk3Start)
|
||||
}
|
||||
}
|
||||
|
||||
// (e) Counter wrap-around: with chunkNonce[4:12]=0xFFFFFFFFFFFFFFFF, an
|
||||
// attempt to encrypt a SECOND body chunk fails on counter increment and
|
||||
// returns ErrNonceCounterWrapped.
|
||||
func TestCounterWraparound(
|
||||
t *testing.T,
|
||||
) {
|
||||
// firstPayloadNonce: slot 0..3 = arbitrary base; slot 4..11 = 0xFF*8.
|
||||
firstPayloadNonce := make([]byte, 12)
|
||||
firstPayloadNonce[0] = 0xde
|
||||
firstPayloadNonce[1] = 0xad
|
||||
firstPayloadNonce[2] = 0xbe
|
||||
firstPayloadNonce[3] = 0xef
|
||||
for i := 4; i < 12; i++ {
|
||||
firstPayloadNonce[i] = 0xFF
|
||||
}
|
||||
|
||||
cek := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, cek); err != nil {
|
||||
t.Fatalf("rand: %v", err)
|
||||
}
|
||||
block, err := aes.NewCipher(cek)
|
||||
if err != nil {
|
||||
t.Fatalf("aes: %v", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
t.Fatalf("gcm: %v", err)
|
||||
}
|
||||
|
||||
// 64 KiB + 1 byte forces 2 body chunks; incrementing after chunk 1
|
||||
// wraps to 0 and ErrNonceCounterWrapped (the second chunk's emission
|
||||
// never happens).
|
||||
input := make([]byte, chunkSize+1)
|
||||
var out bytes.Buffer
|
||||
err = encryptChunks(bytes.NewReader(input), &out, gcm, firstPayloadNonce)
|
||||
if !errors.Is(err, ErrNonceCounterWrapped) {
|
||||
t.Errorf("expected ErrNonceCounterWrapped, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (f) Tamper 1 byte in payload → ErrTamperingDetected.
|
||||
func TestTamperPayload(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
dec := NewDecryptor(reg)
|
||||
|
||||
pqPub, pqPriv := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, classicalPriv := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
plaintext := bytes.Repeat([]byte{0x88}, 64*1024+1) // enough to produce a body chunk + final
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader(plaintext),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
buf := encrypted.Bytes()
|
||||
headerLen := standardHeaderLen(fakePqCtLen, fakeClassicalCtLen)
|
||||
tamperIdx := headerLen + 4 + 1 + 8 // into first chunk ciphertext, past len + flags
|
||||
if tamperIdx >= len(buf) {
|
||||
t.Fatalf("file too small to tamper: idx=%d len=%d", tamperIdx, len(buf))
|
||||
}
|
||||
buf[tamperIdx] ^= 0x01
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := dec.Decrypt(
|
||||
bytes.NewReader(buf),
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
out,
|
||||
)
|
||||
if !errors.Is(err, ErrTamperingDetected) {
|
||||
t.Errorf("expected ErrTamperingDetected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (g) Tamper 1 byte in wrappedCEK → ErrTamperingDetected.
|
||||
func TestTamperWrappedCEK(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
dec := NewDecryptor(reg)
|
||||
|
||||
pqPub, pqPriv := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, classicalPriv := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader([]byte{0xAA}),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
buf := encrypted.Bytes()
|
||||
|
||||
// wrappedCEK starts at: header-prefix (11) + slot0 (+ct) + slot1 (+ct) + wrapNonce (12).
|
||||
wrapOffset := 11 + (14 + fakePqCtLen) + (14 + fakeClassicalCtLen) + 12
|
||||
if wrapOffset+wrappedCekLen > len(buf) {
|
||||
t.Fatalf("file too short for wrappedCEK")
|
||||
}
|
||||
buf[wrapOffset+5] ^= 0x01
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := dec.Decrypt(
|
||||
bytes.NewReader(buf),
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
out,
|
||||
)
|
||||
if !errors.Is(err, ErrTamperingDetected) {
|
||||
t.Errorf("expected ErrTamperingDetected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (h) Wrong priv key (swap pq.priv with another) → ErrWrongKeys.
|
||||
func TestWrongPrivKey(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
dec := NewDecryptor(reg)
|
||||
|
||||
pqPub, _ := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, classicalPriv := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
// Different PQ priv — fresh seed, hence different KeyID.
|
||||
_, wrongPqPriv := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
// Sanity: the wrong priv's keyID must not collide with the original
|
||||
// pub's (otherwise this test would degrade into a keyID-collision case).
|
||||
if bytes.Equal(wrongPqPriv.KeyID(), pqPub.KeyID()) {
|
||||
t.Fatalf("wrongPqPriv keyID accidentally collides with pqPub keyID; reseed")
|
||||
}
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader([]byte{0x11, 0x22, 0x33}),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := dec.Decrypt(
|
||||
bytes.NewReader(encrypted.Bytes()),
|
||||
[]crypto.RecipientPriv{wrongPqPriv, classicalPriv},
|
||||
out,
|
||||
)
|
||||
if !errors.Is(err, ErrWrongKeys) {
|
||||
t.Errorf("expected ErrWrongKeys, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (i) Format conformance: magic, version, nRecipients.
|
||||
func TestFormatConformance(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
pqPub, _ := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, _ := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader([]byte{0x42}),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&out,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
outBytes := out.Bytes()
|
||||
if binary.BigEndian.Uint32(outBytes[0:4]) != 0x47535051 {
|
||||
t.Errorf("magic = 0x%08x, want 0x47535051", binary.BigEndian.Uint32(outBytes[0:4]))
|
||||
}
|
||||
if binary.BigEndian.Uint16(outBytes[4:6]) != 0x0002 {
|
||||
t.Errorf("version = 0x%04x, want 0x0002", binary.BigEndian.Uint16(outBytes[4:6]))
|
||||
}
|
||||
if outBytes[10] != 0x02 {
|
||||
t.Errorf("nRecipients = 0x%02x, want 0x02", outBytes[10])
|
||||
}
|
||||
}
|
||||
|
||||
// (j) Adversarial parser: version==0x0001 → ErrUnsupportedVersion, with no
|
||||
// GCM operations attempted (proven by the post-validation byte counter
|
||||
// remaining at the prefix length — the parser does not consume past the
|
||||
// header before bailing).
|
||||
func TestUnsupportedVersionNoGCM(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
dec := NewDecryptor(reg)
|
||||
|
||||
// File: magic + version=0x0001 + flags + nRecipients=0x02 + filler.
|
||||
var buf bytes.Buffer
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0x47535051)) // magic
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint16(0x0001)) // version (downgrade probe)
|
||||
buf.Write(make([]byte, 200)) // filler
|
||||
|
||||
// Dummy privs — irrelevant because the parser bails at version check,
|
||||
// but Decrypt accepts the slice shape.
|
||||
_, pqPriv := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
_, classicalPriv := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
reader := &countingReader{r: bytes.NewReader(buf.Bytes())}
|
||||
out := &bytes.Buffer{}
|
||||
err := dec.Decrypt(
|
||||
reader,
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
out,
|
||||
)
|
||||
if !errors.Is(err, ErrUnsupportedVersion) {
|
||||
t.Errorf("expected ErrUnsupportedVersion, got %v", err)
|
||||
}
|
||||
|
||||
// The parser consumed only the 11-byte prefix — no further bytes read,
|
||||
// hence no GCM operations attempted.
|
||||
if reader.n != 11 {
|
||||
t.Errorf("Decrypt consumed %d bytes post-validation; expected exactly 11 (the fixed prefix)", reader.n)
|
||||
}
|
||||
}
|
||||
|
||||
// (k) nRecipients==0 → ErrMalformedHeader.
|
||||
func TestZeroRecipients(
|
||||
t *testing.T,
|
||||
) {
|
||||
var buf bytes.Buffer
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0x47535051)) // magic
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint16(0x0002)) // version
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0)) // flags
|
||||
buf.WriteByte(0x00) // nRecipients = 0
|
||||
buf.Write(make([]byte, 64)) // filler
|
||||
|
||||
dec := NewDecryptor(fakeRegistry(t))
|
||||
err := dec.Decrypt(bytes.NewReader(buf.Bytes()), nil, &bytes.Buffer{})
|
||||
if !errors.Is(err, ErrMalformedHeader) {
|
||||
t.Errorf("expected ErrMalformedHeader, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (l) nRecipients>2 OR ctLen > maxRecipientCiphertextLen →
|
||||
// ErrMalformedHeader BEFORE io.ReadFull attempts to allocate the
|
||||
// oversized ciphertext buffer.
|
||||
func TestMalformedHeaderCtLenOverflow(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Run("nRecipients_gt_2", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0x47535051)) // magic
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint16(0x0002)) // version
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0)) // flags
|
||||
buf.WriteByte(0x03) // nRecipients = 3
|
||||
buf.Write(make([]byte, 200)) // filler
|
||||
|
||||
dec := NewDecryptor(fakeRegistry(t))
|
||||
err := dec.Decrypt(bytes.NewReader(buf.Bytes()), nil, &bytes.Buffer{})
|
||||
if !errors.Is(err, ErrMalformedHeader) {
|
||||
t.Errorf("expected ErrMalformedHeader, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ctLen_overflow", func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0x47535051)) // magic
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint16(0x0002)) // version
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0)) // flags
|
||||
buf.WriteByte(0x02) // nRecipients = 2
|
||||
|
||||
// Slot 0 metadata only — schemeID, keyID, ctLen = 2 MiB (over the
|
||||
// 1<<20 cap). The parser validates ctLen BEFORE allocating and
|
||||
// reading per-slot ciphertext bytes, so it must reject at this
|
||||
// point without attempting io.ReadFull of an oversized buffer.
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint16(0x0006))
|
||||
buf.Write(make([]byte, 8)) // keyID
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(2*1024*1024)) // ctLen
|
||||
|
||||
reader := &countingReader{r: bytes.NewReader(buf.Bytes())}
|
||||
dec := NewDecryptor(fakeRegistry(t))
|
||||
_, pqPriv := generateFakeKeyPair(t, fakeRegistry(t), fakePqSchemeID, rand.Reader)
|
||||
_, classicalPriv := generateFakeKeyPair(t, fakeRegistry(t), fakeClassicalSchemeID, rand.Reader)
|
||||
err := dec.Decrypt(
|
||||
reader,
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
&bytes.Buffer{},
|
||||
)
|
||||
if !errors.Is(err, ErrMalformedHeader) {
|
||||
t.Errorf("expected ErrMalformedHeader, got %v", err)
|
||||
}
|
||||
// Consumed exactly prefix(11) + slot0 metadata(14) = 25 bytes — the
|
||||
// ctLen validation fired before reading any slot1 metadata or any
|
||||
// per-slot ciphertext.
|
||||
if reader.n != 25 {
|
||||
t.Errorf("Decrypt consumed %d bytes; expected 25 (no io.ReadFull of oversized ct)", reader.n)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// (m) Chunk record with length==0 AND flags&0x01==0 → ErrMalformedChunk
|
||||
// (prevents an infinite-loop DoS where the parser keeps scanning zero-size
|
||||
// non-final chunks).
|
||||
func TestZeroLengthNonFinalChunk(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
dec := NewDecryptor(reg)
|
||||
|
||||
pqPub, pqPriv := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, classicalPriv := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader([]byte{0xAA}),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
headerLen := standardHeaderLen(fakePqCtLen, fakeClassicalCtLen)
|
||||
var corrupt bytes.Buffer
|
||||
corrupt.Write(encrypted.Bytes()[:headerLen])
|
||||
_ = binary.Write(&corrupt, binary.BigEndian, uint32(0)) // ctLen = 0
|
||||
corrupt.WriteByte(0x00) // flags = 0x00 (NOT final)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := dec.Decrypt(
|
||||
bytes.NewReader(corrupt.Bytes()),
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
out,
|
||||
)
|
||||
if !errors.Is(err, ErrMalformedChunk) {
|
||||
t.Errorf("expected ErrMalformedChunk, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (n) Chunk record with length > 64*1024+16 → ErrMalformedChunk.
|
||||
func TestOversizedChunk(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
dec := NewDecryptor(reg)
|
||||
|
||||
pqPub, pqPriv := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, classicalPriv := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader([]byte{0xAA}),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
headerLen := standardHeaderLen(fakePqCtLen, fakeClassicalCtLen)
|
||||
var corrupt bytes.Buffer
|
||||
corrupt.Write(encrypted.Bytes()[:headerLen])
|
||||
_ = binary.Write(&corrupt, binary.BigEndian, uint32(chunkSize+gcmTagLen+1)) // oversized
|
||||
corrupt.WriteByte(0x00) // flags = 0x00
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := dec.Decrypt(
|
||||
bytes.NewReader(corrupt.Bytes()),
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
out,
|
||||
)
|
||||
if !errors.Is(err, ErrMalformedChunk) {
|
||||
t.Errorf("expected ErrMalformedChunk, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (o) End-of-stream reached BEFORE any chunk with flags&0x01==1
|
||||
// (truncated file after a body chunk with no final marker) →
|
||||
// ErrUnexpectedEOF.
|
||||
func TestPrematureEOF(
|
||||
t *testing.T,
|
||||
) {
|
||||
reg := fakeRegistry(t)
|
||||
enc := NewEncryptor(reg)
|
||||
dec := NewDecryptor(reg)
|
||||
|
||||
pqPub, pqPriv := generateFakeKeyPair(t, reg, fakePqSchemeID, rand.Reader)
|
||||
classicalPub, classicalPriv := generateFakeKeyPair(t, reg, fakeClassicalSchemeID, rand.Reader)
|
||||
|
||||
plaintext := bytes.Repeat([]byte{0xAB}, 64*1024+1) // 2 body chunks + final marker
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader(plaintext),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rand.Reader,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
headerLen := standardHeaderLen(fakePqCtLen, fakeClassicalCtLen)
|
||||
bodyCtLen := 64*1024 + 1 + gcmTagLen
|
||||
bodyChunkRecord := 4 + 1 + bodyCtLen
|
||||
truncatedLen := headerLen + bodyChunkRecord
|
||||
|
||||
if truncatedLen >= len(encrypted.Bytes()) {
|
||||
t.Fatalf("encrypted file shorter than expected: %d vs expected truncation at %d",
|
||||
len(encrypted.Bytes()), truncatedLen)
|
||||
}
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
err := dec.Decrypt(
|
||||
bytes.NewReader(encrypted.Bytes()[:truncatedLen]),
|
||||
[]crypto.RecipientPriv{pqPriv, classicalPriv},
|
||||
out,
|
||||
)
|
||||
if !errors.Is(err, ErrUnexpectedEOF) {
|
||||
t.Errorf("expected ErrUnexpectedEOF, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// (p) Truncated header → ErrMalformedHeader BEFORE any recipient allocation.
|
||||
func TestTruncatedHeader(
|
||||
t *testing.T,
|
||||
) {
|
||||
dec := NewDecryptor(fakeRegistry(t))
|
||||
|
||||
// File is shorter than the fixed 11-byte prefix + per-recipient metadata
|
||||
// (2×14=28 = 39 bytes minimum): only 30 bytes total.
|
||||
var buf bytes.Buffer
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0x47535051)) // magic
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint16(0x0002)) // version
|
||||
_ = binary.Write(&buf, binary.BigEndian, uint32(0)) // flags
|
||||
buf.WriteByte(0x02) // nRecipients = 2
|
||||
buf.Write(make([]byte, 20)) // only 20 of the needed 28 metadata bytes
|
||||
|
||||
reader := &countingReader{r: bytes.NewReader(buf.Bytes())}
|
||||
err := dec.Decrypt(reader, nil, &bytes.Buffer{})
|
||||
if !errors.Is(err, ErrMalformedHeader) {
|
||||
t.Errorf("expected ErrMalformedHeader, got %v", err)
|
||||
}
|
||||
// No recipient ct allocation happened — only the prefix (11) + partial
|
||||
// metadata (20) = 31 bytes consumed; well shy of a full prefix+meta
|
||||
// read that would precede any per-recipient ct allocation.
|
||||
if reader.n > 39 {
|
||||
t.Errorf("Decrypt consumed %d bytes; expected ≤ 39 — no recipient allocation occurred", reader.n)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustReadFile(
|
||||
t *testing.T,
|
||||
path string,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func mustB64Decode(
|
||||
t *testing.T,
|
||||
s string,
|
||||
) []byte {
|
||||
t.Helper()
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
t.Fatalf("base64 decode: %v", err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// loadGoldenPrivs reconstructs the two fake privs from the committed
|
||||
// golden-keys.json (the file produced by //go:build golden_generate).
|
||||
type goldenKeyFile struct {
|
||||
Pq string `json:"pq"`
|
||||
Classical string `json:"classical"`
|
||||
}
|
||||
|
||||
func loadGoldenPrivs(
|
||||
t *testing.T,
|
||||
path string,
|
||||
) (
|
||||
*fakePriv,
|
||||
*fakePriv,
|
||||
) {
|
||||
t.Helper()
|
||||
data := mustReadFile(t, path)
|
||||
var keys goldenKeyFile
|
||||
if err := json.Unmarshal(data, &keys); err != nil {
|
||||
t.Fatalf("unmarshal golden keys: %v", err)
|
||||
}
|
||||
pqRaw := mustB64Decode(t, keys.Pq)
|
||||
classicalRaw := mustB64Decode(t, keys.Classical)
|
||||
if len(pqRaw) != fakeSeedLen {
|
||||
t.Fatalf("pq raw len = %d, want %d", len(pqRaw), fakeSeedLen)
|
||||
}
|
||||
if len(classicalRaw) != fakeSeedLen {
|
||||
t.Fatalf("classical raw len = %d, want %d", len(classicalRaw), fakeSeedLen)
|
||||
}
|
||||
return newFakePriv(fakePqSchemeID, pqRaw), newFakePriv(fakeClassicalSchemeID, classicalRaw)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//go:build golden_generate
|
||||
|
||||
// The golden_generate build tag is intentionally separate so CI never
|
||||
// regenerates the committed fixture. Run ONCE locally to (re)commit:
|
||||
//
|
||||
// ~/sdk/go1.26.5/bin/go test -tags golden_generate \
|
||||
// -run TestGenerateGoldenFixture -v \
|
||||
// ./pkg/adapters/crypto/composite/...
|
||||
//
|
||||
// Then commit the produced testdata/golden-1byte.pqenc and
|
||||
// testdata/golden-keys.json. Non-`-update` runs of TestGoldenFormat load
|
||||
// the committed artifacts and verify decrypt-equality.
|
||||
package composite
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
|
||||
)
|
||||
|
||||
// deterministicRand implements io.Reader via a SHA-256 counter stream so the
|
||||
// golden fixture is byte-for-byte reproducible across machines and Go
|
||||
// toolchain versions.
|
||||
type deterministicRand struct {
|
||||
seq uint64
|
||||
}
|
||||
|
||||
func (d *deterministicRand) Read(
|
||||
p []byte,
|
||||
) (int, error) {
|
||||
for offset := 0; offset < len(p); {
|
||||
var b [8]byte
|
||||
binary.BigEndian.PutUint64(b[:], d.seq)
|
||||
d.seq++
|
||||
out := sha256.New()
|
||||
out.Write(b[:])
|
||||
hashed := out.Sum(nil)
|
||||
n := copy(p[offset:], hashed)
|
||||
offset += n
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Hard-coded priv seeds so the committed golden-keys.json stays stable across
|
||||
// builds — these are the test-only private "keys" the committed golden file
|
||||
// decrypts against.
|
||||
var (
|
||||
goldenPqSeed = [32]byte{
|
||||
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
|
||||
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
|
||||
0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
|
||||
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20,
|
||||
}
|
||||
goldenClassicalSeed = [32]byte{
|
||||
0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28,
|
||||
0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30,
|
||||
0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
|
||||
0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, 0x40,
|
||||
}
|
||||
)
|
||||
|
||||
func TestGenerateGoldenFixture(
|
||||
t *testing.T,
|
||||
) {
|
||||
pqPub := newFakePub(fakePqSchemeID, goldenPqSeed[:])
|
||||
pqPriv := newFakePriv(fakePqSchemeID, goldenPqSeed[:])
|
||||
classicalPub := newFakePub(fakeClassicalSchemeID, goldenClassicalSeed[:])
|
||||
classicalPriv := newFakePriv(fakeClassicalSchemeID, goldenClassicalSeed[:])
|
||||
|
||||
reg := crypto.NewRegistry()
|
||||
if err := reg.Register(fakePqSchemeID, newFakePqKem); err != nil {
|
||||
t.Fatalf("register pq fake: %v", err)
|
||||
}
|
||||
if err := reg.Register(fakeClassicalSchemeID, newFakeClassicalKem); err != nil {
|
||||
t.Fatalf("register classical fake: %v", err)
|
||||
}
|
||||
|
||||
enc := NewEncryptor(reg)
|
||||
rng := &deterministicRand{}
|
||||
|
||||
var encrypted bytes.Buffer
|
||||
if err := enc.Encrypt(
|
||||
bytes.NewReader([]byte{0xAA}),
|
||||
[]crypto.RecipientPub{pqPub, classicalPub},
|
||||
&encrypted,
|
||||
rng,
|
||||
); err != nil {
|
||||
t.Fatalf("Encrypt: %v", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll("testdata", 0o755); err != nil {
|
||||
t.Fatalf("mkdir testdata: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("testdata/golden-1byte.pqenc", encrypted.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write golden file: %v", err)
|
||||
}
|
||||
|
||||
keys := goldenKeyFile{
|
||||
Pq: base64.StdEncoding.EncodeToString(pqPriv.Raw()),
|
||||
Classical: base64.StdEncoding.EncodeToString(classicalPriv.Raw()),
|
||||
}
|
||||
marshalled, err := json.MarshalIndent(keys, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal keys: %v", err)
|
||||
}
|
||||
marshalled = append(marshalled, '\n')
|
||||
if err := os.WriteFile("testdata/golden-keys.json", marshalled, 0o644); err != nil {
|
||||
t.Fatalf("write keys json: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Golden fixture written: testdata/golden-1byte.pqenc (%d bytes), "+
|
||||
"testdata/golden-keys.json (%d bytes)\n", len(encrypted.Bytes()), len(marshalled))
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"pq": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=",
|
||||
"classical": "ISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0A="
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package keymanager
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrPEMTypeMismatch is returned when the PEM block type does not match
|
||||
// the expected type for the given schemeID.
|
||||
ErrPEMTypeMismatch = errors.New("PEM type does not match scheme")
|
||||
// ErrInvalidPEM is returned when the file does not contain a valid PEM block.
|
||||
ErrInvalidPEM = errors.New("invalid PEM data")
|
||||
)
|
||||
|
||||
var pubPEMTypes = map[uint16]string{
|
||||
0x0006: "ML-KEM-768 PUBLIC KEY",
|
||||
0x0007: "X25519 PUBLIC KEY",
|
||||
}
|
||||
|
||||
var privPEMTypes = map[uint16]string{
|
||||
0x0006: "ML-KEM-768 PRIVATE KEY",
|
||||
0x0007: "X25519 PRIVATE KEY",
|
||||
}
|
||||
|
||||
// keyManager handles PEM encoding and decoding of recipient keys.
|
||||
type keyManager struct {
|
||||
registry crypto.Registry
|
||||
}
|
||||
|
||||
// NewKeyManager creates a new KeyManager backed by the provided Registry.
|
||||
func NewKeyManager(
|
||||
registry crypto.Registry,
|
||||
) crypto.KeyManager {
|
||||
return &keyManager{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Generate creates a new key pair for the given schemeID and writes them
|
||||
// as PEM blocks to pubOut and privOut.
|
||||
func (k *keyManager) Generate(
|
||||
schemeID uint16,
|
||||
pubOut io.Writer,
|
||||
privOut io.Writer,
|
||||
rand io.Reader,
|
||||
) error {
|
||||
factory, err := k.registry.Lookup(schemeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kem := factory()
|
||||
pub, priv, err := kem.GenerateKeyPair(rand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pubType, ok := pubPEMTypes[schemeID]
|
||||
if !ok {
|
||||
return fmt.Errorf(
|
||||
"unsupported scheme 0x%04x for public key PEM",
|
||||
schemeID,
|
||||
)
|
||||
}
|
||||
|
||||
privType, ok := privPEMTypes[schemeID]
|
||||
if !ok {
|
||||
return fmt.Errorf(
|
||||
"unsupported scheme 0x%04x for private key PEM",
|
||||
schemeID,
|
||||
)
|
||||
}
|
||||
|
||||
pubBlock := &pem.Block{
|
||||
Type: pubType,
|
||||
Bytes: pub.Raw(),
|
||||
}
|
||||
if err := pem.Encode(pubOut, pubBlock); err != nil {
|
||||
return fmt.Errorf("encode public key PEM: %w", err)
|
||||
}
|
||||
|
||||
privBlock := &pem.Block{
|
||||
Type: privType,
|
||||
Bytes: priv.Raw(),
|
||||
}
|
||||
if err := pem.Encode(privOut, privBlock); err != nil {
|
||||
return fmt.Errorf("encode private key PEM: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadPub reads a PEM-encoded public key from path and validates that its
|
||||
// type matches the expected type for schemeID.
|
||||
func (k *keyManager) LoadPub(
|
||||
path string,
|
||||
schemeID uint16,
|
||||
) (
|
||||
crypto.RecipientPub,
|
||||
error,
|
||||
) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read public key file: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("%w: no valid PEM block found", ErrInvalidPEM)
|
||||
}
|
||||
|
||||
expectedType, ok := pubPEMTypes[schemeID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"unsupported scheme 0x%04x for public key PEM",
|
||||
schemeID,
|
||||
)
|
||||
}
|
||||
|
||||
if block.Type != expectedType {
|
||||
return nil, fmt.Errorf(
|
||||
"expected PEM type %q, got %q: %w",
|
||||
expectedType,
|
||||
block.Type,
|
||||
ErrPEMTypeMismatch,
|
||||
)
|
||||
}
|
||||
|
||||
return newRecipientPub(schemeID, block.Bytes), nil
|
||||
}
|
||||
|
||||
// LoadPriv reads a PEM-encoded private key from path and validates that its
|
||||
// type matches the expected type for schemeID.
|
||||
func (k *keyManager) LoadPriv(
|
||||
path string,
|
||||
schemeID uint16,
|
||||
) (
|
||||
crypto.RecipientPriv,
|
||||
error,
|
||||
) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read private key file: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("%w: no valid PEM block found", ErrInvalidPEM)
|
||||
}
|
||||
|
||||
expectedType, ok := privPEMTypes[schemeID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf(
|
||||
"unsupported scheme 0x%04x for private key PEM",
|
||||
schemeID,
|
||||
)
|
||||
}
|
||||
|
||||
if block.Type != expectedType {
|
||||
return nil, fmt.Errorf(
|
||||
"expected PEM type %q, got %q: %w",
|
||||
expectedType,
|
||||
block.Type,
|
||||
ErrPEMTypeMismatch,
|
||||
)
|
||||
}
|
||||
|
||||
factory, err := k.registry.Lookup(schemeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kem := factory()
|
||||
return kem.LoadPriv(block.Bytes)
|
||||
}
|
||||
|
||||
// recipientPub is a generic RecipientPub implementation backed by raw bytes.
|
||||
type recipientPub struct {
|
||||
schemeID uint16
|
||||
raw []byte
|
||||
keyID []byte
|
||||
}
|
||||
|
||||
func newRecipientPub(
|
||||
schemeID uint16,
|
||||
raw []byte,
|
||||
) crypto.RecipientPub {
|
||||
var keyID []byte
|
||||
|
||||
switch schemeID {
|
||||
case 0x0006:
|
||||
h := sha256.Sum256(raw[:8])
|
||||
keyID = h[:8]
|
||||
case 0x0007:
|
||||
h := sha256.Sum256(raw)
|
||||
keyID = h[:8]
|
||||
}
|
||||
|
||||
return &recipientPub{
|
||||
schemeID: schemeID,
|
||||
raw: append([]byte(nil), raw...),
|
||||
keyID: keyID,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recipientPub) SchemeID() uint16 { return r.schemeID }
|
||||
func (r *recipientPub) KeyID() []byte { return r.keyID }
|
||||
func (r *recipientPub) Raw() []byte { return r.raw }
|
||||
@@ -0,0 +1,362 @@
|
||||
package keymanager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/mlkem768"
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/x25519"
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
|
||||
)
|
||||
|
||||
func makeRegistry(
|
||||
t *testing.T,
|
||||
) crypto.Registry {
|
||||
reg := crypto.NewRegistry()
|
||||
|
||||
if err := reg.Register(
|
||||
0x0006,
|
||||
func() crypto.KEM {
|
||||
return mlkem768.New()
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("register mlkem768: %v", err)
|
||||
}
|
||||
|
||||
if err := reg.Register(
|
||||
0x0007,
|
||||
func() crypto.KEM {
|
||||
return x25519.New()
|
||||
},
|
||||
); err != nil {
|
||||
t.Fatalf("register x25519: %v", err)
|
||||
}
|
||||
|
||||
return reg
|
||||
}
|
||||
|
||||
func TestGenerateMLKEM768(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
var pubOut, privOut bytes.Buffer
|
||||
|
||||
err := km.Generate(
|
||||
0x0006,
|
||||
&pubOut,
|
||||
&privOut,
|
||||
rand.Reader,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
pubBlock, _ := pem.Decode(pubOut.Bytes())
|
||||
if pubBlock == nil {
|
||||
t.Fatal("failed to decode public key PEM")
|
||||
}
|
||||
if pubBlock.Type != "ML-KEM-768 PUBLIC KEY" {
|
||||
t.Errorf("pub PEM type = %q, want %q", pubBlock.Type, "ML-KEM-768 PUBLIC KEY")
|
||||
}
|
||||
if len(pubBlock.Bytes) != 1184 {
|
||||
t.Errorf("pub raw len = %d, want 1184", len(pubBlock.Bytes))
|
||||
}
|
||||
|
||||
privBlock, _ := pem.Decode(privOut.Bytes())
|
||||
if privBlock == nil {
|
||||
t.Fatal("failed to decode private key PEM")
|
||||
}
|
||||
if privBlock.Type != "ML-KEM-768 PRIVATE KEY" {
|
||||
t.Errorf("priv PEM type = %q, want %q", privBlock.Type, "ML-KEM-768 PRIVATE KEY")
|
||||
}
|
||||
if len(privBlock.Bytes) != 64 {
|
||||
t.Errorf("priv raw len = %d, want 64", len(privBlock.Bytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateX25519(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
var pubOut, privOut bytes.Buffer
|
||||
|
||||
err := km.Generate(
|
||||
0x0007,
|
||||
&pubOut,
|
||||
&privOut,
|
||||
rand.Reader,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
pubBlock, _ := pem.Decode(pubOut.Bytes())
|
||||
if pubBlock == nil {
|
||||
t.Fatal("failed to decode public key PEM")
|
||||
}
|
||||
if pubBlock.Type != "X25519 PUBLIC KEY" {
|
||||
t.Errorf("pub PEM type = %q, want %q", pubBlock.Type, "X25519 PUBLIC KEY")
|
||||
}
|
||||
if len(pubBlock.Bytes) != 32 {
|
||||
t.Errorf("pub raw len = %d, want 32", len(pubBlock.Bytes))
|
||||
}
|
||||
|
||||
privBlock, _ := pem.Decode(privOut.Bytes())
|
||||
if privBlock == nil {
|
||||
t.Fatal("failed to decode private key PEM")
|
||||
}
|
||||
if privBlock.Type != "X25519 PRIVATE KEY" {
|
||||
t.Errorf("priv PEM type = %q, want %q", privBlock.Type, "X25519 PRIVATE KEY")
|
||||
}
|
||||
if len(privBlock.Bytes) != 32 {
|
||||
t.Errorf("priv raw len = %d, want 32", len(privBlock.Bytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPubMLKEM768(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
var pubOut, privOut bytes.Buffer
|
||||
|
||||
err := km.Generate(
|
||||
0x0006,
|
||||
&pubOut,
|
||||
&privOut,
|
||||
rand.Reader,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
pubPath := filepath.Join(dir, "test.pub.pem")
|
||||
|
||||
if err := os.WriteFile(pubPath, pubOut.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write pub file: %v", err)
|
||||
}
|
||||
|
||||
pub, err := km.LoadPub(pubPath, 0x0006)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPub failed: %v", err)
|
||||
}
|
||||
if pub.SchemeID() != 0x0006 {
|
||||
t.Errorf("pub.SchemeID() = 0x%04x, want 0x0006", pub.SchemeID())
|
||||
}
|
||||
if len(pub.Raw()) != 1184 {
|
||||
t.Errorf("pub.Raw() len = %d, want 1184", len(pub.Raw()))
|
||||
}
|
||||
|
||||
expectedKeyID := sha256.Sum256(pub.Raw()[:8])
|
||||
if !bytes.Equal(pub.KeyID(), expectedKeyID[:8]) {
|
||||
t.Errorf("pub.KeyID() = %x, want %x", pub.KeyID(), expectedKeyID[:8])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrivMLKEM768(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
var pubOut, privOut bytes.Buffer
|
||||
|
||||
err := km.Generate(
|
||||
0x0006,
|
||||
&pubOut,
|
||||
&privOut,
|
||||
rand.Reader,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
privPath := filepath.Join(dir, "test.priv.pem")
|
||||
|
||||
if err := os.WriteFile(privPath, privOut.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("write priv file: %v", err)
|
||||
}
|
||||
|
||||
priv, err := km.LoadPriv(privPath, 0x0006)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPriv failed: %v", err)
|
||||
}
|
||||
if priv.SchemeID() != 0x0006 {
|
||||
t.Errorf("priv.SchemeID() = 0x%04x, want 0x0006", priv.SchemeID())
|
||||
}
|
||||
if len(priv.Raw()) != 64 {
|
||||
t.Errorf("priv.Raw() len = %d, want 64", len(priv.Raw()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPubX25519(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
var pubOut, privOut bytes.Buffer
|
||||
|
||||
err := km.Generate(
|
||||
0x0007,
|
||||
&pubOut,
|
||||
&privOut,
|
||||
rand.Reader,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
pubPath := filepath.Join(dir, "test.pub.pem")
|
||||
|
||||
if err := os.WriteFile(pubPath, pubOut.Bytes(), 0o644); err != nil {
|
||||
t.Fatalf("write pub file: %v", err)
|
||||
}
|
||||
|
||||
pub, err := km.LoadPub(pubPath, 0x0007)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPub failed: %v", err)
|
||||
}
|
||||
if pub.SchemeID() != 0x0007 {
|
||||
t.Errorf("pub.SchemeID() = 0x%04x, want 0x0007", pub.SchemeID())
|
||||
}
|
||||
if len(pub.Raw()) != 32 {
|
||||
t.Errorf("pub.Raw() len = %d, want 32", len(pub.Raw()))
|
||||
}
|
||||
|
||||
expectedKeyID := sha256.Sum256(pub.Raw())
|
||||
if !bytes.Equal(pub.KeyID(), expectedKeyID[:8]) {
|
||||
t.Errorf("pub.KeyID() = %x, want %x", pub.KeyID(), expectedKeyID[:8])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrivX25519(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
var pubOut, privOut bytes.Buffer
|
||||
|
||||
err := km.Generate(
|
||||
0x0007,
|
||||
&pubOut,
|
||||
&privOut,
|
||||
rand.Reader,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate failed: %v", err)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
privPath := filepath.Join(dir, "test.priv.pem")
|
||||
|
||||
if err := os.WriteFile(privPath, privOut.Bytes(), 0o600); err != nil {
|
||||
t.Fatalf("write priv file: %v", err)
|
||||
}
|
||||
|
||||
priv, err := km.LoadPriv(privPath, 0x0007)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadPriv failed: %v", err)
|
||||
}
|
||||
if priv.SchemeID() != 0x0007 {
|
||||
t.Errorf("priv.SchemeID() = 0x%04x, want 0x0007", priv.SchemeID())
|
||||
}
|
||||
if len(priv.Raw()) != 32 {
|
||||
t.Errorf("priv.Raw() len = %d, want 32", len(priv.Raw()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPubWrongPEMType(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
pubPath := filepath.Join(dir, "wrong.pub.pem")
|
||||
|
||||
block := &pem.Block{
|
||||
Type: "X25519 PUBLIC KEY",
|
||||
Bytes: make([]byte, 32),
|
||||
}
|
||||
data := pem.EncodeToMemory(block)
|
||||
|
||||
if err := os.WriteFile(pubPath, data, 0o644); err != nil {
|
||||
t.Fatalf("write pub file: %v", err)
|
||||
}
|
||||
|
||||
_, err := km.LoadPub(pubPath, 0x0006)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong PEM type")
|
||||
}
|
||||
if !errors.Is(err, ErrPEMTypeMismatch) {
|
||||
t.Errorf("error = %v, want ErrPEMTypeMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrivWrongPEMType(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
privPath := filepath.Join(dir, "wrong.priv.pem")
|
||||
|
||||
block := &pem.Block{
|
||||
Type: "ML-KEM-768 PRIVATE KEY",
|
||||
Bytes: make([]byte, 64),
|
||||
}
|
||||
data := pem.EncodeToMemory(block)
|
||||
|
||||
if err := os.WriteFile(privPath, data, 0o600); err != nil {
|
||||
t.Fatalf("write priv file: %v", err)
|
||||
}
|
||||
|
||||
_, err := km.LoadPriv(privPath, 0x0007)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong PEM type")
|
||||
}
|
||||
if !errors.Is(err, ErrPEMTypeMismatch) {
|
||||
t.Errorf("error = %v, want ErrPEMTypeMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPubTruncatedPEM(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
pubPath := filepath.Join(dir, "truncated.pub.pem")
|
||||
|
||||
if err := os.WriteFile(pubPath, []byte("-----BEGIN ML-KEM-768 PUBLIC KEY-----\nnotbase64\n"), 0o644); err != nil {
|
||||
t.Fatalf("write pub file: %v", err)
|
||||
}
|
||||
|
||||
_, err := km.LoadPub(pubPath, 0x0006)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for truncated PEM")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidPEM) {
|
||||
t.Errorf("error = %v, want ErrInvalidPEM", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadPrivTruncatedPEM(t *testing.T) {
|
||||
reg := makeRegistry(t)
|
||||
km := NewKeyManager(reg)
|
||||
|
||||
dir := t.TempDir()
|
||||
privPath := filepath.Join(dir, "truncated.priv.pem")
|
||||
|
||||
if err := os.WriteFile(privPath, []byte("-----BEGIN X25519 PRIVATE KEY-----\nnotbase64\n"), 0o600); err != nil {
|
||||
t.Fatalf("write priv file: %v", err)
|
||||
}
|
||||
|
||||
_, err := km.LoadPriv(privPath, 0x0007)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for truncated PEM")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidPEM) {
|
||||
t.Errorf("error = %v, want ErrInvalidPEM", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package mlkem768
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/mlkem"
|
||||
"crypto/sha256"
|
||||
"crypto/sha3"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
|
||||
)
|
||||
|
||||
// suiteID is the scheme identifier for ML-KEM-768.
|
||||
const suiteID uint16 = 0x0006
|
||||
|
||||
// ErrDecapsulationFailed is returned when ciphertext decapsulation fails,
|
||||
// typically due to a tampered or invalid ciphertext.
|
||||
var ErrDecapsulationFailed = errors.New("decapsulation failed")
|
||||
|
||||
// DefaultRegistry is the package-level registry for ML-KEM-768.
|
||||
var DefaultRegistry = crypto.NewRegistry()
|
||||
|
||||
// kemAdapter wraps the Go stdlib crypto/mlkem implementation to satisfy
|
||||
// the pkg/domain/crypto.KEM interface.
|
||||
type kemAdapter struct{}
|
||||
|
||||
// New creates a new KEM adapter instance.
|
||||
func New() crypto.KEM {
|
||||
return &kemAdapter{}
|
||||
}
|
||||
|
||||
// SchemeID returns the ML-KEM-768 scheme identifier (0x0006).
|
||||
func (k *kemAdapter) SchemeID() uint16 {
|
||||
return suiteID
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates a new ML-KEM-768 key pair.
|
||||
//
|
||||
// Note: the rand parameter is part of the KEM interface contract but is
|
||||
// ignored here because the stdlib crypto/mlkem.GenerateKey768 uses
|
||||
// crypto/rand internally.
|
||||
func (k *kemAdapter) GenerateKeyPair(
|
||||
rand io.Reader,
|
||||
) (
|
||||
crypto.RecipientPub,
|
||||
crypto.RecipientPriv,
|
||||
error,
|
||||
) {
|
||||
dk, err := mlkem.GenerateKey768()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ek := dk.EncapsulationKey()
|
||||
rawPub := ek.Bytes()
|
||||
keyID := computeKeyID(rawPub)
|
||||
|
||||
pub := &pubKey{
|
||||
key: ek,
|
||||
keyID: keyID,
|
||||
}
|
||||
priv := &privKey{
|
||||
key: dk,
|
||||
keyID: keyID,
|
||||
}
|
||||
|
||||
return pub, priv, nil
|
||||
}
|
||||
|
||||
// Encapsulate generates a shared secret and ciphertext for the given public key.
|
||||
//
|
||||
// Note: the rand parameter is part of the KEM interface contract but is
|
||||
// ignored here because the stdlib (*EncapsulationKey768).Encapsulate uses
|
||||
// crypto/rand internally.
|
||||
//
|
||||
// CRITICAL: the stdlib returns (sharedKey, ciphertext) but this adapter
|
||||
// swaps the order to (ciphertext, sharedSecret) to match the domain KEM
|
||||
// interface contract.
|
||||
func (k *kemAdapter) Encapsulate(
|
||||
pub crypto.RecipientPub,
|
||||
rand io.Reader,
|
||||
) (
|
||||
ciphertext []byte,
|
||||
sharedSecret []byte,
|
||||
err error,
|
||||
) {
|
||||
p, ok := pub.(*pubKey)
|
||||
if !ok {
|
||||
raw := pub.Raw()
|
||||
ek, parseErr := mlkem.NewEncapsulationKey768(raw)
|
||||
if parseErr != nil {
|
||||
return nil, nil, fmt.Errorf("invalid public key for ML-KEM-768: %w", parseErr)
|
||||
}
|
||||
p = &pubKey{key: ek, keyID: computeKeyID(raw)}
|
||||
}
|
||||
|
||||
// stdlib returns (sharedKey, ciphertext); we swap to (ciphertext, sharedSecret).
|
||||
ss, ct := p.key.Encapsulate()
|
||||
|
||||
return ct, ss, nil
|
||||
}
|
||||
|
||||
// LoadPriv loads an ML-KEM-768 private key from raw bytes.
|
||||
func (k *kemAdapter) LoadPriv(
|
||||
raw []byte,
|
||||
) (
|
||||
crypto.RecipientPriv,
|
||||
error,
|
||||
) {
|
||||
dk, err := mlkem.NewDecapsulationKey768(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid ML-KEM-768 private key: %w", err)
|
||||
}
|
||||
pubRaw := dk.EncapsulationKey().Bytes()
|
||||
return &privKey{key: dk, keyID: computeKeyID(pubRaw)}, nil
|
||||
}
|
||||
|
||||
// Decapsulate recovers the shared secret from a ciphertext using the private key.
|
||||
// For tampered ciphertexts, it returns ErrDecapsulationFailed.
|
||||
func (k *kemAdapter) Decapsulate(
|
||||
priv crypto.RecipientPriv,
|
||||
ciphertext []byte,
|
||||
) (
|
||||
sharedSecret []byte,
|
||||
err error,
|
||||
) {
|
||||
p, ok := priv.(*privKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid private key type for ML-KEM-768")
|
||||
}
|
||||
|
||||
ss, err := p.key.Decapsulate(ciphertext)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrDecapsulationFailed, err)
|
||||
}
|
||||
|
||||
// crypto/mlkem.Decapsulate implements implicit rejection: it returns a
|
||||
// pseudorandom shared secret instead of an error for invalid ciphertexts.
|
||||
// Perform explicit rejection by recomputing the implicit-rejection value
|
||||
// Kout = SHAKE256(z || ciphertext) and comparing it with the result.
|
||||
// If they match, the ciphertext was invalid.
|
||||
seed := p.key.Bytes()
|
||||
z := seed[32:]
|
||||
shake := sha3.NewSHAKE256()
|
||||
// sha3.ShakeHash.Write/Read never return an error; explicitly ignore to satisfy errcheck.
|
||||
_, _ = shake.Write(z)
|
||||
_, _ = shake.Write(ciphertext)
|
||||
computedKout := make([]byte, mlkem.SharedKeySize)
|
||||
_, _ = shake.Read(computedKout)
|
||||
|
||||
if bytes.Equal(ss, computedKout) {
|
||||
return nil, ErrDecapsulationFailed
|
||||
}
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// pubKey wraps *mlkem.EncapsulationKey768 to satisfy crypto.RecipientPub.
|
||||
type pubKey struct {
|
||||
key *mlkem.EncapsulationKey768
|
||||
keyID []byte
|
||||
}
|
||||
|
||||
func (p *pubKey) SchemeID() uint16 { return suiteID }
|
||||
func (p *pubKey) KeyID() []byte { return p.keyID }
|
||||
func (p *pubKey) Raw() []byte { return p.key.Bytes() }
|
||||
|
||||
// privKey wraps *mlkem.DecapsulationKey768 to satisfy crypto.RecipientPriv.
|
||||
type privKey struct {
|
||||
key *mlkem.DecapsulationKey768
|
||||
keyID []byte
|
||||
}
|
||||
|
||||
func (p *privKey) SchemeID() uint16 { return suiteID }
|
||||
func (p *privKey) KeyID() []byte { return p.keyID }
|
||||
func (p *privKey) Raw() []byte { return p.key.Bytes() }
|
||||
|
||||
// computeKeyID derives the first 8 bytes of SHA-256 over the first 8 bytes of raw key material.
|
||||
func computeKeyID(raw []byte) []byte {
|
||||
h := sha256.Sum256(raw[:8])
|
||||
return h[:8]
|
||||
}
|
||||
|
||||
// init registers the ML-KEM-768 factory under suiteID 0x0006.
|
||||
func init() {
|
||||
_ = DefaultRegistry.Register(
|
||||
suiteID,
|
||||
func() crypto.KEM {
|
||||
return New()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package mlkem768
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateKeyPair(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
pub, priv, err := adapter.GenerateKeyPair(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
if pub.SchemeID() != suiteID {
|
||||
t.Errorf("pub.SchemeID() = 0x%04x, want 0x%04x", pub.SchemeID(), suiteID)
|
||||
}
|
||||
|
||||
if priv.SchemeID() != suiteID {
|
||||
t.Errorf("priv.SchemeID() = 0x%04x, want 0x%04x", priv.SchemeID(), suiteID)
|
||||
}
|
||||
|
||||
rawPub := pub.Raw()
|
||||
if len(rawPub) != 1184 {
|
||||
t.Errorf("pub.Raw() len = %d, want 1184", len(rawPub))
|
||||
}
|
||||
|
||||
rawPriv := priv.Raw()
|
||||
if len(rawPriv) != 64 {
|
||||
t.Errorf("priv.Raw() len = %d, want 64", len(rawPriv))
|
||||
}
|
||||
|
||||
expectedKeyID := sha256.Sum256(rawPub[:8])
|
||||
if !bytes.Equal(pub.KeyID(), expectedKeyID[:8]) {
|
||||
t.Errorf("pub.KeyID() = %x, want %x", pub.KeyID(), expectedKeyID[:8])
|
||||
}
|
||||
|
||||
if !bytes.Equal(priv.KeyID(), pub.KeyID()) {
|
||||
t.Errorf("priv.KeyID() = %x, want %x", priv.KeyID(), pub.KeyID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncapsulateReturnOrder(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
pub, _, err := adapter.GenerateKeyPair(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
ct, ss, err := adapter.Encapsulate(pub, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Encapsulate failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ct) != 1088 {
|
||||
t.Errorf("ciphertext len = %d, want 1088", len(ct))
|
||||
}
|
||||
|
||||
if len(ss) != 32 {
|
||||
t.Errorf("sharedSecret len = %d, want 32", len(ss))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
pub, priv, err := adapter.GenerateKeyPair(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
ct, ssEnc, err := adapter.Encapsulate(pub, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Encapsulate failed: %v", err)
|
||||
}
|
||||
|
||||
ssDec, err := adapter.Decapsulate(priv, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("Decapsulate failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(ssEnc, ssDec) {
|
||||
t.Fatalf("shared secret mismatch: encapsulate=%x, decapsulate=%x", ssEnc, ssDec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecapsulateTamperedCiphertext(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
pub, priv, err := adapter.GenerateKeyPair(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
ct, _, err := adapter.Encapsulate(pub, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Encapsulate failed: %v", err)
|
||||
}
|
||||
|
||||
ct[0] ^= 0xFF
|
||||
|
||||
_, err = adapter.Decapsulate(priv, ct)
|
||||
if err == nil {
|
||||
t.Fatal("Decapsulate with tampered ciphertext: expected error, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrDecapsulationFailed) {
|
||||
t.Errorf("Decapsulate error = %v, want ErrDecapsulationFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRegistration(t *testing.T) {
|
||||
factory, err := DefaultRegistry.Lookup(suiteID)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup suiteID 0x%04x failed: %v", suiteID, err)
|
||||
}
|
||||
|
||||
instance := factory()
|
||||
if instance.SchemeID() != suiteID {
|
||||
t.Errorf("factory() SchemeID = 0x%04x, want 0x%04x", instance.SchemeID(), suiteID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryReturnsIndependentInstances(t *testing.T) {
|
||||
factory, err := DefaultRegistry.Lookup(suiteID)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup suiteID 0x%04x failed: %v", suiteID, err)
|
||||
}
|
||||
|
||||
one := factory()
|
||||
two := factory()
|
||||
|
||||
if one.SchemeID() != two.SchemeID() {
|
||||
t.Error("factory() returned instances with different scheme IDs")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package x25519
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
|
||||
)
|
||||
|
||||
// suiteID is the scheme identifier for X25519 ECDH KEM.
|
||||
const suiteID uint16 = 0x0007
|
||||
|
||||
// ErrDecapsulationFailed is returned when ciphertext decapsulation fails,
|
||||
// typically because the ciphertext is not a valid X25519 public key.
|
||||
var ErrDecapsulationFailed = errors.New("decapsulation failed")
|
||||
|
||||
// DefaultRegistry is the package-level registry for X25519.
|
||||
var DefaultRegistry = crypto.NewRegistry()
|
||||
|
||||
// kemAdapter wraps the Go stdlib crypto/ecdh X25519 implementation to satisfy
|
||||
// the pkg/domain/crypto.KEM interface.
|
||||
type kemAdapter struct{}
|
||||
|
||||
// New creates a new KEM adapter instance.
|
||||
func New() crypto.KEM {
|
||||
return &kemAdapter{}
|
||||
}
|
||||
|
||||
// SchemeID returns the X25519 scheme identifier (0x0007).
|
||||
func (k *kemAdapter) SchemeID() uint16 {
|
||||
return suiteID
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates a new X25519 key pair.
|
||||
func (k *kemAdapter) GenerateKeyPair(
|
||||
rand io.Reader,
|
||||
) (
|
||||
crypto.RecipientPub,
|
||||
crypto.RecipientPriv,
|
||||
error,
|
||||
) {
|
||||
ecdhPriv, err := ecdh.X25519().GenerateKey(rand)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
rawPub := ecdhPriv.PublicKey().Bytes()
|
||||
keyID := computeKeyID(rawPub)
|
||||
|
||||
pub := &pubKey{
|
||||
key: ecdhPriv.PublicKey(),
|
||||
keyID: keyID,
|
||||
}
|
||||
priv := &privKey{
|
||||
key: ecdhPriv,
|
||||
keyID: keyID,
|
||||
}
|
||||
|
||||
return pub, priv, nil
|
||||
}
|
||||
|
||||
// Encapsulate generates a shared secret and ciphertext for the given public key.
|
||||
// The ciphertext is the ephemeral public key (32 bytes).
|
||||
func (k *kemAdapter) Encapsulate(
|
||||
pub crypto.RecipientPub,
|
||||
rand io.Reader,
|
||||
) (
|
||||
ciphertext []byte,
|
||||
sharedSecret []byte,
|
||||
err error,
|
||||
) {
|
||||
p, ok := pub.(*pubKey)
|
||||
if !ok {
|
||||
raw := pub.Raw()
|
||||
ek, parseErr := ecdh.X25519().NewPublicKey(raw)
|
||||
if parseErr != nil {
|
||||
return nil, nil, fmt.Errorf("invalid public key for X25519: %w", parseErr)
|
||||
}
|
||||
p = &pubKey{key: ek, keyID: computeKeyID(raw)}
|
||||
}
|
||||
|
||||
ephPriv, err := ecdh.X25519().GenerateKey(rand)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
ct := ephPriv.PublicKey().Bytes()
|
||||
ss, err := ephPriv.ECDH(p.key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return ct, ss, nil
|
||||
}
|
||||
|
||||
// LoadPriv loads an X25519 private key from raw bytes.
|
||||
func (k *kemAdapter) LoadPriv(
|
||||
raw []byte,
|
||||
) (
|
||||
crypto.RecipientPriv,
|
||||
error,
|
||||
) {
|
||||
dk, err := ecdh.X25519().NewPrivateKey(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid X25519 private key: %w", err)
|
||||
}
|
||||
pubRaw := dk.PublicKey().Bytes()
|
||||
return &privKey{key: dk, keyID: computeKeyID(pubRaw)}, nil
|
||||
}
|
||||
|
||||
// Decapsulate recovers the shared secret from a ciphertext using the private key.
|
||||
// The ciphertext must be a valid 32-byte X25519 public key.
|
||||
func (k *kemAdapter) Decapsulate(
|
||||
priv crypto.RecipientPriv,
|
||||
ciphertext []byte,
|
||||
) (
|
||||
sharedSecret []byte,
|
||||
err error,
|
||||
) {
|
||||
p, ok := priv.(*privKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid private key type for X25519")
|
||||
}
|
||||
|
||||
if len(ciphertext) != 32 {
|
||||
return nil, errors.Join(ErrDecapsulationFailed, errors.New("invalid ciphertext length"))
|
||||
}
|
||||
|
||||
// X25519 public keys are 255-bit Montgomery u-coordinates; bit 255 must be zero.
|
||||
if ciphertext[31]&0x80 != 0 {
|
||||
return nil, ErrDecapsulationFailed
|
||||
}
|
||||
|
||||
// Reject the all-zero public key (identity point), which yields an all-zero shared secret.
|
||||
allZero := true
|
||||
for _, b := range ciphertext {
|
||||
if b != 0 {
|
||||
allZero = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allZero {
|
||||
return nil, ErrDecapsulationFailed
|
||||
}
|
||||
|
||||
ephPub, err := ecdh.X25519().NewPublicKey(ciphertext)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrDecapsulationFailed, err)
|
||||
}
|
||||
|
||||
ss, err := p.key.ECDH(ephPub)
|
||||
if err != nil {
|
||||
return nil, errors.Join(ErrDecapsulationFailed, err)
|
||||
}
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// pubKey wraps *ecdh.PublicKey to satisfy crypto.RecipientPub.
|
||||
type pubKey struct {
|
||||
key *ecdh.PublicKey
|
||||
keyID []byte
|
||||
}
|
||||
|
||||
func (p *pubKey) SchemeID() uint16 { return suiteID }
|
||||
func (p *pubKey) KeyID() []byte { return p.keyID }
|
||||
func (p *pubKey) Raw() []byte { return p.key.Bytes() }
|
||||
|
||||
// privKey wraps *ecdh.PrivateKey to satisfy crypto.RecipientPriv.
|
||||
type privKey struct {
|
||||
key *ecdh.PrivateKey
|
||||
keyID []byte
|
||||
}
|
||||
|
||||
func (p *privKey) SchemeID() uint16 { return suiteID }
|
||||
func (p *privKey) KeyID() []byte { return p.keyID }
|
||||
func (p *privKey) Raw() []byte { return p.key.Bytes() }
|
||||
|
||||
// computeKeyID derives the first 8 bytes of SHA-256 over the raw public key.
|
||||
func computeKeyID(raw []byte) []byte {
|
||||
h := sha256.Sum256(raw)
|
||||
return h[:8]
|
||||
}
|
||||
|
||||
// init registers the X25519 factory under suiteID 0x0007.
|
||||
func init() {
|
||||
_ = DefaultRegistry.Register(
|
||||
suiteID,
|
||||
func() crypto.KEM {
|
||||
return New()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package x25519
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateKeyPair(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
pub, priv, err := adapter.GenerateKeyPair(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
if pub.SchemeID() != suiteID {
|
||||
t.Errorf("pub.SchemeID() = 0x%04x, want 0x%04x", pub.SchemeID(), suiteID)
|
||||
}
|
||||
|
||||
if priv.SchemeID() != suiteID {
|
||||
t.Errorf("priv.SchemeID() = 0x%04x, want 0x%04x", priv.SchemeID(), suiteID)
|
||||
}
|
||||
|
||||
rawPub := pub.Raw()
|
||||
if len(rawPub) != 32 {
|
||||
t.Errorf("pub.Raw() len = %d, want 32", len(rawPub))
|
||||
}
|
||||
|
||||
rawPriv := priv.Raw()
|
||||
if len(rawPriv) != 32 {
|
||||
t.Errorf("priv.Raw() len = %d, want 32", len(rawPriv))
|
||||
}
|
||||
|
||||
expectedKeyID := sha256.Sum256(rawPub)
|
||||
if !bytes.Equal(pub.KeyID(), expectedKeyID[:8]) {
|
||||
t.Errorf("pub.KeyID() = %x, want %x", pub.KeyID(), expectedKeyID[:8])
|
||||
}
|
||||
|
||||
if !bytes.Equal(priv.KeyID(), pub.KeyID()) {
|
||||
t.Errorf("priv.KeyID() = %x, want %x", priv.KeyID(), pub.KeyID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncapsulate(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
pub, priv, err := adapter.GenerateKeyPair(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
ct, ss, err := adapter.Encapsulate(pub, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Encapsulate failed: %v", err)
|
||||
}
|
||||
|
||||
if len(ct) != 32 {
|
||||
t.Errorf("ciphertext len = %d, want 32", len(ct))
|
||||
}
|
||||
|
||||
if len(ss) != 32 {
|
||||
t.Errorf("sharedSecret len = %d, want 32", len(ss))
|
||||
}
|
||||
|
||||
// Verify ss by independently computing priv.ECDH(ephemeralPubParsedFromCt).
|
||||
ephPub, err := ecdh.X25519().NewPublicKey(ct)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse ephemeral public key from ciphertext: %v", err)
|
||||
}
|
||||
|
||||
parsedPriv, err := ecdh.X25519().NewPrivateKey(priv.Raw())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse private key: %v", err)
|
||||
}
|
||||
|
||||
computedSS, err := parsedPriv.ECDH(ephPub)
|
||||
if err != nil {
|
||||
t.Fatalf("independent ECDH computation failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(ss, computedSS) {
|
||||
t.Errorf("shared secret mismatch: encapsulate=%x, independent=%x", ss, computedSS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
pub, priv, err := adapter.GenerateKeyPair(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
ct, ssEnc, err := adapter.Encapsulate(pub, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("Encapsulate failed: %v", err)
|
||||
}
|
||||
|
||||
ssDec, err := adapter.Decapsulate(priv, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("Decapsulate failed: %v", err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(ssEnc, ssDec) {
|
||||
t.Fatalf("shared secret mismatch: encapsulate=%x, decapsulate=%x", ssEnc, ssDec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripMany(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
for i := 0; i < 1000; i++ {
|
||||
pub, priv, err := adapter.GenerateKeyPair(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: GenerateKeyPair failed: %v", i, err)
|
||||
}
|
||||
|
||||
ct, ssEnc, err := adapter.Encapsulate(pub, rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: Encapsulate failed: %v", i, err)
|
||||
}
|
||||
|
||||
ssDec, err := adapter.Decapsulate(priv, ct)
|
||||
if err != nil {
|
||||
t.Fatalf("iteration %d: Decapsulate failed: %v", i, err)
|
||||
}
|
||||
|
||||
if !bytes.Equal(ssEnc, ssDec) {
|
||||
t.Fatalf("iteration %d: shared secret mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecapsulateRandomCiphertext(t *testing.T) {
|
||||
adapter := New()
|
||||
|
||||
_, priv, err := adapter.GenerateKeyPair(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate a random 32-byte string that is unlikely to be a valid X25519 public key.
|
||||
// Setting the high bit makes it invalid for X25519 (Montgomery u-coordinate must be < 2^255).
|
||||
randomCT := make([]byte, 32)
|
||||
if _, err := io.ReadFull(rand.Reader, randomCT); err != nil {
|
||||
t.Fatalf("failed to read random bytes: %v", err)
|
||||
}
|
||||
randomCT[31] |= 0x80 // set high bit to guarantee invalidity
|
||||
|
||||
_, err = adapter.Decapsulate(priv, randomCT)
|
||||
if err == nil {
|
||||
t.Fatal("Decapsulate with random ciphertext: expected error, got nil")
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrDecapsulationFailed) {
|
||||
t.Errorf("Decapsulate error = %v, want ErrDecapsulationFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryRegistration(t *testing.T) {
|
||||
factory, err := DefaultRegistry.Lookup(suiteID)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup suiteID 0x%04x failed: %v", suiteID, err)
|
||||
}
|
||||
|
||||
instance := factory()
|
||||
if instance.SchemeID() != suiteID {
|
||||
t.Errorf("factory() SchemeID = 0x%04x, want 0x%04x", instance.SchemeID(), suiteID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactoryReturnsIndependentInstances(t *testing.T) {
|
||||
factory, err := DefaultRegistry.Lookup(suiteID)
|
||||
if err != nil {
|
||||
t.Fatalf("Lookup suiteID 0x%04x failed: %v", suiteID, err)
|
||||
}
|
||||
|
||||
one := factory()
|
||||
two := factory()
|
||||
|
||||
if one.SchemeID() != two.SchemeID() {
|
||||
t.Error("factory() returned instances with different scheme IDs")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user