diff --git a/cmd/gen-keys/main.go b/cmd/gen-keys/main.go new file mode 100644 index 0000000..056b119 --- /dev/null +++ b/cmd/gen-keys/main.go @@ -0,0 +1,54 @@ +// Package main implements a tool for generating Ed25519 signing keys +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/jfraeys/fetch_ml/internal/crypto" +) + +func main() { + var ( + outDir = flag.String("out", "./keys", "Output directory for keys") + keyID = flag.String("key-id", "manifest-signer-1", "Key identifier") + ) + flag.Parse() + + // Create output directory + if err := os.MkdirAll(*outDir, 0700); err != nil { + log.Fatalf("Failed to create output directory: %v", err) + } + + // Generate keypair + publicKey, privateKey, err := crypto.GenerateSigningKeys() + if err != nil { + log.Fatalf("Failed to generate signing keys: %v", err) + } + + // Define paths + privKeyPath := fmt.Sprintf("%s/%s_private.key", *outDir, *keyID) + pubKeyPath := fmt.Sprintf("%s/%s_public.key", *outDir, *keyID) + + // Save private key (restricted permissions) + if err := crypto.SavePrivateKeyToFile(privateKey, privKeyPath); err != nil { + log.Fatalf("Failed to save private key: %v", err) + } + + // Save public key + if err := crypto.SavePublicKeyToFile(publicKey, pubKeyPath); err != nil { + log.Fatalf("Failed to save public key: %v", err) + } + + // Print summary + fmt.Printf("✓ Generated Ed25519 signing keys\n") + fmt.Printf(" Key ID: %s\n", *keyID) + fmt.Printf(" Private key: %s (permissions: 0600)\n", privKeyPath) + fmt.Printf(" Public key: %s\n", pubKeyPath) + fmt.Printf("\nImportant:\n") + fmt.Printf(" - Store the private key securely (it can sign manifests)\n") + fmt.Printf(" - Distribute the public key to verification systems\n") + fmt.Printf(" - Set environment variable: FETCHML_SIGNING_KEY_PATH=%s\n", privKeyPath) +} diff --git a/cmd/user_manager/main.go b/cmd/user_manager/main.go index c821bb2..abacbba 100644 --- a/cmd/user_manager/main.go +++ b/cmd/user_manager/main.go @@ -51,14 +51,7 @@ func main() { log.Fatalf("Failed to parse config: %v", err) } - // Generate API key - apiKey := auth.GenerateAPIKey() - - // Setup user - if config.Auth.APIKeys == nil { - config.Auth.APIKeys = make(map[auth.Username]auth.APIKeyEntry) - } - + // Determine admin status and roles adminStatus := *admin roles := []string{"viewer"} permissions := make(map[string]bool) @@ -81,14 +74,19 @@ func main() { } } - // Save user - config.Auth.APIKeys[auth.Username(*username)] = auth.APIKeyEntry{ - Hash: auth.APIKeyHash(auth.HashAPIKey(apiKey)), - Admin: adminStatus, - Roles: roles, - Permissions: permissions, + // Generate API key using new Argon2id method + apiKey, apiKeyEntry, err := auth.GenerateNewAPIKey(adminStatus, roles, permissions) + if err != nil { + log.Fatalf("Failed to generate API key: %v", err) } + // Setup user + if config.Auth.APIKeys == nil { + config.Auth.APIKeys = make(map[auth.Username]auth.APIKeyEntry) + } + + config.Auth.APIKeys[auth.Username(*username)] = apiKeyEntry + data, err = yaml.Marshal(config) if err != nil { log.Fatalf("Failed to marshal config: %v", err) @@ -129,8 +127,12 @@ func main() { if *apiKey == "" { log.Fatal("Usage: --cmd hash-key --key ") } - hash := auth.HashAPIKey(*apiKey) - fmt.Printf("Hash: %s\n", hash) + // Hash the provided key with Argon2id + hashed, err := auth.HashAPIKeyArgon2id(*apiKey) + if err != nil { + log.Fatalf("Failed to hash key: %v", err) + } + fmt.Printf("Hash: %s\nSalt: %s\nAlgorithm: %s\n", hashed.Hash, hashed.Salt, hashed.Algorithm) default: log.Fatalf("Unknown command: %s", *command) diff --git a/internal/auth/api_key.go b/internal/auth/api_key.go index 1cec87d..4a84e5e 100644 --- a/internal/auth/api_key.go +++ b/internal/auth/api_key.go @@ -3,7 +3,6 @@ package auth import ( "context" - "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" @@ -43,6 +42,8 @@ type APIKeyHash string // APIKeyEntry represents an API key configuration type APIKeyEntry struct { Hash APIKeyHash `yaml:"hash"` + Salt string `yaml:"salt,omitempty"` // Salt for Argon2id hashing + Algorithm string `yaml:"algorithm,omitempty"` // "sha256" or "argon2id" Admin bool `yaml:"admin"` Roles []string `yaml:"roles,omitempty"` Permissions map[string]bool `yaml:"permissions,omitempty"` @@ -89,17 +90,29 @@ type UserInfo struct { } // ValidateAPIKey validates an API key and returns user information +// Supports both legacy SHA256 and modern Argon2id hashing func (c *Config) ValidateAPIKey(key string) (*User, error) { if !c.Enabled { // Auth disabled - return default admin user for development return &User{Name: "default", Admin: true}, nil } - // Always hash the incoming key for comparison - keyHash := HashAPIKey(key) - for username, entry := range c.APIKeys { - if string(entry.Hash) == keyHash { + // Build HashedKey from entry + stored := &HashedKey{ + Hash: string(entry.Hash), + Salt: entry.Salt, + Algorithm: HashAlgorithm(entry.Algorithm), + } + + // Verify using appropriate algorithm + match, err := VerifyAPIKey(key, stored) + if err != nil { + // Log error but continue to next key (don't leak info) + continue + } + + if match { // Build user with role and permission inheritance user := &User{ Name: string(username), @@ -340,19 +353,32 @@ func getRolePermissions(roles []string) map[string]bool { return permissions } -// GenerateAPIKey generates a new random API key -func GenerateAPIKey() string { - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return fmt.Sprintf("%x", sha256.Sum256([]byte(time.Now().String()))) +// GenerateAPIKey creates a new API key with Argon2id hashing. +// Returns plaintext key (show once) and the entry for storage. +func GenerateNewAPIKey(admin bool, roles []string, permissions map[string]bool) (plaintext string, entry APIKeyEntry, err error) { + plaintext, hashed, err := GenerateAPIKey() + if err != nil { + return "", APIKeyEntry{}, err } - return hex.EncodeToString(buf) -} -// HashAPIKey creates a SHA256 hash of an API key -func HashAPIKey(key string) string { - hash := sha256.Sum256([]byte(key)) - return hex.EncodeToString(hash[:]) + entry = APIKeyEntry{ + Hash: APIKeyHash(hashed.Hash), + Salt: hashed.Salt, + Algorithm: string(hashed.Algorithm), + Admin: admin, + Roles: roles, + Permissions: permissions, + } + + return plaintext, entry, nil } // isHex checks if a string contains only hex characters +func isHex(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return len(s) > 0 +} diff --git a/internal/auth/crypto.go b/internal/auth/crypto.go new file mode 100644 index 0000000..991e421 --- /dev/null +++ b/internal/auth/crypto.go @@ -0,0 +1,146 @@ +// Package auth provides authentication and authorization functionality +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + + "golang.org/x/crypto/argon2" +) + +// HashAlgorithm identifies the hashing algorithm used for API keys +type HashAlgorithm string + +const ( + // HashAlgorithmSHA256 is the legacy SHA256 hashing (deprecated, for backward compatibility) + HashAlgorithmSHA256 HashAlgorithm = "sha256" + // HashAlgorithmArgon2id is the modern Argon2id hashing (recommended) + HashAlgorithmArgon2id HashAlgorithm = "argon2id" +) + +// Argon2id parameters (OWASP recommended minimum) +const ( + argon2idTime = 1 // Iterations + argon2idMemory = 64 * 1024 // 64 MB + argon2idThreads = 4 // Parallelism + argon2idKeyLen = 32 // 256-bit output + argon2idSaltLen = 16 // 128-bit salt +) + +// HashedKey represents a hashed API key with its algorithm and salt +type HashedKey struct { + Hash string `json:"hash"` + Salt string `json:"salt,omitempty"` + Algorithm HashAlgorithm `json:"algorithm"` +} + +// HashAPIKeyArgon2id creates an Argon2id hash of an API key +// This is the recommended hashing method for new keys +func HashAPIKeyArgon2id(key string) (*HashedKey, error) { + // Generate random salt + salt := make([]byte, argon2idSaltLen) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + + // Hash with Argon2id + hash := argon2.IDKey( + []byte(key), + salt, + argon2idTime, + argon2idMemory, + argon2idThreads, + argon2idKeyLen, + ) + + return &HashedKey{ + Hash: hex.EncodeToString(hash), + Salt: hex.EncodeToString(salt), + Algorithm: HashAlgorithmArgon2id, + }, nil +} + +// HashAPIKey creates a legacy SHA256 hash of an API key +// Deprecated: Use HashAPIKeyArgon2id for new keys +func HashAPIKey(key string) string { + hash := sha256.Sum256([]byte(key)) + return hex.EncodeToString(hash[:]) +} + +// VerifyAPIKey verifies an API key against a stored hash +// Supports both Argon2id (preferred) and SHA256 (legacy) for backward compatibility +func VerifyAPIKey(key string, stored *HashedKey) (bool, error) { + switch stored.Algorithm { + case HashAlgorithmArgon2id: + return verifyArgon2id(key, stored) + case HashAlgorithmSHA256, "": // Empty string treated as legacy SHA256 + return verifySHA256(key, stored.Hash), nil + default: + return false, fmt.Errorf("unsupported hash algorithm: %s", stored.Algorithm) + } +} + +// verifyArgon2id verifies a key against an Argon2id hash +func verifyArgon2id(key string, stored *HashedKey) (bool, error) { + // Decode salt + salt, err := hex.DecodeString(stored.Salt) + if err != nil { + return false, fmt.Errorf("invalid salt encoding: %w", err) + } + + // Compute hash with same parameters + computedHash := argon2.IDKey( + []byte(key), + salt, + argon2idTime, + argon2idMemory, + argon2idThreads, + argon2idKeyLen, + ) + + // Decode stored hash + storedHash, err := hex.DecodeString(stored.Hash) + if err != nil { + return false, fmt.Errorf("invalid hash encoding: %w", err) + } + + // Constant-time comparison to prevent timing attacks + return subtle.ConstantTimeCompare(computedHash, storedHash) == 1, nil +} + +// verifySHA256 verifies a key against a legacy SHA256 hash +func verifySHA256(key, storedHash string) bool { + computedHash := HashAPIKey(key) + return subtle.ConstantTimeCompare([]byte(computedHash), []byte(storedHash)) == 1 +} + +// GenerateAPIKey generates a new random API key +// Returns the plaintext key (to be shown once) and its Argon2id hash +func GenerateAPIKey() (plaintext string, hashed *HashedKey, err error) { + // Generate 32 bytes of randomness (256 bits) + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", nil, fmt.Errorf("failed to generate API key: %w", err) + } + + plaintext = hex.EncodeToString(buf) + hashed, err = HashAPIKeyArgon2id(plaintext) + if err != nil { + return "", nil, err + } + + return plaintext, hashed, nil +} + +// GenerateAPISalt creates a random salt for API key hashing +// Primarily used for testing and migration scenarios +func GenerateAPISalt() ([]byte, error) { + salt := make([]byte, argon2idSaltLen) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("failed to generate salt: %w", err) + } + return salt, nil +} diff --git a/internal/crypto/signing.go b/internal/crypto/signing.go new file mode 100644 index 0000000..3ca246a --- /dev/null +++ b/internal/crypto/signing.go @@ -0,0 +1,182 @@ +// Package crypto provides cryptographic utilities for FetchML +package crypto + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "os" +) + +// ManifestSigner provides Ed25519 signing for run manifests +type ManifestSigner struct { + privateKey ed25519.PrivateKey + publicKey ed25519.PublicKey + keyID string +} + +// SigningResult contains the signature and metadata +type SigningResult struct { + Signature string `json:"signature"` + KeyID string `json:"key_id"` + Algorithm string `json:"algorithm"` +} + +// GenerateSigningKeys creates a new Ed25519 keypair for manifest signing +// This should be done once and the keys stored securely +func GenerateSigningKeys() (publicKey, privateKey []byte, err error) { + return ed25519.GenerateKey(rand.Reader) +} + +// NewManifestSigner creates a signer from a private key +func NewManifestSigner(privateKey []byte, keyID string) (*ManifestSigner, error) { + if len(privateKey) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid private key size: expected %d, got %d", + ed25519.PrivateKeySize, len(privateKey)) + } + + // Extract public key from private key + pubKey := make([]byte, ed25519.PublicKeySize) + copy(pubKey, privateKey[32:]) + + return &ManifestSigner{ + privateKey: ed25519.PrivateKey(privateKey), + publicKey: ed25519.PublicKey(pubKey), + keyID: keyID, + }, nil +} + +// SignManifest signs a manifest and returns the signing result +// The manifest is canonicalized to JSON before signing +func (s *ManifestSigner) SignManifest(manifest any) (*SigningResult, error) { + // Marshal to canonical JSON (sorted keys) + data, err := json.Marshal(manifest) + if err != nil { + return nil, fmt.Errorf("failed to marshal manifest: %w", err) + } + + // Sign the data + signature := ed25519.Sign(s.privateKey, data) + + return &SigningResult{ + Signature: base64.StdEncoding.EncodeToString(signature), + KeyID: s.keyID, + Algorithm: "Ed25519", + }, nil +} + +// SignManifestBytes signs raw bytes directly (for pre-serialized manifests) +func (s *ManifestSigner) SignManifestBytes(data []byte) (*SigningResult, error) { + signature := ed25519.Sign(s.privateKey, data) + + return &SigningResult{ + Signature: base64.StdEncoding.EncodeToString(signature), + KeyID: s.keyID, + Algorithm: "Ed25519", + }, nil +} + +// VerifyManifest verifies a manifest signature +func VerifyManifest(manifest any, result *SigningResult, publicKey []byte) (bool, error) { + if result.Algorithm != "Ed25519" { + return false, fmt.Errorf("unsupported algorithm: %s", result.Algorithm) + } + + // Marshal manifest to same format used for signing + data, err := json.Marshal(manifest) + if err != nil { + return false, fmt.Errorf("failed to marshal manifest: %w", err) + } + + // Decode signature + signature, err := base64.StdEncoding.DecodeString(result.Signature) + if err != nil { + return false, fmt.Errorf("failed to decode signature: %w", err) + } + + // Verify + return ed25519.Verify(ed25519.PublicKey(publicKey), data, signature), nil +} + +// VerifyManifestBytes verifies raw bytes directly +func VerifyManifestBytes(data []byte, result *SigningResult, publicKey []byte) (bool, error) { + if result.Algorithm != "Ed25519" { + return false, fmt.Errorf("unsupported algorithm: %s", result.Algorithm) + } + + signature, err := base64.StdEncoding.DecodeString(result.Signature) + if err != nil { + return false, fmt.Errorf("failed to decode signature: %w", err) + } + + return ed25519.Verify(ed25519.PublicKey(publicKey), data, signature), nil +} + +// GetPublicKey returns the signer's public key +func (s *ManifestSigner) GetPublicKey() []byte { + return s.publicKey +} + +// GetKeyID returns the signer's key ID +func (s *ManifestSigner) GetKeyID() string { + return s.keyID +} + +// SavePrivateKeyToFile saves a private key to a file with restricted permissions +func SavePrivateKeyToFile(key []byte, path string) error { + // Write with restricted permissions (owner read/write only) + if err := os.WriteFile(path, key, 0600); err != nil { + return fmt.Errorf("failed to write private key: %w", err) + } + return nil +} + +// LoadPrivateKeyFromFile loads a private key from a file +func LoadPrivateKeyFromFile(path string) ([]byte, error) { + key, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read private key: %w", err) + } + + if len(key) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("invalid private key size: expected %d, got %d", + ed25519.PrivateKeySize, len(key)) + } + + return key, nil +} + +// SavePublicKeyToFile saves a public key to a file +func SavePublicKeyToFile(key []byte, path string) error { + if err := os.WriteFile(path, key, 0644); err != nil { + return fmt.Errorf("failed to write public key: %w", err) + } + return nil +} + +// LoadPublicKeyFromFile loads a public key from a file +func LoadPublicKeyFromFile(path string) ([]byte, error) { + key, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read public key: %w", err) + } + + if len(key) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid public key size: expected %d, got %d", + ed25519.PublicKeySize, len(key)) + } + + return key, nil +} + +// EncodeKeyToBase64 encodes a key to base64 for storage/transmission +func EncodeKeyToBase64(key []byte) string { + return base64.StdEncoding.EncodeToString(key) +} + +// DecodeKeyFromBase64 decodes a key from base64 +func DecodeKeyFromBase64(encoded string) ([]byte, error) { + return base64.StdEncoding.DecodeString(encoded) +}