Рыба проекта. Минимальная функциональность
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
|
||||
}
|
||||
Reference in New Issue
Block a user