// Package crypto provides key management and encryption helpers for MCIAS. // // Security design: // - All random material (keys, nonces, salts) comes from crypto/rand. // - AES-256-GCM is used for symmetric encryption; the 256-bit key size // provides 128-bit post-quantum security margin. // - Ed25519 is used for JWT signing; it has no key-size or parameter // malleability issues that affect RSA/ECDSA. // - The master key KDF uses Argon2id (separate parameterisation from // password hashing) to derive a 256-bit key from a passphrase. package crypto import ( "crypto/aes" "crypto/cipher" "crypto/ed25519" "crypto/rand" "crypto/x509" "encoding/pem" "errors" "fmt" "io" "golang.org/x/crypto/argon2" ) const ( // aesKeySize is 32 bytes = 256-bit AES key. aesKeySize = 32 // gcmNonceSize is the standard 96-bit GCM nonce. gcmNonceSize = 12 // kdfSaltSize is 32 bytes for the Argon2id salt. kdfSaltSize = 32 // kdfTime and kdfMemory are the Argon2id parameters used for master key // derivation. These are separate from password hashing parameters and are // chosen to be expensive enough to resist offline attack on the passphrase. // Security: OWASP 2023 recommends time=2, memory=64MiB as minimum. // We use time=3, memory=64MiB, threads=4 as the operational default for // password hashing (configured in mcias.toml). // For master key derivation, we hardcode time=3, memory=128MiB, threads=4 // since this only runs at server startup. kdfTime = 3 kdfMemory = 128 * 1024 // 128 MiB in KiB kdfThreads = 4 ) // GenerateEd25519KeyPair generates a new Ed25519 key pair using crypto/rand. // Security: Ed25519 key generation is deterministic given the seed; crypto/rand // provides the cryptographically-secure seed. func GenerateEd25519KeyPair() (ed25519.PublicKey, ed25519.PrivateKey, error) { pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, nil, fmt.Errorf("crypto: generate Ed25519 key pair: %w", err) } return pub, priv, nil } // MarshalPrivateKeyPEM encodes an Ed25519 private key as a PKCS#8 PEM block. func MarshalPrivateKeyPEM(key ed25519.PrivateKey) ([]byte, error) { der, err := x509.MarshalPKCS8PrivateKey(key) if err != nil { return nil, fmt.Errorf("crypto: marshal private key DER: %w", err) } return pem.EncodeToMemory(&pem.Block{ Type: "PRIVATE KEY", Bytes: der, }), nil } // ParsePrivateKeyPEM decodes a PKCS#8 PEM-encoded Ed25519 private key. // Returns an error if the PEM block is missing, malformed, or not an Ed25519 key. func ParsePrivateKeyPEM(pemData []byte) (ed25519.PrivateKey, error) { block, _ := pem.Decode(pemData) if block == nil { return nil, errors.New("crypto: no PEM block found") } if block.Type != "PRIVATE KEY" { return nil, fmt.Errorf("crypto: unexpected PEM block type %q, want %q", block.Type, "PRIVATE KEY") } key, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, fmt.Errorf("crypto: parse PKCS#8 private key: %w", err) } ed, ok := key.(ed25519.PrivateKey) if !ok { return nil, fmt.Errorf("crypto: PEM key is not Ed25519 (got %T)", key) } return ed, nil } // SealAESGCM encrypts plaintext with AES-256-GCM using key. // Returns ciphertext and nonce separately so both can be stored. // Security: A fresh random nonce is generated for every call. Nonce reuse // under the same key would break GCM's confidentiality and authentication // guarantees, so callers must never reuse nonces manually. func SealAESGCM(key, plaintext []byte) (ciphertext, nonce []byte, err error) { if len(key) != aesKeySize { return nil, nil, fmt.Errorf("crypto: AES-GCM key must be %d bytes, got %d", aesKeySize, len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, nil, fmt.Errorf("crypto: create AES cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, nil, fmt.Errorf("crypto: create GCM: %w", err) } nonce = make([]byte, gcmNonceSize) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, nil, fmt.Errorf("crypto: generate GCM nonce: %w", err) } ciphertext = gcm.Seal(nil, nonce, plaintext, nil) return ciphertext, nonce, nil } // OpenAESGCM decrypts and authenticates ciphertext encrypted with SealAESGCM. // Returns the plaintext, or an error if authentication fails (wrong key, tampered // ciphertext, or wrong nonce). func OpenAESGCM(key, nonce, ciphertext []byte) ([]byte, error) { if len(key) != aesKeySize { return nil, fmt.Errorf("crypto: AES-GCM key must be %d bytes, got %d", aesKeySize, len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("crypto: create AES cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("crypto: create GCM: %w", err) } plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { // Do not expose internal GCM error details; they could reveal key info. return nil, errors.New("crypto: AES-GCM authentication failed") } return plaintext, nil } // DeriveKey derives a 256-bit AES key from passphrase and salt using Argon2id. // The salt must be at least 16 bytes; use NewSalt to generate one. // Security: Argon2id is the OWASP-recommended KDF for key derivation from // passphrases. The parameters are hardcoded at compile time and exceed OWASP // minimums to resist offline dictionary attacks against the passphrase. func DeriveKey(passphrase string, salt []byte) ([]byte, error) { if len(salt) < 16 { return nil, fmt.Errorf("crypto: KDF salt must be at least 16 bytes, got %d", len(salt)) } if passphrase == "" { return nil, errors.New("crypto: passphrase must not be empty") } // argon2.IDKey returns keyLen bytes derived from the passphrase and salt. // Security: parameters are time=3, memory=128MiB, threads=4, keyLen=32. // These exceed OWASP 2023 minimums for key derivation. key := argon2.IDKey( []byte(passphrase), salt, kdfTime, kdfMemory, kdfThreads, aesKeySize, ) return key, nil } // NewSalt generates a cryptographically-random 32-byte KDF salt. func NewSalt() ([]byte, error) { salt := make([]byte, kdfSaltSize) if _, err := io.ReadFull(rand.Reader, salt); err != nil { return nil, fmt.Errorf("crypto: generate salt: %w", err) } return salt, nil } // RandomBytes returns n cryptographically-random bytes. func RandomBytes(n int) ([]byte, error) { b := make([]byte, n) if _, err := io.ReadFull(rand.Reader, b); err != nil { return nil, fmt.Errorf("crypto: read random bytes: %w", err) } return b, nil }