// 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") // ErrPlaintextTooLarge indicates the decrypted payload would exceed the // configured maximum plaintext size; decryption is aborted before the // limit is crossed to prevent unbounded disk consumption. ErrPlaintextTooLarge = errors.New("composite: plaintext size limit exceeded") ) // 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 maxPlaintextSize int64 = 1 << 40 // 1 TiB cap on decrypted output 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 scratchBytes [4]byte binary.BigEndian.PutUint32(scratchBytes[:], magic) buf.Write(scratchBytes[:]) // [0:4] magic binary.BigEndian.PutUint16(scratchBytes[:2], version) buf.Write(scratchBytes[:2]) // [4:6] version binary.BigEndian.PutUint32(scratchBytes[:], flags) buf.Write(scratchBytes[:]) // [6:10] flags // [10] nRecipients — composite v2 always carries exactly two slots. buf.WriteByte(byte(maxRecipients)) // Slot 0 (PQ). binary.BigEndian.PutUint16(scratchBytes[:2], pqPub.SchemeID()) buf.Write(scratchBytes[:2]) if len(pqPub.KeyID()) != 8 { return ErrMalformedHeader } buf.Write(pqPub.KeyID()) binary.BigEndian.PutUint32(scratchBytes[:], uint32(len(pqCt))) buf.Write(scratchBytes[:]) buf.Write(pqCt) // Slot 1 (classical). binary.BigEndian.PutUint16(scratchBytes[:2], classicalPub.SchemeID()) buf.Write(scratchBytes[:2]) if len(classicalPub.KeyID()) != 8 { return ErrMalformedHeader } buf.Write(classicalPub.KeyID()) binary.BigEndian.PutUint32(scratchBytes[:], uint32(len(classicalCt))) buf.Write(scratchBytes[:]) 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 } plaintextLimiter := &limitedWriter{writer: plaintext, remaining: maxPlaintextSize} return decryptChunks(src, plaintextLimiter, payloadGcm, firstPayloadNonce) } // limitedWriter wraps an io.Writer and rejects writes that would exceed a // maximum byte budget. type limitedWriter struct { writer io.Writer remaining int64 } func (lw *limitedWriter) Write(p []byte) (int, error) { if int64(len(p)) > lw.remaining { return 0, ErrPlaintextTooLarge } n, err := lw.writer.Write(p) lw.remaining -= int64(n) return n, err } // 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 }