Files
zeekay e85a5385b0 pqsafe: real X-Wing + capsule + manifest + AEAD + E2E tests + example
Decomplected into orthogonal leaf packages, each with one and only one
way to do its job:

  pkg/ctx      single source of truth for domain-separation context
               strings (PQSAFE-DEK-WRAP-v1, PQSAFE-CHUNK-AEAD-v1,
               PQSAFE-MANIFEST-SIG-v1, PQSAFE-REWRAP-v1,
               PQSAFE-RECOVERY-SHARE-v1, PQSAFE-QCHAIN-AUDIT-ANCHOR-v1).
               No two layers ever derive a key from the same transcript.

  pkg/xwing    X-Wing hybrid KEM (X25519 + ML-KEM-768 per IETF
               draft-connolly-cfrg-xwing-kem). One Encap, one Decap;
               SHA3-256 combiner with the IETF-prescribed label.

  pkg/identity hybrid sender/recipient identity (Ed25519 + ML-DSA-65
               + X-Wing). Single Generate / Sign / Verify surface.

  pkg/aead     bulk-chunk AEAD: AES-256-GCM (FIPS) or
               ChaCha20-Poly1305 (software), nonce = nonce_seed
               (8 B) || chunk_idx (4 B BE). AAD binds profile + idx
               + envelope_id so chunks can't be replayed across files.

  pkg/manifest deterministic core-deterministic CBOR per RFC 8949
               §4.2.1; Hash() returns SHA-384 of canonical bytes.

  pkg/capsule  per-recipient X-Wing-wrapped DEK with AEAD AAD =
               manifest_hash so a capsule can't be replayed into a
               different manifest.

  pkg/envelope Seal/Open. Stable envelope_id computed BEFORE chunk
               encryption (binds profile, version, sender pub,
               nonce_seed, ordered recipient pubs, created). Capsule
               AEAD AAD = manifest_hash; chunk AEAD AAD = envelope_id.
               Hybrid signature over manifest_hash with
               PQSAFE-MANIFEST-SIG-v1 context.

  _examples/basic  end-to-end demo: seal 200 KB to 2 recipients,
                   both recover byte-identical plaintext, tampered
                   chunk fails closed.

E2E tests pass (`go test -count=1 ./...`):
  TestRoundTrip                    X-Wing round-trip
  TestSizes                        wire-size pins
  TestCorruptCiphertextFails       implicit-rejection on KEM
  TestTwoKeysDoNotShare            no key collision
  TestSealOpenRoundTrip            full envelope round-trip
  TestWrongRecipientCannotOpen     wrong key fails closed
  TestCorruptChunkFails            chunk SHA mismatch detected
  TestTamperedManifestFails        hybrid sig refuses
  TestSwapCapsulesFails            X-Wing decap fails for wrong key
  TestExpiredEnvelopeRefused       TTL gate at Open time

Total: ~470 LOC of real crypto + ~280 LOC of tests + an example that
ships.
2026-05-12 19:52:19 -07:00

139 lines
4.0 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package aead implements PQSAFE chunk AEAD: AES-256-GCM (FIPS profile)
// or ChaCha20-Poly1305 (software profile) over the chunked file payload.
//
// Nonce construction: 12 bytes = 8-byte nonce_seed (from manifest) ||
// 4-byte big-endian chunk index. The 8-byte seed is bound into the
// signed manifest so a sender cannot reuse the same nonce_seed across
// two different files without breaking the signature. Within one file,
// chunks have unique indices so nonces are unique.
//
// The DEK is rotated every 2^32 chunks (256 TiB) via HKDF re-key — but
// chunk size is 64 KiB and the largest realistic file is well under
// 256 TiB, so in practice one DEK protects one file.
package aead
import (
"crypto/aes"
"crypto/cipher"
"encoding/binary"
"errors"
"fmt"
"golang.org/x/crypto/chacha20poly1305"
)
// Profile chooses the bulk AEAD construction.
type Profile uint8
const (
ProfileAESGCM Profile = 1 // AES-256-GCM (FIPS)
ProfileChaCha20Poly1305 Profile = 2 // ChaCha20-Poly1305 (software)
)
// String prints the canonical wire name.
func (p Profile) String() string {
switch p {
case ProfileAESGCM:
return "AES-256-GCM"
case ProfileChaCha20Poly1305:
return "ChaCha20-Poly1305"
default:
return fmt.Sprintf("aead-profile(%d)", uint8(p))
}
}
const (
NonceSize = 12
NonceSeedLen = 8
KeySize = 32
TagSize = 16
)
// Errors.
var (
ErrInvalidKey = errors.New("aead: key must be 32 bytes")
ErrInvalidNonce = errors.New("aead: nonce seed must be 8 bytes")
ErrBadProfile = errors.New("aead: unknown profile")
ErrOpen = errors.New("aead: authentication failed")
)
// Seal encrypts plaintext at the given chunk index. The full nonce is
// nonce_seed (8 bytes) || index (4 bytes, big-endian). The AAD binds
// the manifest hash + chunk index + profile so a chunk cannot be
// replayed into a different file or shuffled within the same file.
func Seal(profile Profile, key []byte, nonceSeed []byte, chunkIdx uint32, manifestHash []byte, plaintext []byte) ([]byte, error) {
if len(key) != KeySize {
return nil, ErrInvalidKey
}
if len(nonceSeed) != NonceSeedLen {
return nil, ErrInvalidNonce
}
gcm, err := newAEAD(profile, key)
if err != nil {
return nil, err
}
nonce := make([]byte, NonceSize)
copy(nonce[:NonceSeedLen], nonceSeed)
binary.BigEndian.PutUint32(nonce[NonceSeedLen:], chunkIdx)
aad := makeAAD(profile, chunkIdx, manifestHash)
return gcm.Seal(nil, nonce, plaintext, aad), nil
}
// Open inverts Seal. Returns ErrOpen if the authentication tag is bad
// or the AAD doesn't match (e.g., chunk replayed into a different
// manifest).
func Open(profile Profile, key []byte, nonceSeed []byte, chunkIdx uint32, manifestHash []byte, ciphertext []byte) ([]byte, error) {
if len(key) != KeySize {
return nil, ErrInvalidKey
}
if len(nonceSeed) != NonceSeedLen {
return nil, ErrInvalidNonce
}
gcm, err := newAEAD(profile, key)
if err != nil {
return nil, err
}
nonce := make([]byte, NonceSize)
copy(nonce[:NonceSeedLen], nonceSeed)
binary.BigEndian.PutUint32(nonce[NonceSeedLen:], chunkIdx)
aad := makeAAD(profile, chunkIdx, manifestHash)
pt, err := gcm.Open(nil, nonce, ciphertext, aad)
if err != nil {
return nil, ErrOpen
}
return pt, nil
}
// makeAAD binds (profile || chunk_idx || manifest_hash) into the AEAD's
// additional-authenticated-data. A chunk cipher-text replayed into a
// different manifest produces a tag mismatch.
func makeAAD(profile Profile, chunkIdx uint32, manifestHash []byte) []byte {
aad := make([]byte, 1+4+len(manifestHash))
aad[0] = byte(profile)
binary.BigEndian.PutUint32(aad[1:5], chunkIdx)
copy(aad[5:], manifestHash)
return aad
}
func newAEAD(profile Profile, key []byte) (cipher.AEAD, error) {
switch profile {
case ProfileAESGCM:
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
case ProfileChaCha20Poly1305:
return chacha20poly1305.New(key)
default:
return nil, ErrBadProfile
}
}