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:
parent
f357624685
commit
34aaba8f17
5 changed files with 442 additions and 32 deletions
54
cmd/gen-keys/main.go
Normal file
54
cmd/gen-keys/main.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
146
internal/auth/crypto.go
Normal 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
182
internal/crypto/signing.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue