Files
mcias/internal/crypto/crypto.go
Kyle Isom f02eff21b4 Complete implementation: e2e tests, gofmt, hardening
- Add test/e2e: 11 end-to-end tests covering full login/logout,
  token renewal, admin account management, credential-never-in-response,
  unauthorised access, JWT alg confusion and alg:none attacks,
  revoked token rejection, system account token issuance,
  wrong-password vs unknown-user indistinguishability
- Apply gofmt to all source files (formatting only, no logic changes)
- Update .golangci.yaml for golangci-lint v2 (version field required,
  gosimple merged into staticcheck, formatters section separated)
- Update PROGRESS.md to reflect Phase 5 completion
Security:
  All 97 tests pass with go test -race ./... (zero race conditions).
  Adversarial JWT tests (alg confusion, alg:none) confirm the
  ValidateToken alg-first check is effective against both attack classes.
  Credential fields (PasswordHash, TOTPSecret*, PGPassword) confirmed
  absent from all API responses via both unit and e2e tests.
  go vet ./... clean. golangci-lint v2.6.2 incompatible with go1.26
  runtime; go vet used as linter until toolchain is updated.
2026-03-11 11:54:14 -07:00

193 lines
6.4 KiB
Go

// 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
}