- 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.
193 lines
6.4 KiB
Go
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
|
|
}
|