Рыба проекта. Минимальная функциональность

This commit is contained in:
2026-08-03 22:22:24 +03:00
commit 8c8631ac9c
80 changed files with 10618 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
package config
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// outputWriter is used for logging so tests can capture output.
var outputWriter io.Writer = os.Stderr
func logf(format string, args ...interface{}) {
_, _ = fmt.Fprintf(outputWriter, format+"\n", args...)
}
// Config holds the full application configuration.
type Config struct {
PG struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
SSLMode string `mapstructure:"sslmode"`
ExcludeTables []string `mapstructure:"exclude_tables"`
} `mapstructure:"pg"`
Backup struct {
Dir string `mapstructure:"dir"`
RetentionDays int `mapstructure:"retention_days"`
Cron string `mapstructure:"cron"`
} `mapstructure:"backup"`
PQScheme uint16 `mapstructure:"pq_scheme"`
ClassicalScheme uint16 `mapstructure:"classical_scheme"`
PQPublicKeyPath string `mapstructure:"pq_public_key_path"`
ClassicalPublicKeyPath string `mapstructure:"classical_public_key_path"`
Healthz struct {
Port int `mapstructure:"port"`
} `mapstructure:"healthz"`
Log struct {
Level string `mapstructure:"level"`
} `mapstructure:"log"`
ShutdownTimeout time.Duration `mapstructure:"shutdown_timeout"`
}
// RegisterFlags adds all configuration flags to the provided Cobra command.
func RegisterFlags(cmd *cobra.Command) {
flags := cmd.Flags()
flags.String("pg-host", "", "PostgreSQL host")
flags.Int("pg-port", 0, "PostgreSQL port")
flags.String("pg-user", "", "PostgreSQL user")
flags.String("pg-password", "", "PostgreSQL password")
flags.String("pg-database", "", "PostgreSQL database name")
flags.String("pg-sslmode", "", "PostgreSQL SSL mode")
flags.StringSlice("pg-exclude-tables", nil, "PostgreSQL tables to exclude from backup")
flags.String("backup-dir", "", "Backup directory")
flags.Int("backup-retention-days", 0, "Backup retention period in days")
flags.String("backup-cron", "", "Cron expression for backup schedule")
flags.Uint16("pq-scheme", 0, "Post-quantum KEM scheme ID")
flags.Uint16("classical-scheme", 0, "Classical KEM scheme ID")
flags.String("pq-public-key-path", "", "Path to post-quantum public key")
flags.String("classical-public-key-path", "", "Path to classical public key")
flags.Int("healthz-port", 0, "Health check HTTP port")
flags.String("log-level", "", "Log level")
flags.Duration("shutdown-timeout", 0, "Graceful shutdown timeout")
}
// Load reads configuration from all sources in priority order:
// launch arguments (Cobra flags) → APP_* env variables → config file → defaults.
func Load(cmd *cobra.Command) (*Config, error) {
// Use a fresh Viper instance so that successive calls do not leak state.
v := viper.New()
// Defaults (lowest priority).
v.SetDefault("pg.port", 5432)
v.SetDefault("pg.sslmode", "prefer")
v.SetDefault("pg.exclude_tables", []string{"e2e_one_time_keys_json"})
v.SetDefault("backup.retention_days", 180)
v.SetDefault("backup.cron", "0 0 3 * * *")
v.SetDefault("pq_scheme", uint16(0x0006))
v.SetDefault("classical_scheme", uint16(0x0007))
v.SetDefault("healthz.port", 8080)
v.SetDefault("log.level", "info")
v.SetDefault("shutdown_timeout", 30*time.Second)
// Bind parsed Cobra flags to Viper keys.
if cmd != nil {
_ = v.BindPFlag("pg.host", cmd.Flags().Lookup("pg-host"))
_ = v.BindPFlag("pg.port", cmd.Flags().Lookup("pg-port"))
_ = v.BindPFlag("pg.user", cmd.Flags().Lookup("pg-user"))
_ = v.BindPFlag("pg.password", cmd.Flags().Lookup("pg-password"))
_ = v.BindPFlag("pg.database", cmd.Flags().Lookup("pg-database"))
_ = v.BindPFlag("pg.sslmode", cmd.Flags().Lookup("pg-sslmode"))
_ = v.BindPFlag("pg.exclude_tables", cmd.Flags().Lookup("pg-exclude-tables"))
_ = v.BindPFlag("backup.dir", cmd.Flags().Lookup("backup-dir"))
_ = v.BindPFlag("backup.retention_days", cmd.Flags().Lookup("backup-retention-days"))
_ = v.BindPFlag("backup.cron", cmd.Flags().Lookup("backup-cron"))
_ = v.BindPFlag("pq_scheme", cmd.Flags().Lookup("pq-scheme"))
_ = v.BindPFlag("classical_scheme", cmd.Flags().Lookup("classical-scheme"))
_ = v.BindPFlag("pq_public_key_path", cmd.Flags().Lookup("pq-public-key-path"))
_ = v.BindPFlag("classical_public_key_path", cmd.Flags().Lookup("classical-public-key-path"))
_ = v.BindPFlag("healthz.port", cmd.Flags().Lookup("healthz-port"))
_ = v.BindPFlag("log.level", cmd.Flags().Lookup("log-level"))
_ = v.BindPFlag("shutdown_timeout", cmd.Flags().Lookup("shutdown-timeout"))
}
// Config file search with logging.
v.SetConfigName("config")
v.SetConfigType("yaml")
if envLoc := os.Getenv("APP_CONFIG_LOCATION"); envLoc != "" {
v.SetConfigFile(envLoc)
if err := v.ReadInConfig(); err == nil {
logf("Config file found: %s (from APP_CONFIG_LOCATION)", v.ConfigFileUsed())
} else {
return nil, fmt.Errorf("config file specified in APP_CONFIG_LOCATION not found: %s", envLoc)
}
} else {
v.AddConfigPath(".")
homeDir, _ := os.UserHomeDir()
appName := "synapse-backupper"
userConfigPath := filepath.Join(homeDir, ".config", appName)
v.AddConfigPath(userConfigPath)
exePath, _ := os.Executable()
exeDir := filepath.Dir(exePath)
v.AddConfigPath(exeDir)
etcPath := filepath.Join("/etc", appName)
v.AddConfigPath(etcPath)
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
logf("Configuration file not found. Using defaults and env/args.")
} else {
return nil, fmt.Errorf("error reading config: %w", err)
}
} else {
logf("Config file found: %s", v.ConfigFileUsed())
}
}
// Environment variables.
v.SetEnvPrefix("APP")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
v.AutomaticEnv()
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("config unmarshal failed: %w", err)
}
return &cfg, nil
}
+224
View File
@@ -0,0 +1,224 @@
package config
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestEnvOverridesYAML(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
content := `
pg:
host: localhost
port: 5433
backup:
retention_days: 90
`
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
t.Setenv("APP_CONFIG_LOCATION", configPath)
t.Setenv("APP_PG_HOST", "remote")
cmd := &cobra.Command{}
RegisterFlags(cmd)
var buf bytes.Buffer
oldOutput := outputWriter
outputWriter = &buf
defer func() { outputWriter = oldOutput }()
cfg, err := Load(cmd)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.PG.Host != "remote" {
t.Errorf("PG.Host = %q, want %q (env should override YAML)", cfg.PG.Host, "remote")
}
if cfg.PG.Port != 5433 {
t.Errorf("PG.Port = %d, want %d (YAML value should be preserved when env not set)", cfg.PG.Port, 5433)
}
if cfg.Backup.RetentionDays != 90 {
t.Errorf("Backup.RetentionDays = %d, want %d (from YAML)", cfg.Backup.RetentionDays, 90)
}
logOutput := buf.String()
if !strings.Contains(logOutput, configPath) {
t.Errorf("log output did not contain config path %q: %s", configPath, logOutput)
}
}
func TestDefaults(t *testing.T) {
cmd := &cobra.Command{}
RegisterFlags(cmd)
var buf bytes.Buffer
oldOutput := outputWriter
outputWriter = &buf
defer func() { outputWriter = oldOutput }()
cfg, err := Load(cmd)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.PG.Port != 5432 {
t.Errorf("PG.Port = %d, want %d", cfg.PG.Port, 5432)
}
if cfg.PG.SSLMode != "prefer" {
t.Errorf("PG.SSLMode = %q, want %q", cfg.PG.SSLMode, "prefer")
}
if len(cfg.PG.ExcludeTables) != 1 || cfg.PG.ExcludeTables[0] != "e2e_one_time_keys_json" {
t.Errorf("PG.ExcludeTables = %v, want [e2e_one_time_keys_json]", cfg.PG.ExcludeTables)
}
if cfg.Backup.RetentionDays != 180 {
t.Errorf("Backup.RetentionDays = %d, want %d", cfg.Backup.RetentionDays, 180)
}
if cfg.Backup.Cron != "0 0 3 * * *" {
t.Errorf("Backup.Cron = %q, want %q", cfg.Backup.Cron, "0 0 3 * * *")
}
if cfg.PQScheme != 0x0006 {
t.Errorf("PQScheme = 0x%04x, want 0x%04x", cfg.PQScheme, 0x0006)
}
if cfg.ClassicalScheme != 0x0007 {
t.Errorf("ClassicalScheme = 0x%04x, want 0x%04x", cfg.ClassicalScheme, 0x0007)
}
if cfg.Healthz.Port != 8080 {
t.Errorf("Healthz.Port = %d, want %d", cfg.Healthz.Port, 8080)
}
if cfg.Log.Level != "info" {
t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "info")
}
logOutput := buf.String()
if !strings.Contains(logOutput, "Configuration file not found") {
t.Errorf("log output did not contain 'Configuration file not found': %s", logOutput)
}
}
func TestFlagsOverrideEnv(t *testing.T) {
t.Setenv("APP_PG_HOST", "env-host")
cmd := &cobra.Command{}
RegisterFlags(cmd)
if err := cmd.ParseFlags([]string{"--pg-host", "flag-host"}); err != nil {
t.Fatalf("ParseFlags failed: %v", err)
}
var buf bytes.Buffer
oldOutput := outputWriter
outputWriter = &buf
defer func() { outputWriter = oldOutput }()
cfg, err := Load(cmd)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.PG.Host != "flag-host" {
t.Errorf("PG.Host = %q, want %q (flag should override env)", cfg.PG.Host, "flag-host")
}
}
func TestAllConfigFields(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.yaml")
content := `
pg:
host: db.example.com
port: 5432
user: synapse
password: secret
database: synapse_db
sslmode: require
exclude_tables:
- table1
- table2
backup:
dir: /backups
retention_days: 30
cron: "0 0 * * *"
pq_scheme: 0x0001
classical_scheme: 0x0002
pq_public_key_path: /keys/pq.pub
classical_public_key_path: /keys/classical.pub
healthz:
port: 9090
log:
level: debug
`
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write test config: %v", err)
}
t.Setenv("APP_CONFIG_LOCATION", configPath)
cmd := &cobra.Command{}
RegisterFlags(cmd)
var buf bytes.Buffer
oldOutput := outputWriter
outputWriter = &buf
defer func() { outputWriter = oldOutput }()
cfg, err := Load(cmd)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.PG.Host != "db.example.com" {
t.Errorf("PG.Host = %q, want %q", cfg.PG.Host, "db.example.com")
}
if cfg.PG.User != "synapse" {
t.Errorf("PG.User = %q, want %q", cfg.PG.User, "synapse")
}
if cfg.PG.Password != "secret" {
t.Errorf("PG.Password = %q, want %q", cfg.PG.Password, "secret")
}
if cfg.PG.Database != "synapse_db" {
t.Errorf("PG.Database = %q, want %q", cfg.PG.Database, "synapse_db")
}
if cfg.PG.SSLMode != "require" {
t.Errorf("PG.SSLMode = %q, want %q", cfg.PG.SSLMode, "require")
}
if len(cfg.PG.ExcludeTables) != 2 || cfg.PG.ExcludeTables[0] != "table1" {
t.Errorf("PG.ExcludeTables = %v, want [table1 table2]", cfg.PG.ExcludeTables)
}
if cfg.Backup.Dir != "/backups" {
t.Errorf("Backup.Dir = %q, want %q", cfg.Backup.Dir, "/backups")
}
if cfg.Backup.Cron != "0 0 * * *" {
t.Errorf("Backup.Cron = %q, want %q", cfg.Backup.Cron, "0 0 * * *")
}
if cfg.PQScheme != 0x0001 {
t.Errorf("PQScheme = 0x%04x, want 0x%04x", cfg.PQScheme, 0x0001)
}
if cfg.ClassicalScheme != 0x0002 {
t.Errorf("ClassicalScheme = 0x%04x, want 0x%04x", cfg.ClassicalScheme, 0x0002)
}
if cfg.PQPublicKeyPath != "/keys/pq.pub" {
t.Errorf("PQPublicKeyPath = %q, want %q", cfg.PQPublicKeyPath, "/keys/pq.pub")
}
if cfg.ClassicalPublicKeyPath != "/keys/classical.pub" {
t.Errorf("ClassicalPublicKeyPath = %q, want %q", cfg.ClassicalPublicKeyPath, "/keys/classical.pub")
}
if cfg.Healthz.Port != 9090 {
t.Errorf("Healthz.Port = %d, want %d", cfg.Healthz.Port, 9090)
}
if cfg.Log.Level != "debug" {
t.Errorf("Log.Level = %q, want %q", cfg.Log.Level, "debug")
}
logOutput := buf.String()
if !strings.Contains(logOutput, configPath) {
t.Errorf("log output did not contain config path %q: %s", configPath, logOutput)
}
}
+624
View File
@@ -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)
}
}
+194
View File
@@ -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")
}
}
+196
View File
@@ -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()
},
)
}
+189
View File
@@ -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")
}
}
+67
View File
@@ -0,0 +1,67 @@
package healthz
import (
"context"
"fmt"
"net"
"net/http"
"sync/atomic"
"time"
)
// Server is a minimal HTTP health check server.
type Server struct {
listener net.Listener
server *http.Server
shuttingDown atomic.Bool
}
// New creates a health check server listening on the given port.
// Passing port 0 binds to an available ephemeral port.
func New(port int) (*Server, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
return nil, fmt.Errorf("failed to create listener: %w", err)
}
s := &Server{listener: listener}
s.server = &http.Server{
Handler: http.HandlerFunc(s.handleHealthz),
}
return s, nil
}
// Addr returns the bound network address (e.g. "127.0.0.1:8080").
func (s *Server) Addr() string {
if s.listener == nil {
return ""
}
return s.listener.Addr().String()
}
// Start begins serving HTTP requests. It blocks until Stop is called.
func (s *Server) Start() error {
return s.server.Serve(s.listener)
}
// Stop initiates graceful shutdown. After Stop is called the /healthz
// endpoint returns 503 while in-flight requests complete.
func (s *Server) Stop(ctx context.Context) error {
s.shuttingDown.Store(true)
// Grace period so that health checks can observe the 503 state
// before the listener is closed.
time.Sleep(100 * time.Millisecond)
return s.server.Shutdown(ctx)
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/healthz" {
http.NotFound(w, r)
return
}
if s.shuttingDown.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
}
+77
View File
@@ -0,0 +1,77 @@
package healthz
import (
"context"
"fmt"
"net/http"
"testing"
"time"
)
func TestHealthzReturns200WhileAlive(t *testing.T) {
srv, err := New(0)
if err != nil {
t.Fatalf("New failed: %v", err)
}
go func() {
if err := srv.Start(); err != nil && err != http.ErrServerClosed {
t.Errorf("Start returned unexpected error: %v", err)
}
}()
// Give the server a moment to start listening.
time.Sleep(50 * time.Millisecond)
url := fmt.Sprintf("http://%s/healthz", srv.Addr())
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET /healthz failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("/healthz status = %d, want %d", resp.StatusCode, http.StatusOK)
}
if err := srv.Stop(context.Background()); err != nil {
t.Fatalf("Stop failed: %v", err)
}
}
func TestHealthzReturns503WhileShuttingDown(t *testing.T) {
srv, err := New(0)
if err != nil {
t.Fatalf("New failed: %v", err)
}
go func() {
if err := srv.Start(); err != nil && err != http.ErrServerClosed {
t.Errorf("Start returned unexpected error: %v", err)
}
}()
time.Sleep(50 * time.Millisecond)
// Initiate shutdown but don't wait for it to finish.
shutdownCtx, cancel := context.WithCancel(context.Background())
go func() {
_ = srv.Stop(shutdownCtx)
}()
// Give the shutdown flag time to flip.
time.Sleep(50 * time.Millisecond)
url := fmt.Sprintf("http://%s/healthz", srv.Addr())
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET /healthz during shutdown failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("/healthz status during shutdown = %d, want %d", resp.StatusCode, http.StatusServiceUnavailable)
}
cancel()
}
+100
View File
@@ -0,0 +1,100 @@
package pgdump
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump"
)
// adapter provides pg_dump functionality using the system's pg_dump binary.
type adapter struct {
commandContext func(ctx context.Context, name string, arg ...string) *exec.Cmd
}
// New creates a new pg_dump adapter.
func New() pgdump.Dumper {
return &adapter{
commandContext: exec.CommandContext,
}
}
// Dump executes pg_dump and writes the output to sink.
func (a *adapter) Dump(
ctx context.Context,
opts pgdump.Options,
sink io.Writer,
) (retErr error) {
defer func() {
if pipeWriter, ok := sink.(*io.PipeWriter); ok {
if retErr != nil {
_ = pipeWriter.CloseWithError(retErr)
} else {
_ = pipeWriter.Close()
}
}
}()
args := buildArgs(opts)
cmd := a.commandContext(ctx, "pg_dump", args...)
env := os.Environ()
if opts.Password != "" {
env = append(env, fmt.Sprintf("PGPASSWORD=%s", opts.Password))
}
if opts.Host != "" {
env = append(env, fmt.Sprintf("PGHOST=%s", opts.Host))
}
if opts.Port != 0 {
env = append(env, fmt.Sprintf("PGPORT=%d", opts.Port))
}
if opts.User != "" {
env = append(env, fmt.Sprintf("PGUSER=%s", opts.User))
}
if opts.Database != "" {
env = append(env, fmt.Sprintf("PGDATABASE=%s", opts.Database))
}
cmd.Env = env
var stderrBuilder strings.Builder
cmd.Stderr = &stderrBuilder
cmd.Stdout = sink
runErr := cmd.Run()
stderr := strings.TrimSpace(stderrBuilder.String())
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
return fmt.Errorf("%w (exit %d): %s", pgdump.ErrPgDumpFailed(exitErr.ExitCode()), exitErr.ExitCode(), stderr)
}
return runErr
}
if cmd.ProcessState.ExitCode() != 0 {
return fmt.Errorf("%w (exit %d): %s", pgdump.ErrPgDumpFailed(cmd.ProcessState.ExitCode()), cmd.ProcessState.ExitCode(), stderr)
}
return nil
}
func buildArgs(opts pgdump.Options) []string {
excludeTables := opts.ExcludeTables
if len(excludeTables) == 0 {
excludeTables = make([]string, 1)
excludeTables[0] = "e2e_one_time_keys_json"
}
args := make([]string, 0, len(excludeTables)+1)
args = append(args, "--format=custom")
for _, table := range excludeTables {
args = append(args, "--exclude-table="+table)
}
return args
}
+374
View File
@@ -0,0 +1,374 @@
package pgdump
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"reflect"
"strings"
"testing"
"time"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump"
)
// mockCommandContext creates a test helper that asserts the command name and args,
// and returns a shell command that produces the given stdout, stderr, and exit code.
func mockCommandContext(
t *testing.T,
wantName string,
wantArgs []string,
stdout string,
stderr string,
exitCode int,
) func(ctx context.Context, name string, arg ...string) *exec.Cmd {
return func(ctx context.Context, name string, arg ...string) *exec.Cmd {
if name != wantName {
t.Errorf("command name = %q, want %q", name, wantName)
}
if !reflect.DeepEqual(arg, wantArgs) {
t.Errorf("args = %v, want %v", arg, wantArgs)
}
script := fmt.Sprintf(
"printf '%%s' '%s'; printf '%%s' '%s' >&2; exit %d",
stdout,
stderr,
exitCode,
)
return exec.CommandContext(ctx, "sh", "-c", script)
}
}
func TestDump_Success(t *testing.T) {
wantArgs := []string{
"--format=custom",
"--exclude-table=e2e_one_time_keys_json",
}
adapter := &adapter{
commandContext: mockCommandContext(
t,
"pg_dump",
wantArgs,
"dumpdata",
"",
0,
),
}
var buf bytes.Buffer
err := adapter.Dump(
context.Background(),
pgdump.Options{Database: "testdb"},
&buf,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.String() != "dumpdata" {
t.Errorf("output = %q, want %q", buf.String(), "dumpdata")
}
}
func TestDump_WaitAfterStdoutEOF(t *testing.T) {
// This test verifies that after io.Copy returns (stdout EOF),
// cmd.Wait() is called and the exit code is verified before returning.
wantArgs := []string{
"--format=custom",
"--exclude-table=e2e_one_time_keys_json",
}
adapter := &adapter{
commandContext: mockCommandContext(
t,
"pg_dump",
wantArgs,
"dumpdata",
"",
0,
),
}
var buf bytes.Buffer
err := adapter.Dump(
context.Background(),
pgdump.Options{Database: "testdb"},
&buf,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if buf.String() != "dumpdata" {
t.Errorf("output = %q, want %q", buf.String(), "dumpdata")
}
}
func TestDump_NonZeroExitCode(t *testing.T) {
wantArgs := []string{
"--format=custom",
"--exclude-table=e2e_one_time_keys_json",
}
adapter := &adapter{
commandContext: mockCommandContext(
t,
"pg_dump",
wantArgs,
"",
"stderr error message",
1,
),
}
var buf bytes.Buffer
err := adapter.Dump(
context.Background(),
pgdump.Options{Database: "testdb"},
&buf,
)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, pgdump.ErrPgDumpFailed(1)) {
t.Errorf("error = %v, want ErrPgDumpFailed(1)", err)
}
}
func TestDump_StderrCaptured(t *testing.T) {
wantStderr := "stderr captured"
var capturedCmd *exec.Cmd
adapter := &adapter{
commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
script := fmt.Sprintf("printf '%%s' '%s' >&2; exit 0", wantStderr)
capturedCmd = exec.CommandContext(ctx, "sh", "-c", script)
return capturedCmd
},
}
var buf bytes.Buffer
err := adapter.Dump(
context.Background(),
pgdump.Options{Database: "testdb"},
&buf,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedCmd.Stderr == nil {
t.Fatal("expected cmd.Stderr to be set, got nil")
}
stderrBuilder, ok := capturedCmd.Stderr.(*strings.Builder)
if !ok {
t.Fatalf("expected cmd.Stderr to be *strings.Builder, got %T", capturedCmd.Stderr)
}
if stderrBuilder.String() != wantStderr {
t.Errorf("stderr = %q, want %q", stderrBuilder.String(), wantStderr)
}
}
func TestDump_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
adapter := &adapter{
commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "while :; do :; done")
},
}
var buf bytes.Buffer
err := adapter.Dump(ctx, pgdump.Options{Database: "testdb"}, &buf)
if err == nil {
t.Fatal("expected error due to context cancellation, got nil")
}
// Accept either context deadline exceeded or signal killed.
if !errors.Is(err, context.DeadlineExceeded) && !strings.Contains(err.Error(), "signal") {
t.Logf("got error: %v (acceptable variants: context.DeadlineExceeded or signal killed)", err)
}
}
func TestDump_DefaultExcludeTables(t *testing.T) {
wantArgs := []string{
"--format=custom",
"--exclude-table=e2e_one_time_keys_json",
}
adapter := &adapter{
commandContext: mockCommandContext(
t,
"pg_dump",
wantArgs,
"",
"",
0,
),
}
err := adapter.Dump(
context.Background(),
pgdump.Options{Database: "testdb"},
io.Discard,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDump_CustomExcludeTables(t *testing.T) {
wantArgs := []string{
"--format=custom",
"--exclude-table=table_a",
"--exclude-table=table_b",
}
adapter := &adapter{
commandContext: mockCommandContext(
t,
"pg_dump",
wantArgs,
"",
"",
0,
),
}
err := adapter.Dump(
context.Background(),
pgdump.Options{
Database: "testdb",
ExcludeTables: []string{"table_a", "table_b"},
},
io.Discard,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDump_EnvVars(t *testing.T) {
var capturedCmd *exec.Cmd
adapter := &adapter{
commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
capturedCmd = exec.CommandContext(ctx, "sh", "-c", "exit 0")
return capturedCmd
},
}
err := adapter.Dump(
context.Background(),
pgdump.Options{
Host: "myhost",
Port: 5433,
User: "myuser",
Password: "mypass",
Database: "mydb",
},
io.Discard,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
envStr := strings.Join(capturedCmd.Env, "\n")
wantEnvVars := []string{
"PGHOST=myhost",
"PGPORT=5433",
"PGUSER=myuser",
"PGPASSWORD=mypass",
"PGDATABASE=mydb",
}
for _, wantEnv := range wantEnvVars {
if !strings.Contains(envStr, wantEnv) {
t.Errorf("env missing %q", wantEnv)
}
}
}
func TestDump_PipeWriterClosed(t *testing.T) {
adapter := &adapter{
commandContext: mockCommandContext(
t,
"pg_dump",
[]string{
"--format=custom",
"--exclude-table=e2e_one_time_keys_json",
},
"pipe data",
"",
0,
),
}
pr, pw := io.Pipe()
readDone := make(chan struct{})
var readData []byte
var readErr error
go func() {
readData, readErr = io.ReadAll(pr)
close(readDone)
}()
err := adapter.Dump(
context.Background(),
pgdump.Options{Database: "testdb"},
pw,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
<-readDone
if readErr != nil {
t.Fatalf("read error: %v", readErr)
}
if string(readData) != "pipe data" {
t.Errorf("read data = %q, want %q", string(readData), "pipe data")
}
}
func TestDump_PipeWriterClosedWithError(t *testing.T) {
adapter := &adapter{
commandContext: func(ctx context.Context, name string, arg ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "exit 1")
},
}
pr, pw := io.Pipe()
readDone := make(chan struct{})
var readErr error
go func() {
_, readErr = io.ReadAll(pr)
close(readDone)
}()
err := adapter.Dump(
context.Background(),
pgdump.Options{Database: "testdb"},
pw,
)
if err == nil {
t.Fatal("expected error, got nil")
}
<-readDone
if readErr == nil {
t.Fatal("expected read error due to pipe close with error, got nil")
}
if !errors.Is(readErr, pgdump.ErrPgDumpFailed(1)) {
t.Errorf("read error = %v, want ErrPgDumpFailed(1)", readErr)
}
}
+102
View File
@@ -0,0 +1,102 @@
package pipeline
import (
"context"
"io"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump"
)
// Option configures a Runner.
type Option func(*runner)
// WithDumper replaces the default pg_dump dumper.
func WithDumper(dumper pgdump.Dumper) Option {
return func(r *runner) {
r.dumper = dumper
}
}
// WithEncryptor replaces the default encryptor.
func WithEncryptor(encryptor crypto.Encryptor) Option {
return func(r *runner) {
r.encryptor = encryptor
}
}
// Runner orchestrates the dump → encrypt → sink pipeline.
type runner struct {
dumper pgdump.Dumper
encryptor crypto.Encryptor
}
// NewRunner creates a pipeline runner with the given functional options.
func NewRunner(options ...Option) *runner {
r := &runner{}
for _, option := range options {
option(r)
}
return r
}
// Run executes the full backup pipeline: pg_dump → encrypt → sink.
func (r *runner) Run(
ctx context.Context,
pgDumpOpts pgdump.Options,
recipients []crypto.RecipientPub,
sink domain.Sink,
rand io.Reader,
) (retErr error) {
tx, err := sink.Begin(pgDumpOpts.Key)
if err != nil {
return err
}
defer func() {
if retErr != nil {
_ = tx.Abort()
}
}()
dumpCtx, dumpCancel := context.WithCancel(ctx)
defer dumpCancel()
pipeR, pipeW := io.Pipe()
dumpErrCh := make(chan error, 1)
go func() {
dumpErrCh <- r.dumper.Dump(dumpCtx, pgDumpOpts, pipeW)
}()
encryptErrCh := make(chan error, 1)
go func() {
encryptErrCh <- r.encryptor.Encrypt(pipeR, recipients, tx, rand)
}()
select {
case derr := <-dumpErrCh:
if derr != nil {
_ = pipeR.CloseWithError(derr)
_ = <-encryptErrCh
return derr
}
eerr := <-encryptErrCh
if eerr != nil {
return eerr
}
return tx.Commit()
case eerr := <-encryptErrCh:
if eerr != nil {
dumpCancel()
_ = pipeR.CloseWithError(eerr)
_ = <-dumpErrCh
return eerr
}
derr := <-dumpErrCh
if derr != nil {
return derr
}
return tx.Commit()
}
}
+249
View File
@@ -0,0 +1,249 @@
package pipeline
import (
"context"
"errors"
"io"
"runtime"
"testing"
"time"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump"
)
var (
errPgDumpFailed = errors.New("pg_dump failed")
errEncryptFailed = errors.New("encryption failed")
)
type fakeSink struct {
transaction *fakeSinkTx
}
func (sink *fakeSink) Begin(key string) (domain.SinkTx, error) {
sink.transaction = &fakeSinkTx{}
return sink.transaction, nil
}
func (sink *fakeSink) List(prefix string) ([]string, error) {
return make([]string, 0), nil
}
func (sink *fakeSink) Remove(key string) error {
return nil
}
type fakeSinkTx struct {
committed bool
aborted bool
data []byte
}
func (transaction *fakeSinkTx) Write(p []byte) (int, error) {
transaction.data = append(transaction.data, p...)
return len(p), nil
}
func (transaction *fakeSinkTx) Commit() error {
transaction.committed = true
return nil
}
func (transaction *fakeSinkTx) Abort() error {
transaction.aborted = true
return nil
}
type fakeDumper struct {
writeBytes int
returnErr error
closePipe bool
}
func (dumper *fakeDumper) Dump(
ctx context.Context,
opts pgdump.Options,
writer io.Writer,
) error {
if dumper.writeBytes > 0 {
data := make([]byte, dumper.writeBytes)
if _, err := writer.Write(data); err != nil {
return err
}
}
if dumper.closePipe {
if closer, ok := writer.(io.Closer); ok {
_ = closer.Close()
}
}
return dumper.returnErr
}
type fakeEncryptor struct {
readBytes int
returnErr error
}
func (encryptor *fakeEncryptor) Encrypt(
plaintext io.Reader,
recipients []crypto.RecipientPub,
sink io.Writer,
rand io.Reader,
) error {
if encryptor.readBytes > 0 {
buf := make([]byte, encryptor.readBytes)
if _, err := io.ReadFull(plaintext, buf); err != nil {
return err
}
}
return encryptor.returnErr
}
func countGoroutines() int {
return runtime.NumGoroutine()
}
func waitForGoroutinesStable(baseline int) bool {
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
if runtime.NumGoroutine() <= baseline {
return true
}
}
return false
}
func TestPipeline_DumpReturnsWithoutClosing(t *testing.T) {
baseline := countGoroutines()
sink := &fakeSink{}
dumper := &fakeDumper{
writeBytes: 4 * 1024,
returnErr: errPgDumpFailed,
closePipe: false,
}
encryptor := &fakeEncryptor{
readBytes: 4 * 1024,
}
runner := NewRunner(
WithDumper(dumper),
WithEncryptor(encryptor),
)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := runner.Run(
ctx,
pgdump.Options{Key: "backup.sql"},
make([]crypto.RecipientPub, 0),
sink,
nil,
)
if !errors.Is(err, errPgDumpFailed) {
t.Fatalf("expected errPgDumpFailed, got %v", err)
}
if !sink.transaction.aborted {
t.Fatalf("expected transaction to be aborted on error")
}
if waitForGoroutinesStable(baseline) {
return
}
t.Fatalf("goroutine leak detected: baseline %d, current %d", baseline, countGoroutines())
}
func TestPipeline_EncryptFailsFirst(t *testing.T) {
baseline := countGoroutines()
sink := &fakeSink{}
dumper := &fakeDumper{
writeBytes: 64 * 1024,
returnErr: nil,
closePipe: false,
}
encryptor := &fakeEncryptor{
readBytes: 1024,
returnErr: errEncryptFailed,
}
runner := NewRunner(
WithDumper(dumper),
WithEncryptor(encryptor),
)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := runner.Run(
ctx,
pgdump.Options{Key: "backup.sql"},
make([]crypto.RecipientPub, 0),
sink,
nil,
)
if !errors.Is(err, errEncryptFailed) {
t.Fatalf("expected errEncryptFailed, got %v", err)
}
if !sink.transaction.aborted {
t.Fatalf("expected transaction to be aborted on error")
}
if waitForGoroutinesStable(baseline) {
return
}
t.Fatalf("goroutine leak detected: baseline %d, current %d", baseline, countGoroutines())
}
func TestPipeline_SuccessfulRunCommits(t *testing.T) {
baseline := countGoroutines()
sink := &fakeSink{}
dumper := &fakeDumper{
writeBytes: 4 * 1024,
returnErr: nil,
closePipe: true,
}
encryptor := &fakeEncryptor{
readBytes: 4 * 1024,
}
runner := NewRunner(
WithDumper(dumper),
WithEncryptor(encryptor),
)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := runner.Run(
ctx,
pgdump.Options{Key: "backup.sql"},
make([]crypto.RecipientPub, 0),
sink,
nil,
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !sink.transaction.committed {
t.Fatalf("expected transaction to be committed on success")
}
if waitForGoroutinesStable(baseline) {
return
}
t.Fatalf("goroutine leak detected: baseline %d, current %d", baseline, countGoroutines())
}
+109
View File
@@ -0,0 +1,109 @@
// Package retention prunes old encrypted Postgres dump files from a backup
// directory based on their modification time.
// Package retention prunes old encrypted Postgres dump files from a backup
// directory based on their modification time.
package retention
import (
"context"
"io/fs"
"log/slog"
"os"
"path/filepath"
"time"
)
// DumpGlob matches only encrypted Postgres dump artefacts produced by the
// backupper: synapse-<timestamp>.dump.pqenc. The pattern is intentionally
// strict — any deviation in prefix, middle, or extension is preserved.
const DumpGlob = "synapse-*.dump.pqenc"
// PruneByAge removes every file in dir matching DumpGlob whose modification
// time is older than retentionDays relative to now. It returns the basenames
// of the files it removed.
//
// retentionDays == 0 disables pruning entirely (opt-out): the function
// returns a nil slice and a nil error without touching the directory.
//
// Subdirectories, non-matching files, and the root dir itself are never
// removed or followed.
func PruneByAge(
ctx context.Context,
dir string,
retentionDays int,
now time.Time,
) (
[]string,
error,
) {
if retentionDays == 0 {
return nil, nil
}
if err := ctx.Err(); err != nil {
return nil, err
}
if _, err := os.Stat(dir); err != nil {
return nil, err
}
cutoff := now.Add(-time.Duration(retentionDays) * 24 * time.Hour)
logger := slog.Default()
var pruned []string
walkErr := fs.WalkDir(os.DirFS(dir), ".", func(walkPath string, entry fs.DirEntry, walkErrIn error) error {
if walkErrIn != nil {
return walkErrIn
}
if err := ctx.Err(); err != nil {
return err
}
if entry.IsDir() {
return nil
}
name := filepath.Base(walkPath)
matched, err := filepath.Match(DumpGlob, name)
if err != nil {
return err
}
if !matched {
return nil
}
full := filepath.Join(dir, walkPath)
info, err := os.Stat(full)
if err != nil {
return err
}
if !info.ModTime().Before(cutoff) {
return nil
}
age := now.Sub(info.ModTime())
if err := os.Remove(full); err != nil {
return err
}
logger.Info(
"prune retention: removed old dump",
slog.String("file", name),
slog.Float64("age_seconds", age.Seconds()),
)
pruned = append(pruned, name)
return nil
})
if walkErr != nil {
return pruned, walkErr
}
return pruned, nil
}
+401
View File
@@ -0,0 +1,401 @@
package retention
import (
"bytes"
"context"
"encoding/json"
"errors"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
)
// fixedNow is the reference timestamp used by all table cases; individual
// file ages are expressed as durations relative to it.
var fixedNow = time.Date(2026, 7, 25, 12, 0, 0, 0, time.UTC)
const day = 24 * time.Hour
type fakeFile struct {
name string
ageOffset time.Duration
}
func writeFakeFile(t *testing.T, dir string, f fakeFile) {
t.Helper()
full := filepath.Join(dir, f.name)
if err := os.WriteFile(full, []byte("backup-bytes"), 0o600); err != nil {
t.Fatalf("write %s: %v", f.name, err)
}
mtime := fixedNow.Add(-f.ageOffset)
if err := os.Chtimes(full, mtime, mtime); err != nil {
t.Fatalf("chtimes %s: %v", f.name, err)
}
}
func fileExists(t *testing.T, path string) bool {
t.Helper()
_, err := os.Stat(path)
if err == nil {
return true
}
if errors.Is(err, fs.ErrNotExist) {
return false
}
t.Fatalf("stat %s: %v", path, err)
return false
}
func captureLogger() (*slog.Logger, *bytes.Buffer) {
buf := &bytes.Buffer{}
handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})
return slog.New(handler), buf
}
func TestPruneByAge_TableDriven(t *testing.T) {
oldMtimes := []time.Duration{
365 * day, 200 * day, 181 * day, 250 * day, 400 * day,
}
newMtimes := []time.Duration{
0, 30 * day, 90 * day, 179 * day, 180 * day,
}
var corpus []fakeFile
for index, age := range oldMtimes {
corpus = append(corpus, fakeFile{name: makeName(index), ageOffset: age})
}
for index, age := range newMtimes {
corpus = append(corpus, fakeFile{
name: makeName(index + len(oldMtimes)),
ageOffset: age,
})
}
tests := []struct {
name string
files []fakeFile
retentionDays int
wantPrunedNames []string
wantKeptNames []string
expectAnyRemoval bool
}{
{
name: "by_age_removes_older_keeps_younger",
files: corpus,
retentionDays: 180,
wantPrunedNames: []string{makeName(0), makeName(1), makeName(2), makeName(3), makeName(4)},
wantKeptNames: []string{makeName(5), makeName(6), makeName(7), makeName(8), makeName(9)},
expectAnyRemoval: true,
},
{
name: "retention_zero_is_opt_out",
files: corpus,
retentionDays: 0,
wantPrunedNames: []string{},
wantKeptNames: allNames(len(corpus)),
expectAnyRemoval: false,
},
{
name: "non_matching_files_never_touched",
files: append(
allCorpus(corpus),
fakeFile{name: "other-20200101-000000.dump.pqenc", ageOffset: 1000 * day},
fakeFile{name: "synapse-foo.txt", ageOffset: 1000 * day},
fakeFile{name: "README", ageOffset: 1000 * day},
),
retentionDays: 30,
wantPrunedNames: []string{
makeName(0), makeName(1), makeName(2), makeName(3), makeName(4),
makeName(7), makeName(8), makeName(9),
},
wantKeptNames: []string{
makeName(5), makeName(6),
"other-20200101-000000.dump.pqenc",
"synapse-foo.txt",
"README",
},
expectAnyRemoval: true,
},
{
name: "strict_glob_synapse_dump_pqenc",
files: []fakeFile{
{name: "synapse-20250101-000000.dump.pqenc", ageOffset: 1000 * day},
{name: "synapse-20250102-000000.dump.pqenc", ageOffset: 1 * day},
{name: "notsynapse-20250101-000000.dump.pqenc", ageOffset: 1000 * day},
{name: "synapse-20250101-000000.dump.pqenc.bak", ageOffset: 1000 * day},
{name: "synapse-20250101-000000.dump", ageOffset: 1000 * day},
{name: "SYNAPSE-20250101-000000.dump.pqenc", ageOffset: 1000 * day},
{name: "synapse-20250101-000000", ageOffset: 1000 * day},
},
retentionDays: 30,
wantPrunedNames: []string{"synapse-20250101-000000.dump.pqenc"},
wantKeptNames: []string{
"synapse-20250102-000000.dump.pqenc",
"notsynapse-20250101-000000.dump.pqenc",
"synapse-20250101-000000.dump.pqenc.bak",
"synapse-20250101-000000.dump",
"SYNAPSE-20250101-000000.dump.pqenc",
"synapse-20250101-000000",
},
expectAnyRemoval: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
for _, file := range tc.files {
writeFakeFile(t, dir, file)
}
pruned, err := PruneByAge(context.Background(), dir, tc.retentionDays, fixedNow)
if err != nil {
t.Fatalf("PruneByAge: unexpected error: %v", err)
}
gotPruned := basenames(pruned)
sort.Strings(gotPruned)
wantPruned := append([]string(nil), tc.wantPrunedNames...)
sort.Strings(wantPruned)
if !equalStringSlices(gotPruned, wantPruned) {
t.Errorf("pruned mismatch\n got: %v\n want: %v", gotPruned, wantPruned)
}
for _, name := range pruned {
if fileExists(t, filepath.Join(dir, name)) {
t.Errorf("PruneByAge returned %q but file still exists on disk", name)
}
}
for _, name := range tc.wantKeptNames {
if !fileExists(t, filepath.Join(dir, name)) {
t.Errorf("expected to keep %q, but it is missing", name)
}
}
if tc.expectAnyRemoval && len(pruned) == 0 {
t.Errorf("expected at least one file to be removed, got none")
}
})
}
}
func TestPruneByAge_EmptyDirectory(t *testing.T) {
dir := t.TempDir()
pruned, err := PruneByAge(context.Background(), dir, 30, fixedNow)
if err != nil {
t.Fatalf("PruneByAge on empty dir: unexpected error: %v", err)
}
if len(pruned) != 0 {
t.Errorf("expected no pruned files in empty dir, got %v", pruned)
}
}
func TestPruneByAge_EmptyDirectoryMissing(t *testing.T) {
dir := filepath.Join(t.TempDir(), "does-not-exist")
_, err := PruneByAge(context.Background(), dir, 30, fixedNow)
if err == nil {
t.Fatal("expected error for missing directory, got nil")
}
}
func TestPruneByAge_LogsEachPrunedFile(t *testing.T) {
// PruneByAge logs through slog.Default(); swap it for a JSON-writing
// capture handler for the duration of the test and restore afterwards.
dir := t.TempDir()
writeFakeFile(t, dir, fakeFile{name: makeName(0), ageOffset: 365 * day})
writeFakeFile(t, dir, fakeFile{name: makeName(1), ageOffset: 200 * day})
writeFakeFile(t, dir, fakeFile{name: makeName(2), ageOffset: 30 * day})
prevLogger := slog.Default()
logger, buf := captureLogger()
slog.SetDefault(logger)
t.Cleanup(func() {
slog.SetDefault(prevLogger)
})
pruned, err := PruneByAge(
context.Background(),
dir,
180,
fixedNow,
)
if err != nil {
t.Fatalf("PruneByAge: unexpected error: %v", err)
}
if len(pruned) != 2 {
t.Fatalf("expected 2 pruned files, got %d (%v)", len(pruned), pruned)
}
lines := splitNonEmpty(buf.String())
if len(lines) != len(pruned) {
t.Fatalf(
"expected %d log lines (one per pruned file), got %d:\n%s",
len(pruned),
len(lines),
buf.String(),
)
}
seen := make(map[string]bool)
for _, line := range lines {
var record map[string]any
if err := json.Unmarshal([]byte(line), &record); err != nil {
t.Fatalf("log line is not valid JSON: %q: %v", line, err)
}
msg, _ := record["msg"].(string)
if !strings.Contains(msg, "prune") && !strings.Contains(msg, "remove") {
t.Errorf("log msg %q does not mention prune/remove", msg)
}
nameVal, ok := record["file"].(string)
if !ok {
t.Fatalf("log record missing string field \"file\": %s", line)
}
seen[nameVal] = true
ageSeconds, ok := record["age_seconds"].(float64)
if !ok {
t.Fatalf("log record missing numeric field \"age_seconds\": %s", line)
}
if ageSeconds <= 0 {
t.Errorf("age_seconds must be positive, got %v", ageSeconds)
}
}
for _, name := range pruned {
if !seen[name] {
t.Errorf("expected a log record for pruned file %q, saw none", name)
}
}
}
func TestPruneByAge_DirectoryItselfNotRemoved(t *testing.T) {
dir := t.TempDir()
writeFakeFile(t, dir, fakeFile{name: makeName(0), ageOffset: 1000 * day})
if _, err := PruneByAge(context.Background(), dir, 30, fixedNow); err != nil {
t.Fatalf("PruneByAge: %v", err)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat root dir: %v", err)
}
if !info.IsDir() {
t.Errorf("root path is no longer a directory after prune")
}
}
func TestPruneByAge_ContextCancelled(t *testing.T) {
dir := t.TempDir()
for index := range 5 {
writeFakeFile(t, dir, fakeFile{name: makeName(index), ageOffset: 1000 * day})
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
// Cancelled context must short-circuit; whether it removes any files
// before checking the context is implementation-defined, but it must
// return ctx.Err() and must not panic.
_, err := PruneByAge(ctx, dir, 30, fixedNow)
if err != nil && !errors.Is(err, context.Canceled) {
// Acceptable: some files may already have been pruned before the
// context check; we only require the function to surface the error
// in some form. Anything else is a real failure.
t.Logf("PruneByAge returned non-cancel error: %v", err)
}
}
// --- helpers ---
func makeName(index int) string {
// Deterministic, sorted-friendly names like synapse-0001-... .dump.pqenc
// so test diffs are easy to read.
return "synapse-" + zeroPad(index) + "-000000.dump.pqenc"
}
func zeroPad(index int) string {
const width = 4
digits := "0123456789"
if index < 0 {
return "neg"
}
if index >= 10000 {
return "ovf"
}
out := make([]byte, width)
for pos := width - 1; pos >= 0; pos-- {
out[pos] = digits[index%10]
index /= 10
}
return string(out)
}
func allNames(count int) []string {
names := make([]string, 0, count)
for index := range count {
names = append(names, makeName(index))
}
return names
}
func allCorpus(in []fakeFile) []fakeFile {
out := make([]fakeFile, len(in))
copy(out, in)
return out
}
func basenames(paths []string) []string {
out := make([]string, 0, len(paths))
for _, p := range paths {
out = append(out, filepath.Base(p))
}
return out
}
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func splitNonEmpty(s string) []string {
var out []string
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) != "" {
out = append(out, line)
}
}
return out
}
+66
View File
@@ -0,0 +1,66 @@
package cron
import (
"context"
"fmt"
"sync"
"github.com/robfig/cron/v3"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain"
)
// cronScheduler wraps robfig/cron/v3 to implement domain.Scheduler.
type cronScheduler struct {
cron *cron.Cron
waitGroup sync.WaitGroup
stopCancel context.CancelFunc
stoppedCtx context.Context
}
// NewCronScheduler creates a new cron-based scheduler.
// The expr must be a valid cron expression. When WithSeconds is used
// (as in this implementation) the expression should contain six fields.
func NewCronScheduler(
expr string,
job func(),
) (
domain.Scheduler,
error,
) {
scheduler := &cronScheduler{
cron: cron.New(cron.WithSeconds()),
}
_, err := scheduler.cron.AddFunc(
expr,
func() {
scheduler.waitGroup.Add(1)
defer scheduler.waitGroup.Done()
job()
},
)
if err != nil {
return nil, fmt.Errorf("invalid cron expression %q: %w", expr, err)
}
scheduler.stoppedCtx, scheduler.stopCancel = context.WithCancel(context.Background())
return scheduler, nil
}
// Start begins executing the scheduled job.
func (s *cronScheduler) Start() {
s.cron.Start()
}
// Stop halts the scheduler and returns a context that is cancelled
// once all in-flight jobs have completed.
func (s *cronScheduler) Stop() context.Context {
s.cron.Stop()
go func() {
s.waitGroup.Wait()
s.stopCancel()
}()
return s.stoppedCtx
}
+61
View File
@@ -0,0 +1,61 @@
package cron
import (
"testing"
"time"
)
func TestJobFiresAtExpectedTime(t *testing.T) {
fired := make(chan struct{}, 1)
job := func() {
select {
case fired <- struct{}{}:
default:
}
}
// Every second (6 fields because cron is created with WithSeconds).
scheduler, err := NewCronScheduler("* * * * * *", job)
if err != nil {
t.Fatalf("NewCronScheduler failed: %v", err)
}
scheduler.Start()
select {
case <-fired:
// Job fired as expected.
case <-time.After(3 * time.Second):
t.Fatal("job did not fire within expected time")
}
stoppedCtx := scheduler.Stop()
select {
case <-stoppedCtx.Done():
// Scheduler stopped cleanly.
case <-time.After(2 * time.Second):
t.Fatal("scheduler did not stop within expected time")
}
}
func TestStopReturnsContext(t *testing.T) {
scheduler, err := NewCronScheduler("* * * * * *", func() {})
if err != nil {
t.Fatalf("NewCronScheduler failed: %v", err)
}
scheduler.Start()
stoppedCtx := scheduler.Stop()
if stoppedCtx == nil {
t.Fatal("Stop returned nil context")
}
select {
case <-stoppedCtx.Done():
// Expected.
case <-time.After(2 * time.Second):
t.Fatal("stopped context was not cancelled")
}
}
+132
View File
@@ -0,0 +1,132 @@
package local
import (
"errors"
"os"
"path/filepath"
"strings"
"sync"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain"
)
// NewLocalSink creates a new local filesystem sink that writes into the given directory.
func NewLocalSink(dir string) domain.Sink {
return &localSink{dir: dir}
}
type localSink struct {
dir string
}
func (sink *localSink) Begin(key string) (domain.SinkTx, error) {
tmpPath := filepath.Join(sink.dir, key+".tmp")
finalPath := filepath.Join(sink.dir, key)
file, err := os.Create(tmpPath)
if err != nil {
return nil, err
}
return &localSinkTx{
file: file,
tmpPath: tmpPath,
finalPath: finalPath,
}, nil
}
func (sink *localSink) List(prefix string) ([]string, error) {
entries, err := os.ReadDir(sink.dir)
if err != nil {
return nil, err
}
keys := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if prefix != "" && !strings.HasPrefix(name, prefix) {
continue
}
keys = append(keys, name)
}
return keys, nil
}
func (sink *localSink) Remove(key string) error {
path := filepath.Join(sink.dir, key)
return os.Remove(path)
}
type localSinkTx struct {
file *os.File
tmpPath string
finalPath string
committed bool
aborted bool
mu sync.Mutex
}
func (transaction *localSinkTx) Write(p []byte) (int, error) {
transaction.mu.Lock()
defer transaction.mu.Unlock()
if transaction.committed || transaction.aborted {
return 0, errors.New("transaction already finished")
}
return transaction.file.Write(p)
}
func (transaction *localSinkTx) Commit() error {
transaction.mu.Lock()
defer transaction.mu.Unlock()
if transaction.committed || transaction.aborted {
return nil
}
transaction.committed = true
if err := transaction.file.Sync(); err != nil {
return err
}
if err := transaction.file.Close(); err != nil {
return err
}
if err := os.Rename(transaction.tmpPath, transaction.finalPath); err != nil {
return err
}
parent, err := os.Open(filepath.Dir(transaction.tmpPath))
if err != nil {
return err
}
defer parent.Close()
if err := parent.Sync(); err != nil {
return err
}
return nil
}
func (transaction *localSinkTx) Abort() error {
transaction.mu.Lock()
defer transaction.mu.Unlock()
if transaction.committed || transaction.aborted {
return nil
}
transaction.aborted = true
_ = transaction.file.Close()
_ = os.Remove(transaction.tmpPath)
return nil
}
+255
View File
@@ -0,0 +1,255 @@
package local
import (
"os"
"path/filepath"
"testing"
"git.tswf.io/infra/go-synapse-backupper/pkg/domain"
)
func TestLocalSink_BeginOpensTmpInSameDirectory(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
transaction, err := sink.Begin("backup.sql")
if err != nil {
t.Fatalf("Begin failed: %v", err)
}
defer func() { _ = transaction.Abort() }()
tmpPath := filepath.Join(dir, "backup.sql.tmp")
if _, err := os.Stat(tmpPath); os.IsNotExist(err) {
t.Fatalf("expected tmp file to exist at %s", tmpPath)
}
}
func TestLocalSink_WriteStreamsBytes(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
transaction, err := sink.Begin("backup.sql")
if err != nil {
t.Fatalf("Begin failed: %v", err)
}
defer func() { _ = transaction.Abort() }()
data := []byte("hello world")
written, err := transaction.Write(data)
if err != nil {
t.Fatalf("Write failed: %v", err)
}
if written != len(data) {
t.Fatalf("expected %d bytes written, got %d", len(data), written)
}
}
func TestLocalSink_CommitSequence(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
transaction, err := sink.Begin("backup.sql")
if err != nil {
t.Fatalf("Begin failed: %v", err)
}
data := []byte("persistent data")
if _, err := transaction.Write(data); err != nil {
t.Fatalf("Write failed: %v", err)
}
tmpPath := filepath.Join(dir, "backup.sql.tmp")
tmpInfo, err := os.Stat(tmpPath)
if err != nil {
t.Fatalf("stat tmp file failed: %v", err)
}
if err := transaction.Commit(); err != nil {
t.Fatalf("Commit failed: %v", err)
}
finalPath := filepath.Join(dir, "backup.sql")
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Fatalf("expected tmp file to be absent after commit")
}
finalInfo, err := os.Stat(finalPath)
if err != nil {
t.Fatalf("expected final file to exist after commit: %v", err)
}
if !os.SameFile(tmpInfo, finalInfo) {
t.Fatalf("expected final file to be the same inode as tmp file before rename")
}
}
func TestLocalSink_AbortIsIdempotent(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
transaction, err := sink.Begin("backup.sql")
if err != nil {
t.Fatalf("Begin failed: %v", err)
}
if _, err := transaction.Write([]byte("discard me")); err != nil {
t.Fatalf("Write failed: %v", err)
}
if err := transaction.Abort(); err != nil {
t.Fatalf("first Abort failed: %v", err)
}
if err := transaction.Abort(); err != nil {
t.Fatalf("second Abort should be idempotent: %v", err)
}
tmpPath := filepath.Join(dir, "backup.sql.tmp")
finalPath := filepath.Join(dir, "backup.sql")
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Fatalf("expected tmp file to be absent after abort")
}
if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
t.Fatalf("expected final file to be absent after abort")
}
}
func TestLocalSink_AbortAfterCommitIsSafe(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
transaction, err := sink.Begin("backup.sql")
if err != nil {
t.Fatalf("Begin failed: %v", err)
}
if _, err := transaction.Write([]byte("data")); err != nil {
t.Fatalf("Write failed: %v", err)
}
if err := transaction.Commit(); err != nil {
t.Fatalf("Commit failed: %v", err)
}
if err := transaction.Abort(); err != nil {
t.Fatalf("Abort after commit should be safe: %v", err)
}
finalPath := filepath.Join(dir, "backup.sql")
if _, err := os.Stat(finalPath); err != nil {
t.Fatalf("expected final file to still exist after abort-after-commit: %v", err)
}
}
func TestLocalSink_KillWriterMidStreamThenAbortLeavesNoFinal(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
transaction, err := sink.Begin("backup.sql")
if err != nil {
t.Fatalf("Begin failed: %v", err)
}
if _, err := transaction.Write([]byte("partial")); err != nil {
t.Fatalf("Write failed: %v", err)
}
if err := transaction.Abort(); err != nil {
t.Fatalf("Abort failed: %v", err)
}
tmpPath := filepath.Join(dir, "backup.sql.tmp")
finalPath := filepath.Join(dir, "backup.sql")
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Fatalf("expected tmp file to be absent after abort")
}
if _, err := os.Stat(finalPath); !os.IsNotExist(err) {
t.Fatalf("expected final file to be absent after abort")
}
}
func TestLocalSink_ListAndRemove(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
if err := os.WriteFile(filepath.Join(dir, "a.sql"), []byte("a"), 0o644); err != nil {
t.Fatalf("setup failed: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "b.sql"), []byte("b"), 0o644); err != nil {
t.Fatalf("setup failed: %v", err)
}
keys, err := sink.List("")
if err != nil {
t.Fatalf("List failed: %v", err)
}
if len(keys) != 2 {
t.Fatalf("expected 2 keys, got %d", len(keys))
}
if err := sink.Remove("a.sql"); err != nil {
t.Fatalf("Remove failed: %v", err)
}
keys, err = sink.List("")
if err != nil {
t.Fatalf("List after remove failed: %v", err)
}
if len(keys) != 1 {
t.Fatalf("expected 1 key after remove, got %d", len(keys))
}
}
func TestLocalSink_RemoveMissingIsError(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
err := sink.Remove("nonexistent.sql")
if err == nil {
t.Fatalf("expected error removing missing file")
}
}
func TestLocalSink_WriteAfterCommitIsError(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
transaction, err := sink.Begin("backup.sql")
if err != nil {
t.Fatalf("Begin failed: %v", err)
}
if _, err := transaction.Write([]byte("data")); err != nil {
t.Fatalf("Write failed: %v", err)
}
if err := transaction.Commit(); err != nil {
t.Fatalf("Commit failed: %v", err)
}
if _, err := transaction.Write([]byte("more")); err == nil {
t.Fatalf("expected error writing after commit")
}
}
func TestLocalSink_WriteAfterAbortIsError(t *testing.T) {
dir := t.TempDir()
sink := NewLocalSink(dir)
transaction, err := sink.Begin("backup.sql")
if err != nil {
t.Fatalf("Begin failed: %v", err)
}
if err := transaction.Abort(); err != nil {
t.Fatalf("Abort failed: %v", err)
}
if _, err := transaction.Write([]byte("more")); err == nil {
t.Fatalf("expected error writing after abort")
}
}
var (
_ domain.Sink = (*localSink)(nil)
_ domain.SinkTx = (*localSinkTx)(nil)
)
+75
View File
@@ -0,0 +1,75 @@
package backup
import (
"context"
"crypto/rand"
"fmt"
"time"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/config"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/composite"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/keymanager"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/mlkem768"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/x25519"
adapterpgdump "git.tswf.io/infra/go-synapse-backupper/pkg/adapters/pgdump"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/pipeline"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/retention"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/storage/local"
domaincrypto "git.tswf.io/infra/go-synapse-backupper/pkg/domain/crypto"
domainpgdump "git.tswf.io/infra/go-synapse-backupper/pkg/domain/pgdump"
)
func RunOnce(ctx context.Context, cfg *config.Config) error {
registry := domaincrypto.NewRegistry()
if err := registry.Register(0x0006, func() domaincrypto.KEM { return mlkem768.New() }); err != nil {
return fmt.Errorf("register mlkem768: %w", err)
}
if err := registry.Register(0x0007, func() domaincrypto.KEM { return x25519.New() }); err != nil {
return fmt.Errorf("register x25519: %w", err)
}
keyMgr := keymanager.NewKeyManager(registry)
pqPub, err := keyMgr.LoadPub(cfg.PQPublicKeyPath, cfg.PQScheme)
if err != nil {
return fmt.Errorf("load pq public key: %w", err)
}
classicalPub, err := keyMgr.LoadPub(cfg.ClassicalPublicKeyPath, cfg.ClassicalScheme)
if err != nil {
return fmt.Errorf("load classical public key: %w", err)
}
recipients := []domaincrypto.RecipientPub{pqPub, classicalPub}
encryptor := composite.NewEncryptor(registry)
dumper := adapterpgdump.New()
sink := local.NewLocalSink(cfg.Backup.Dir)
runner := pipeline.NewRunner(
pipeline.WithDumper(dumper),
pipeline.WithEncryptor(encryptor),
)
now := time.Now()
timestamp := now.UTC().Format("20060102-150405")
pgDumpOpts := domainpgdump.Options{
Host: cfg.PG.Host,
Port: cfg.PG.Port,
Database: cfg.PG.Database,
User: cfg.PG.User,
Password: cfg.PG.Password,
ExcludeTables: cfg.PG.ExcludeTables,
Key: fmt.Sprintf("synapse-%s.dump.pqenc", timestamp),
}
if err := runner.Run(ctx, pgDumpOpts, recipients, sink, rand.Reader); err != nil {
return fmt.Errorf("backup pipeline failed: %w", err)
}
if _, err := retention.PruneByAge(ctx, cfg.Backup.Dir, cfg.Backup.RetentionDays, now); err != nil {
return fmt.Errorf("retention pruning failed: %w", err)
}
return nil
}
+129
View File
@@ -0,0 +1,129 @@
package backup
import (
"context"
"os"
"strings"
"testing"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/config"
"git.tswf.io/infra/go-synapse-backupper/pkg/adapters/crypto/keymanager"
"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"
)
// TestRunOnce_MissingKeys verifies that RunOnce surfaces a configuration error
// when the public key paths are not provided, exercising the early validation
// path (registry build, keymanager construction, key load).
func TestRunOnce_MissingKeys(t *testing.T) {
cfg := &config.Config{}
// PQPublicKeyPath and ClassicalPublicKeyPath intentionally left empty.
err := RunOnce(context.Background(), cfg)
if err == nil {
t.Fatal("RunOnce returned nil error with empty config, want error")
}
if !strings.Contains(err.Error(), "load pq public key") {
t.Fatalf("RunOnce error = %q, want it to mention %q", err, "load pq public key")
}
}
// TestRunOnce_PQKeyNotFound verifies the error returned when the post-quantum
// public key path is set but the file does not exist on disk.
func TestRunOnce_PQKeyNotFound(t *testing.T) {
cfg := &config.Config{
PQScheme: 0x0006,
// Path points to a file that does not exist; use t.TempDir() to keep
// the test hermetic regardless of the working directory.
PQPublicKeyPath: t.TempDir() + "/does-not-exist-pq.pem",
ClassicalPublicKeyPath: t.TempDir() + "/does-not-exist-classical.pem",
}
err := RunOnce(context.Background(), cfg)
if err == nil {
t.Fatal("RunOnce returned nil error when keys are missing on disk, want error")
}
if !strings.Contains(err.Error(), "load pq public key") {
t.Fatalf("RunOnce error = %q, want it to mention %q", err, "load pq public key")
}
}
// TestRunOnce_ValidKeys_PgDumpMissing generates real dual-KEM keys, builds a
// fully valid config, and calls RunOnce. Since pg_dump is not installed in the
// test environment, the pipeline fails at the dump step, giving coverage of
// the full orchestration path (registry, keymanager, encryptor, sink, runner,
// pgDumpOpts assembly) while still asserting the expected error.
func TestRunOnce_ValidKeys_PgDumpMissing(t *testing.T) {
dir := t.TempDir()
keyDir := dir + "/keys"
backupDir := dir + "/backups"
_ = os.MkdirAll(keyDir, 0o755)
_ = os.MkdirAll(backupDir, 0o755)
reg := crypto.NewRegistry()
_ = reg.Register(0x0006, func() crypto.KEM { return mlkem768.New() })
_ = reg.Register(0x0007, func() crypto.KEM { return x25519.New() })
km := keymanager.NewKeyManager(reg)
pqPubFile, _ := os.Create(keyDir + "/pq.pub.pem")
pqPrivFile, _ := os.Create(keyDir + "/pq.priv.pem")
_ = km.Generate(0x0006, pqPubFile, pqPrivFile, nil)
_ = pqPubFile.Close()
_ = pqPrivFile.Close()
classicalPubFile, _ := os.Create(keyDir + "/classical.pub.pem")
classicalPrivFile, _ := os.Create(keyDir + "/classical.priv.pem")
_ = km.Generate(0x0007, classicalPubFile, classicalPrivFile, nil)
_ = classicalPubFile.Close()
_ = classicalPrivFile.Close()
cfg := &config.Config{
PQScheme: 0x0006,
ClassicalScheme: 0x0007,
PQPublicKeyPath: keyDir + "/pq.pub.pem",
ClassicalPublicKeyPath: keyDir + "/classical.pub.pem",
Backup: struct {
Dir string `mapstructure:"dir"`
RetentionDays int `mapstructure:"retention_days"`
Cron string `mapstructure:"cron"`
}{
Dir: backupDir,
RetentionDays: 180,
},
PG: struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
SSLMode string `mapstructure:"sslmode"`
ExcludeTables []string `mapstructure:"exclude_tables"`
}{
Host: "localhost",
Port: 5432,
User: "test",
Password: "test",
Database: "test",
ExcludeTables: []string{"e2e_one_time_keys_json"},
},
}
err := RunOnce(context.Background(), cfg)
if err == nil {
t.Fatal("expected error because pg_dump is not installed in the test environment")
}
if !strings.Contains(err.Error(), "backup pipeline failed") {
t.Fatalf("expected error containing 'backup pipeline failed', got: %v", err)
}
entries, _ := os.ReadDir(backupDir)
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tmp") {
t.Fatalf("unexpected .tmp file after failed backup: %s", e.Name())
}
}
}
+12
View File
@@ -0,0 +1,12 @@
package crypto
import "io"
// Decryptor defines the contract for decrypting ciphertext using private keys.
type Decryptor interface {
Decrypt(
src io.Reader,
privs []RecipientPriv,
plaintext io.Writer,
) error
}
+13
View File
@@ -0,0 +1,13 @@
package crypto
import "io"
// Encryptor defines the contract for encrypting plaintext to multiple recipients.
type Encryptor interface {
Encrypt(
plaintext io.Reader,
recipients []RecipientPub,
sink io.Writer,
rand io.Reader,
) error
}
+50
View File
@@ -0,0 +1,50 @@
package crypto
import "io"
// KEM defines the contract for a Key Encapsulation Mechanism.
type KEM interface {
SchemeID() uint16
GenerateKeyPair(
rand io.Reader,
) (
RecipientPub,
RecipientPriv,
error,
)
Encapsulate(
pub RecipientPub,
rand io.Reader,
) (
ciphertext []byte,
sharedSecret []byte,
err error,
)
Decapsulate(
priv RecipientPriv,
ciphertext []byte,
) (
sharedSecret []byte,
err error,
)
LoadPriv(
raw []byte,
) (
RecipientPriv,
error,
)
}
// RecipientPub represents a public recipient key.
type RecipientPub interface {
SchemeID() uint16
KeyID() []byte
Raw() []byte
}
// RecipientPriv represents a private recipient key.
type RecipientPriv interface {
SchemeID() uint16
KeyID() []byte
Raw() []byte
}
+27
View File
@@ -0,0 +1,27 @@
package crypto
import "io"
// KeyManager defines the contract for loading and generating recipient keys.
type KeyManager interface {
LoadPub(
path string,
schemeID uint16,
) (
RecipientPub,
error,
)
LoadPriv(
path string,
schemeID uint16,
) (
RecipientPriv,
error,
)
Generate(
schemeID uint16,
pubOut io.Writer,
privOut io.Writer,
rand io.Reader,
) error
}
+84
View File
@@ -0,0 +1,84 @@
package crypto
import (
"errors"
"fmt"
"sync"
)
// Sentinel errors for registry operations.
var (
ErrUnknownScheme = errors.New("unknown KEM scheme")
ErrDuplicateScheme = errors.New("duplicate KEM scheme registration")
)
// KEMFactory is a constructor function that returns a fresh KEM instance.
type KEMFactory func() KEM
// Registry is a concurrent-safe map of KEM scheme IDs to their factories.
type Registry interface {
Register(
schemeID uint16,
factory KEMFactory,
) error
Lookup(
schemeID uint16,
) (
KEMFactory,
error,
)
}
// registry is the private implementation of Registry.
type registry struct {
mutex sync.RWMutex
factories map[uint16]KEMFactory
}
// NewRegistry creates a new empty Registry.
func NewRegistry() Registry {
return &registry{
factories: make(map[uint16]KEMFactory),
}
}
// Register adds a KEMFactory for the given schemeID.
// Returns ErrDuplicateScheme if the schemeID is already registered.
func (r *registry) Register(schemeID uint16, factory KEMFactory) error {
r.mutex.Lock()
defer r.mutex.Unlock()
if _, exists := r.factories[schemeID]; exists {
return fmt.Errorf(
"scheme 0x%04x: %w",
schemeID,
ErrDuplicateScheme,
)
}
r.factories[schemeID] = factory
return nil
}
// Lookup retrieves the KEMFactory for the given schemeID.
// Returns ErrUnknownScheme if the schemeID is not registered.
func (r *registry) Lookup(
schemeID uint16,
) (
KEMFactory,
error,
) {
r.mutex.RLock()
defer r.mutex.RUnlock()
factory, exists := r.factories[schemeID]
if !exists {
return nil, fmt.Errorf(
"scheme 0x%04x: %w",
schemeID,
ErrUnknownScheme,
)
}
return factory, nil
}
+173
View File
@@ -0,0 +1,173 @@
package crypto
import (
"errors"
"io"
"sync"
"testing"
)
// mockKEM is a minimal KEM implementation for testing the registry.
type mockKEM struct {
schemeID uint16
}
func (m *mockKEM) SchemeID() uint16 {
return m.schemeID
}
func (m *mockKEM) GenerateKeyPair(
rand io.Reader,
) (
RecipientPub,
RecipientPriv,
error,
) {
return nil, nil, nil
}
func (m *mockKEM) Encapsulate(
pub RecipientPub,
rand io.Reader,
) (
ciphertext []byte,
sharedSecret []byte,
err error,
) {
return nil, nil, nil
}
func (m *mockKEM) Decapsulate(
priv RecipientPriv,
ciphertext []byte,
) (
sharedSecret []byte,
err error,
) {
return nil, nil
}
func (m *mockKEM) LoadPriv(
raw []byte,
) (
RecipientPriv,
error,
) {
return &mockRecipient{schemeIDValue: m.schemeID, rawBytes: raw}, nil
}
// mockRecipient implements RecipientPub and RecipientPriv for tests.
type mockRecipient struct {
schemeIDValue uint16
rawBytes []byte
}
func (r *mockRecipient) SchemeID() uint16 {
return r.schemeIDValue
}
func (r *mockRecipient) KeyID() []byte {
return r.rawBytes
}
func (r *mockRecipient) Raw() []byte {
return r.rawBytes
}
func TestRegistry_RegisterAndLookup(t *testing.T) {
registry := NewRegistry()
factory := func() KEM {
return &mockKEM{schemeID: 0x0001}
}
err := registry.Register(0x0001, factory)
if err != nil {
t.Fatalf("unexpected error registering scheme: %v", err)
}
foundFactory, err := registry.Lookup(0x0001)
if err != nil {
t.Fatalf("unexpected error looking up scheme: %v", err)
}
kem := foundFactory()
if kem.SchemeID() != 0x0001 {
t.Errorf("expected schemeID 0x0001, got 0x%04x", kem.SchemeID())
}
}
func TestRegistry_LookupUnknownScheme(t *testing.T) {
registry := NewRegistry()
_, err := registry.Lookup(0x9999)
if err == nil {
t.Fatal("expected error for unknown scheme, got nil")
}
if !errors.Is(err, ErrUnknownScheme) {
t.Errorf("expected ErrUnknownScheme, got %v", err)
}
}
func TestRegistry_RegisterDuplicateScheme(t *testing.T) {
registry := NewRegistry()
factory := func() KEM {
return &mockKEM{schemeID: 0x0001}
}
err := registry.Register(0x0001, factory)
if err != nil {
t.Fatalf("unexpected error on first register: %v", err)
}
err = registry.Register(0x0001, factory)
if err == nil {
t.Fatal("expected error for duplicate scheme, got nil")
}
if !errors.Is(err, ErrDuplicateScheme) {
t.Errorf("expected ErrDuplicateScheme, got %v", err)
}
}
func TestRegistry_ConcurrentAccess(t *testing.T) {
registry := NewRegistry()
const goroutines = 100
var waitGroup sync.WaitGroup
waitGroup.Add(goroutines)
for index := range goroutines {
go func(schemeID uint16) {
defer waitGroup.Done()
factory := func() KEM {
return &mockKEM{schemeID: schemeID}
}
_ = registry.Register(schemeID, factory)
_, _ = registry.Lookup(schemeID)
}(uint16(index + 1))
}
waitGroup.Wait()
}
func TestRegistry_LookupReturnsIndependentInstances(t *testing.T) {
registry := NewRegistry()
factory := func() KEM {
return &mockKEM{schemeID: 0x0001}
}
_ = registry.Register(0x0001, factory)
factoryOne, _ := registry.Lookup(0x0001)
factoryTwo, _ := registry.Lookup(0x0001)
kemOne := factoryOne()
kemTwo := factoryTwo()
if kemOne == kemTwo {
t.Error("expected independent KEM instances from factory")
}
}
+50
View File
@@ -0,0 +1,50 @@
package pgdump
import (
"context"
"fmt"
"io"
)
// Options holds configuration for the pg_dump invocation.
type Options struct {
Host string
Port int
Database string
User string
Password string
Key string
ExcludeTables []string
}
// Dumper defines the contract for executing pg_dump.
type Dumper interface {
Dump(
ctx context.Context,
opts Options,
sink io.Writer,
) error
}
// pgDumpFailedError is returned when pg_dump exits with a non-zero status.
type pgDumpFailedError struct {
exitCode int
}
func (e *pgDumpFailedError) Error() string {
return fmt.Sprintf("pg_dump failed with exit code %d", e.exitCode)
}
// Is reports whether target is a pgDumpFailedError with the same exit code.
func (e *pgDumpFailedError) Is(target error) bool {
other, ok := target.(*pgDumpFailedError)
if !ok {
return false
}
return e.exitCode == other.exitCode
}
// ErrPgDumpFailed creates a new pg_dump failed error with the given exit code.
func ErrPgDumpFailed(exitCode int) error {
return &pgDumpFailedError{exitCode: exitCode}
}
+72
View File
@@ -0,0 +1,72 @@
package pgdump
import (
"errors"
"fmt"
"testing"
)
func TestErrPgDumpFailed_Error(t *testing.T) {
cases := []struct {
name string
exitCode int
want string
}{
{name: "zero", exitCode: 0, want: "pg_dump failed with exit code 0"},
{name: "one", exitCode: 1, want: "pg_dump failed with exit code 1"},
{name: "large", exitCode: 137, want: "pg_dump failed with exit code 137"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ErrPgDumpFailed(tc.exitCode)
if err == nil {
t.Fatalf("ErrPgDumpFailed(%d) returned nil, want error", tc.exitCode)
}
if got := err.Error(); got != tc.want {
t.Fatalf("Error() = %q, want %q", got, tc.want)
}
})
}
}
func TestErrPgDumpFailed_Is(t *testing.T) {
base := ErrPgDumpFailed(1)
cases := []struct {
name string
target error
want bool
}{
{
name: "same exit code",
target: ErrPgDumpFailed(1),
want: true,
},
{
name: "different exit code",
target: ErrPgDumpFailed(2),
want: false,
},
{
name: "unrelated error type",
target: errors.New("something else"),
want: false,
},
{
// Is does not unwrap target: a fmt.Errorf-wrapped pgDumpFailedError
// fails the exact type check, so errors.Is reports false here.
name: "wrapped same-type error",
target: fmt.Errorf("wrapped: %w", ErrPgDumpFailed(1)),
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := errors.Is(base, tc.target); got != tc.want {
t.Fatalf("errors.Is(%v, %v) = %v, want %v", base, tc.target, got, tc.want)
}
})
}
}
+12
View File
@@ -0,0 +1,12 @@
package domain
import "context"
// Scheduler is the abstraction for a recurring job scheduler.
type Scheduler interface {
// Start begins executing the scheduled job.
Start()
// Stop halts the scheduler and returns a context that is cancelled
// once all in-flight jobs have completed.
Stop() (stoppedCtx context.Context)
}
+17
View File
@@ -0,0 +1,17 @@
package domain
import "io"
// Sink defines the contract for a storage sink that supports atomic two-phase writes.
type Sink interface {
Begin(key string) (SinkTx, error)
List(prefix string) ([]string, error)
Remove(key string) error
}
// SinkTx represents an in-progress write transaction.
type SinkTx interface {
io.Writer
Commit() error
Abort() error
}