checkpoint mciassrv
This commit is contained in:
192
internal/crypto/crypto.go
Normal file
192
internal/crypto/crypto.go
Normal 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
|
||||
}
|
||||
259
internal/crypto/crypto_test.go
Normal file
259
internal/crypto/crypto_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user