feat: implement Argon2id hashing and Ed25519 manifest signing

- Add Argon2id-based API key hashing with salt support
- Implement Ed25519 manifest signing (key generation, sign, verify)
- Add gen-keys CLI tool for manifest signing keys
- Fix hash-key command to hash provided key (not generate new one)
- Complete isHex helper function
This commit is contained in:
Jeremie Fraeys 2026-02-19 15:34:20 -05:00
parent f357624685
commit 34aaba8f17
No known key found for this signature in database
5 changed files with 442 additions and 32 deletions

54
cmd/gen-keys/main.go Normal file
View file

@ -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)
}

View file

@ -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 <api-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)

View file

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

146
internal/auth/crypto.go Normal file
View file

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

182
internal/crypto/signing.go Normal file
View file

@ -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)
}