checkpoint mciassrv

This commit is contained in:
2026-03-11 11:48:24 -07:00
parent 9e4e7aba7a
commit d75a1d6fd3
21 changed files with 5307 additions and 0 deletions

192
internal/crypto/crypto.go Normal file
View File

@@ -0,0 +1,192 @@
// 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
}

View File

@@ -0,0 +1,259 @@
package crypto
import (
"bytes"
"crypto/ed25519"
"testing"
)
// TestGenerateEd25519KeyPair verifies that key generation returns valid,
// distinct keys and that the public key is derivable from the private key.
func TestGenerateEd25519KeyPair(t *testing.T) {
pub1, priv1, err := GenerateEd25519KeyPair()
if err != nil {
t.Fatalf("GenerateEd25519KeyPair: %v", err)
}
pub2, priv2, err := GenerateEd25519KeyPair()
if err != nil {
t.Fatalf("GenerateEd25519KeyPair second call: %v", err)
}
// Keys should be different across calls.
if bytes.Equal(priv1, priv2) {
t.Error("two calls produced identical private keys")
}
if bytes.Equal(pub1, pub2) {
t.Error("two calls produced identical public keys")
}
// Public key must be extractable from private key.
derived := priv1.Public().(ed25519.PublicKey)
if !bytes.Equal(derived, pub1) {
t.Error("public key derived from private key does not match generated public key")
}
}
// TestEd25519PEMRoundTrip verifies that a private key can be encoded to PEM
// and decoded back to the identical key.
func TestEd25519PEMRoundTrip(t *testing.T) {
_, priv, err := GenerateEd25519KeyPair()
if err != nil {
t.Fatalf("GenerateEd25519KeyPair: %v", err)
}
pem, err := MarshalPrivateKeyPEM(priv)
if err != nil {
t.Fatalf("MarshalPrivateKeyPEM: %v", err)
}
if len(pem) == 0 {
t.Fatal("MarshalPrivateKeyPEM returned empty PEM")
}
decoded, err := ParsePrivateKeyPEM(pem)
if err != nil {
t.Fatalf("ParsePrivateKeyPEM: %v", err)
}
if !bytes.Equal(priv, decoded) {
t.Error("decoded private key does not match original")
}
}
// TestParsePrivateKeyPEMErrors validates error cases.
func TestParsePrivateKeyPEMErrors(t *testing.T) {
// Empty input
if _, err := ParsePrivateKeyPEM([]byte{}); err == nil {
t.Error("expected error for empty PEM, got nil")
}
// Wrong PEM type (using a fake RSA block header)
fakePEM := []byte("-----BEGIN RSA PRIVATE KEY-----\nYWJj\n-----END RSA PRIVATE KEY-----\n")
if _, err := ParsePrivateKeyPEM(fakePEM); err == nil {
t.Error("expected error for wrong PEM type, got nil")
}
// Corrupt DER inside valid PEM block
corruptPEM := []byte("-----BEGIN PRIVATE KEY-----\nYWJj\n-----END PRIVATE KEY-----\n")
if _, err := ParsePrivateKeyPEM(corruptPEM); err == nil {
t.Error("expected error for corrupt DER, got nil")
}
}
// TestSealOpenAESGCMRoundTrip verifies that sealed data can be opened.
func TestSealOpenAESGCMRoundTrip(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
plaintext := []byte("hello world secret data")
ct, nonce, err := SealAESGCM(key, plaintext)
if err != nil {
t.Fatalf("SealAESGCM: %v", err)
}
if len(ct) == 0 || len(nonce) == 0 {
t.Fatal("SealAESGCM returned empty ciphertext or nonce")
}
got, err := OpenAESGCM(key, nonce, ct)
if err != nil {
t.Fatalf("OpenAESGCM: %v", err)
}
if !bytes.Equal(got, plaintext) {
t.Errorf("decrypted = %q, want %q", got, plaintext)
}
}
// TestSealNoncesAreUnique verifies that repeated seals produce different nonces.
func TestSealNoncesAreUnique(t *testing.T) {
key := make([]byte, 32)
plaintext := []byte("same plaintext")
_, nonce1, err := SealAESGCM(key, plaintext)
if err != nil {
t.Fatalf("SealAESGCM (1): %v", err)
}
_, nonce2, err := SealAESGCM(key, plaintext)
if err != nil {
t.Fatalf("SealAESGCM (2): %v", err)
}
if bytes.Equal(nonce1, nonce2) {
t.Error("two seals of the same plaintext produced identical nonces — crypto/rand may be broken")
}
}
// TestOpenAESGCMWrongKey verifies that decryption with the wrong key fails.
func TestOpenAESGCMWrongKey(t *testing.T) {
key := make([]byte, 32)
wrongKey := make([]byte, 32)
wrongKey[0] = 0xFF
ct, nonce, err := SealAESGCM(key, []byte("secret"))
if err != nil {
t.Fatalf("SealAESGCM: %v", err)
}
if _, err := OpenAESGCM(wrongKey, nonce, ct); err == nil {
t.Error("expected error when opening with wrong key, got nil")
}
}
// TestOpenAESGCMTamperedCiphertext verifies that tampering is detected.
func TestOpenAESGCMTamperedCiphertext(t *testing.T) {
key := make([]byte, 32)
ct, nonce, err := SealAESGCM(key, []byte("secret"))
if err != nil {
t.Fatalf("SealAESGCM: %v", err)
}
// Flip one bit in the ciphertext.
ct[0] ^= 0x01
if _, err := OpenAESGCM(key, nonce, ct); err == nil {
t.Error("expected error for tampered ciphertext, got nil")
}
}
// TestOpenAESGCMWrongKeySize verifies that keys with wrong size are rejected.
func TestOpenAESGCMWrongKeySize(t *testing.T) {
if _, _, err := SealAESGCM([]byte("short"), []byte("data")); err == nil {
t.Error("expected error for short key in Seal, got nil")
}
if _, err := OpenAESGCM([]byte("short"), make([]byte, 12), []byte("data")); err == nil {
t.Error("expected error for short key in Open, got nil")
}
}
// TestDeriveKey verifies that DeriveKey produces consistent, non-empty output.
func TestDeriveKey(t *testing.T) {
salt, err := NewSalt()
if err != nil {
t.Fatalf("NewSalt: %v", err)
}
key1, err := DeriveKey("my-passphrase", salt)
if err != nil {
t.Fatalf("DeriveKey: %v", err)
}
if len(key1) != 32 {
t.Errorf("DeriveKey returned %d bytes, want 32", len(key1))
}
// Same inputs → same output (deterministic).
key2, err := DeriveKey("my-passphrase", salt)
if err != nil {
t.Fatalf("DeriveKey (2): %v", err)
}
if !bytes.Equal(key1, key2) {
t.Error("DeriveKey is not deterministic")
}
// Different passphrase → different key.
key3, err := DeriveKey("different-passphrase", salt)
if err != nil {
t.Fatalf("DeriveKey (3): %v", err)
}
if bytes.Equal(key1, key3) {
t.Error("different passphrases produced the same key")
}
// Different salt → different key.
salt2, err := NewSalt()
if err != nil {
t.Fatalf("NewSalt (2): %v", err)
}
key4, err := DeriveKey("my-passphrase", salt2)
if err != nil {
t.Fatalf("DeriveKey (4): %v", err)
}
if bytes.Equal(key1, key4) {
t.Error("different salts produced the same key")
}
}
// TestDeriveKeyErrors verifies invalid input rejection.
func TestDeriveKeyErrors(t *testing.T) {
// Short salt
if _, err := DeriveKey("passphrase", []byte("short")); err == nil {
t.Error("expected error for short salt, got nil")
}
// Empty passphrase
salt, _ := NewSalt()
if _, err := DeriveKey("", salt); err == nil {
t.Error("expected error for empty passphrase, got nil")
}
}
// TestNewSaltUniqueness verifies that two salts are different.
func TestNewSaltUniqueness(t *testing.T) {
s1, err := NewSalt()
if err != nil {
t.Fatalf("NewSalt (1): %v", err)
}
s2, err := NewSalt()
if err != nil {
t.Fatalf("NewSalt (2): %v", err)
}
if bytes.Equal(s1, s2) {
t.Error("two NewSalt calls returned identical salts")
}
}
// TestRandomBytes verifies length and uniqueness.
func TestRandomBytes(t *testing.T) {
b1, err := RandomBytes(32)
if err != nil {
t.Fatalf("RandomBytes: %v", err)
}
if len(b1) != 32 {
t.Errorf("RandomBytes returned %d bytes, want 32", len(b1))
}
b2, err := RandomBytes(32)
if err != nil {
t.Fatalf("RandomBytes (2): %v", err)
}
if bytes.Equal(b1, b2) {
t.Error("two RandomBytes calls returned identical values")
}
}