diff --git a/auth/auth.go b/auth/auth.go index cba5ca5..97da488 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -19,9 +19,10 @@ // users/ // {userID}.pub PGP public key (armored) // {userID}.key PGP private key (armored, password-encrypted) -// {userID}.rev Revocation certificate (placeholder) +// {userID}.rev Revocation record (JSON) or legacy placeholder // {userID}.json User metadata (encrypted with user's public key) -// {userID}.lthn LTHN password hash +// {userID}.hash Argon2id password hash (new registrations) +// {userID}.lthn LTHN password hash (legacy, migrated on login) package auth import ( @@ -30,11 +31,13 @@ import ( "encoding/hex" "encoding/json" "fmt" + "strings" "sync" "time" coreerr "forge.lthn.ai/core/go/pkg/framework/core" + "forge.lthn.ai/core/go-crypt/crypt" "forge.lthn.ai/core/go-crypt/crypt/lthn" "forge.lthn.ai/core/go-crypt/crypt/pgp" "forge.lthn.ai/core/go/pkg/io" @@ -59,7 +62,7 @@ type User struct { PublicKey string `json:"public_key"` KeyID string `json:"key_id"` Fingerprint string `json:"fingerprint"` - PasswordHash string `json:"password_hash"` // LTHN hash + PasswordHash string `json:"password_hash"` // Argon2id (new) or LTHN (legacy) Created time.Time `json:"created"` LastLogin time.Time `json:"last_login"` } @@ -78,6 +81,14 @@ type Session struct { ExpiresAt time.Time `json:"expires_at"` } +// Revocation records the details of a revoked user key. +// Stored as JSON in the user's .rev file, replacing the legacy placeholder. +type Revocation struct { + UserID string `json:"user_id"` + Reason string `json:"reason"` + RevokedAt time.Time `json:"revoked_at"` +} + // Option configures an Authenticator. type Option func(*Authenticator) @@ -108,9 +119,14 @@ func WithSessionStore(s SessionStore) Option { // be backed by disk, memory (MockMedium), or any other storage backend. // Sessions are persisted via a SessionStore (in-memory by default, // optionally SQLite-backed for crash recovery). +// +// An optional HardwareKey can be provided via WithHardwareKey for +// hardware-backed cryptographic operations (PKCS#11, YubiKey, etc.). +// See auth/hardware.go for the interface definition and integration points. type Authenticator struct { medium io.Medium store SessionStore + hardwareKey HardwareKey // optional hardware key (nil = software only) challenges map[string]*Challenge // userID -> pending challenge mu sync.RWMutex // protects challenges map only challengeTTL time.Duration @@ -145,7 +161,7 @@ func userPath(userID, ext string) string { // Register creates a new user account. It hashes the username with LTHN to // produce a userID, generates a PGP keypair (protected by the given password), // and persists the public key, private key, revocation placeholder, password -// hash, and encrypted metadata via the Medium. +// hash (Argon2id), and encrypted metadata via the Medium. func (a *Authenticator) Register(username, password string) (*User, error) { const op = "auth.Register" @@ -182,9 +198,12 @@ func (a *Authenticator) Register(username, password string) (*User, error) { return nil, coreerr.E(op, "failed to write revocation certificate", err) } - // Store LTHN password hash - passwordHash := lthn.Hash(password) - if err := a.medium.Write(userPath(userID, ".lthn"), passwordHash); err != nil { + // Store Argon2id password hash (replaces legacy LTHN hash for new registrations) + passwordHash, err := crypt.HashPassword(password) + if err != nil { + return nil, coreerr.E(op, "failed to hash password", err) + } + if err := a.medium.Write(userPath(userID, ".hash"), passwordHash); err != nil { return nil, coreerr.E(op, "failed to write password hash", err) } @@ -223,6 +242,11 @@ func (a *Authenticator) Register(username, password string) (*User, error) { func (a *Authenticator) CreateChallenge(userID string) (*Challenge, error) { const op = "auth.CreateChallenge" + // Reject challenges for revoked users + if a.IsRevoked(userID) { + return nil, coreerr.E(op, "key has been revoked", nil) + } + // Read user's public key pubKey, err := a.medium.Read(userPath(userID, ".pub")) if err != nil { @@ -354,8 +378,8 @@ func (a *Authenticator) DeleteUser(userID string) error { return coreerr.E(op, "user not found", nil) } - // Remove all artifacts - extensions := []string{".pub", ".key", ".rev", ".json", ".lthn"} + // Remove all artifacts (both new .hash and legacy .lthn) + extensions := []string{".pub", ".key", ".rev", ".json", ".hash", ".lthn"} for _, ext := range extensions { p := userPath(userID, ext) if a.medium.IsFile(p) { @@ -372,25 +396,205 @@ func (a *Authenticator) DeleteUser(userID string) error { } // Login performs password-based authentication as a convenience method. -// It verifies the password against the stored LTHN hash and, on success, +// It verifies the password against the stored hash and, on success, // creates a new session. This bypasses the PGP challenge-response flow. +// +// Hash format detection: +// - If a .hash file exists, its content starts with "$argon2id$" and is verified +// using constant-time Argon2id comparison. +// - Otherwise, falls back to legacy .lthn file with LTHN hash verification. +// On successful legacy login, the password is re-hashed with Argon2id and +// a .hash file is written (transparent migration). func (a *Authenticator) Login(userID, password string) (*Session, error) { const op = "auth.Login" - // Read stored password hash + // Reject login for revoked users + if a.IsRevoked(userID) { + return nil, coreerr.E(op, "key has been revoked", nil) + } + + // Try Argon2id hash first (.hash file) + if a.medium.IsFile(userPath(userID, ".hash")) { + storedHash, err := a.medium.Read(userPath(userID, ".hash")) + if err != nil { + return nil, coreerr.E(op, "failed to read password hash", err) + } + + if strings.HasPrefix(storedHash, "$argon2id$") { + valid, err := crypt.VerifyPassword(password, storedHash) + if err != nil { + return nil, coreerr.E(op, "failed to verify password", err) + } + if !valid { + return nil, coreerr.E(op, "invalid password", nil) + } + return a.createSession(userID) + } + } + + // Fall back to legacy LTHN hash (.lthn file) storedHash, err := a.medium.Read(userPath(userID, ".lthn")) if err != nil { return nil, coreerr.E(op, "user not found", err) } - // Verify password if !lthn.Verify(password, storedHash) { return nil, coreerr.E(op, "invalid password", nil) } + // Migrate: re-hash with Argon2id and write .hash file + newHash, err := crypt.HashPassword(password) + if err == nil { + // Best-effort migration — do not fail login if migration write fails + _ = a.medium.Write(userPath(userID, ".hash"), newHash) + } + return a.createSession(userID) } +// RotateKeyPair generates a new PGP keypair for the given user, re-encrypts +// their metadata with the new key, updates the password hash, and invalidates +// all existing sessions. The caller must provide the current password +// (oldPassword) to decrypt existing metadata and the new password (newPassword) +// to protect the new keypair. +func (a *Authenticator) RotateKeyPair(userID, oldPassword, newPassword string) (*User, error) { + const op = "auth.RotateKeyPair" + + // Verify the user exists + if !a.medium.IsFile(userPath(userID, ".pub")) { + return nil, coreerr.E(op, "user not found", nil) + } + + // Load current private key for metadata decryption + privKeyArmor, err := a.medium.Read(userPath(userID, ".key")) + if err != nil { + return nil, coreerr.E(op, "failed to read private key", err) + } + + // Load and decrypt current metadata + encMeta, err := a.medium.Read(userPath(userID, ".json")) + if err != nil { + return nil, coreerr.E(op, "failed to read user metadata", err) + } + + metaJSON, err := pgp.Decrypt([]byte(encMeta), privKeyArmor, oldPassword) + if err != nil { + return nil, coreerr.E(op, "failed to decrypt metadata (wrong password?)", err) + } + + var user User + if err := json.Unmarshal(metaJSON, &user); err != nil { + return nil, coreerr.E(op, "failed to unmarshal user metadata", err) + } + + // Generate new PGP keypair + newKP, err := pgp.CreateKeyPair(userID, userID+"@auth.local", newPassword) + if err != nil { + return nil, coreerr.E(op, "failed to create new PGP keypair", err) + } + + // Update user metadata with new key material + user.PublicKey = newKP.PublicKey + user.Fingerprint = lthn.Hash(newKP.PublicKey) + + // Hash new password with Argon2id + newHash, err := crypt.HashPassword(newPassword) + if err != nil { + return nil, coreerr.E(op, "failed to hash new password", err) + } + user.PasswordHash = newHash + + // Re-encrypt metadata with new public key + updatedMeta, err := json.Marshal(&user) + if err != nil { + return nil, coreerr.E(op, "failed to marshal updated metadata", err) + } + + encUpdatedMeta, err := pgp.Encrypt(updatedMeta, newKP.PublicKey) + if err != nil { + return nil, coreerr.E(op, "failed to encrypt metadata with new key", err) + } + + // Write new files (overwrite existing) + if err := a.medium.Write(userPath(userID, ".pub"), newKP.PublicKey); err != nil { + return nil, coreerr.E(op, "failed to write new public key", err) + } + if err := a.medium.Write(userPath(userID, ".key"), newKP.PrivateKey); err != nil { + return nil, coreerr.E(op, "failed to write new private key", err) + } + if err := a.medium.Write(userPath(userID, ".json"), string(encUpdatedMeta)); err != nil { + return nil, coreerr.E(op, "failed to write updated metadata", err) + } + if err := a.medium.Write(userPath(userID, ".hash"), newHash); err != nil { + return nil, coreerr.E(op, "failed to write new password hash", err) + } + + // Invalidate all sessions for this user + _ = a.store.DeleteByUser(userID) + + return &user, nil +} + +// RevokeKey marks a user's key as revoked. It verifies the password first, +// writes a JSON revocation record to the .rev file (replacing the placeholder), +// and invalidates all sessions for the user. +func (a *Authenticator) RevokeKey(userID, password, reason string) error { + const op = "auth.RevokeKey" + + // Verify user exists + if !a.medium.IsFile(userPath(userID, ".pub")) { + return coreerr.E(op, "user not found", nil) + } + + // Verify password — try Argon2id first, then fall back to LTHN + if err := a.verifyPassword(userID, password); err != nil { + return coreerr.E(op, err.Error(), nil) + } + + // Write revocation record as JSON + rev := Revocation{ + UserID: userID, + Reason: reason, + RevokedAt: time.Now(), + } + revJSON, err := json.Marshal(&rev) + if err != nil { + return coreerr.E(op, "failed to marshal revocation record", err) + } + if err := a.medium.Write(userPath(userID, ".rev"), string(revJSON)); err != nil { + return coreerr.E(op, "failed to write revocation record", err) + } + + // Invalidate all sessions + _ = a.store.DeleteByUser(userID) + + return nil +} + +// IsRevoked checks whether a user's key has been revoked by inspecting the +// .rev file. Returns true only if the file contains valid revocation JSON +// (not the legacy "REVOCATION_PLACEHOLDER" string). +func (a *Authenticator) IsRevoked(userID string) bool { + content, err := a.medium.Read(userPath(userID, ".rev")) + if err != nil { + return false + } + + // Legacy placeholder is not a valid revocation + if content == "REVOCATION_PLACEHOLDER" { + return false + } + + // Attempt to parse as JSON revocation record + var rev Revocation + if err := json.Unmarshal([]byte(content), &rev); err != nil { + return false + } + + // Valid revocation must have a non-zero timestamp + return !rev.RevokedAt.IsZero() +} + // WriteChallengeFile writes an encrypted challenge to a file for air-gapped // (courier) transport. The challenge is created and then its encrypted nonce // is written to the specified path on the Medium. @@ -433,6 +637,36 @@ func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error) return session, nil } +// verifyPassword checks the given password against stored hashes for a user. +// Tries Argon2id (.hash) first, then falls back to legacy LTHN (.lthn). +// Returns nil on success, or an error describing the failure. +func (a *Authenticator) verifyPassword(userID, password string) error { + // Try Argon2id hash first (.hash file) + if a.medium.IsFile(userPath(userID, ".hash")) { + storedHash, err := a.medium.Read(userPath(userID, ".hash")) + if err == nil && strings.HasPrefix(storedHash, "$argon2id$") { + valid, verr := crypt.VerifyPassword(password, storedHash) + if verr != nil { + return fmt.Errorf("failed to verify password") + } + if !valid { + return fmt.Errorf("invalid password") + } + return nil + } + } + + // Fall back to legacy LTHN hash (.lthn file) + storedHash, err := a.medium.Read(userPath(userID, ".lthn")) + if err != nil { + return fmt.Errorf("user not found") + } + if !lthn.Verify(password, storedHash) { + return fmt.Errorf("invalid password") + } + return nil +} + // createSession generates a cryptographically random session token and // stores the session via the SessionStore. func (a *Authenticator) createSession(userID string) (*Session, error) { diff --git a/auth/auth_test.go b/auth/auth_test.go index afd39dc..c90ee8b 100644 --- a/auth/auth_test.go +++ b/auth/auth_test.go @@ -34,18 +34,19 @@ func TestRegister_Good(t *testing.T) { userID := lthn.Hash("alice") - // Verify public key is stored + // Verify all files are stored (new registrations use .hash, not .lthn) assert.True(t, m.IsFile(userPath(userID, ".pub"))) assert.True(t, m.IsFile(userPath(userID, ".key"))) assert.True(t, m.IsFile(userPath(userID, ".rev"))) assert.True(t, m.IsFile(userPath(userID, ".json"))) - assert.True(t, m.IsFile(userPath(userID, ".lthn"))) + assert.True(t, m.IsFile(userPath(userID, ".hash"))) + assert.False(t, m.IsFile(userPath(userID, ".lthn")), "new registrations should not create .lthn file") // Verify user fields assert.NotEmpty(t, user.PublicKey) assert.Equal(t, userID, user.KeyID) assert.NotEmpty(t, user.Fingerprint) - assert.Equal(t, lthn.Hash("hunter2"), user.PasswordHash) + assert.True(t, strings.HasPrefix(user.PasswordHash, "$argon2id$"), "password hash should be Argon2id format") assert.False(t, user.Created.IsZero()) } @@ -321,11 +322,12 @@ func TestDeleteUser_Good(t *testing.T) { err = a.DeleteUser(userID) require.NoError(t, err) - // All files should be gone + // All files should be gone (both new .hash and legacy .lthn) assert.False(t, m.IsFile(userPath(userID, ".pub"))) assert.False(t, m.IsFile(userPath(userID, ".key"))) assert.False(t, m.IsFile(userPath(userID, ".rev"))) assert.False(t, m.IsFile(userPath(userID, ".json"))) + assert.False(t, m.IsFile(userPath(userID, ".hash"))) assert.False(t, m.IsFile(userPath(userID, ".lthn"))) // Session should be gone (validate returns error) @@ -851,3 +853,351 @@ func TestRefreshExpiredSession_Bad(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "session not found") } + +// --- Phase 2: Password Hash Migration --- + +// TestRegisterArgon2id_Good verifies that new registrations use Argon2id format. +func TestRegisterArgon2id_Good(t *testing.T) { + a, m := newTestAuth() + + user, err := a.Register("argon2-user", "strong-pass") + require.NoError(t, err) + + userID := lthn.Hash("argon2-user") + + // .hash file should exist with Argon2id format + assert.True(t, m.IsFile(userPath(userID, ".hash"))) + hashContent, err := m.Read(userPath(userID, ".hash")) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(hashContent, "$argon2id$"), "stored hash should be Argon2id") + + // .lthn file should NOT exist for new registrations + assert.False(t, m.IsFile(userPath(userID, ".lthn"))) + + // User struct should have Argon2id hash + assert.True(t, strings.HasPrefix(user.PasswordHash, "$argon2id$")) +} + +// TestLoginArgon2id_Good verifies login works with Argon2id hashed password. +func TestLoginArgon2id_Good(t *testing.T) { + a, _ := newTestAuth() + + _, err := a.Register("login-argon2", "my-password") + require.NoError(t, err) + userID := lthn.Hash("login-argon2") + + // Login should succeed with correct password + session, err := a.Login(userID, "my-password") + require.NoError(t, err) + assert.NotEmpty(t, session.Token) +} + +// TestLoginArgon2id_Bad verifies wrong password fails with Argon2id hash. +func TestLoginArgon2id_Bad(t *testing.T) { + a, _ := newTestAuth() + + _, err := a.Register("login-argon2-bad", "correct") + require.NoError(t, err) + userID := lthn.Hash("login-argon2-bad") + + _, err = a.Login(userID, "wrong") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid password") +} + +// TestLegacyLTHNMigration_Good verifies that a user registered with the legacy +// LTHN hash format is transparently migrated to Argon2id on successful login. +func TestLegacyLTHNMigration_Good(t *testing.T) { + m := io.NewMockMedium() + a := New(m) + + // Simulate a legacy registration by manually writing LTHN-format files + userID := lthn.Hash("legacy-user") + _ = m.EnsureDir("users") + + // Generate PGP keypair (same as original Register did) + kp, err := pgp.CreateKeyPair(userID, userID+"@auth.local", "legacy-pass") + require.NoError(t, err) + + _ = m.Write(userPath(userID, ".pub"), kp.PublicKey) + _ = m.Write(userPath(userID, ".key"), kp.PrivateKey) + _ = m.Write(userPath(userID, ".rev"), "REVOCATION_PLACEHOLDER") + + // Write legacy LTHN hash (this is what old Register did) + legacyHash := lthn.Hash("legacy-pass") + _ = m.Write(userPath(userID, ".lthn"), legacyHash) + + // No .hash file should exist yet + assert.False(t, m.IsFile(userPath(userID, ".hash"))) + + // Login with legacy hash should succeed + session, err := a.Login(userID, "legacy-pass") + require.NoError(t, err) + assert.NotEmpty(t, session.Token) + + // After successful login, .hash file should now exist with Argon2id + assert.True(t, m.IsFile(userPath(userID, ".hash")), "migration should create .hash file") + newHash, err := m.Read(userPath(userID, ".hash")) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(newHash, "$argon2id$"), "migrated hash should be Argon2id") + + // Subsequent login should use the new Argon2id hash (not LTHN) + session2, err := a.Login(userID, "legacy-pass") + require.NoError(t, err) + assert.NotEmpty(t, session2.Token) +} + +// TestLegacyLTHNLogin_Bad verifies wrong password fails for legacy LTHN users. +func TestLegacyLTHNLogin_Bad(t *testing.T) { + m := io.NewMockMedium() + a := New(m) + + userID := lthn.Hash("legacy-bad") + _ = m.EnsureDir("users") + + kp, err := pgp.CreateKeyPair(userID, userID+"@auth.local", "real-pass") + require.NoError(t, err) + + _ = m.Write(userPath(userID, ".pub"), kp.PublicKey) + _ = m.Write(userPath(userID, ".key"), kp.PrivateKey) + _ = m.Write(userPath(userID, ".lthn"), lthn.Hash("real-pass")) + + // Wrong password should fail + _, err = a.Login(userID, "wrong-pass") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid password") + + // No migration should have occurred + assert.False(t, m.IsFile(userPath(userID, ".hash")), "failed login should not create .hash file") +} + +// --- Phase 2: Key Rotation --- + +// TestRotateKeyPair_Good verifies the full key rotation flow: +// register -> login -> rotate -> verify old key can't decrypt -> verify new key works -> sessions invalidated. +func TestRotateKeyPair_Good(t *testing.T) { + a, m := newTestAuth() + + // Register and login + _, err := a.Register("rotate-user", "old-pass") + require.NoError(t, err) + userID := lthn.Hash("rotate-user") + + session, err := a.Login(userID, "old-pass") + require.NoError(t, err) + + // Read old public key for comparison + oldPubKey, err := m.Read(userPath(userID, ".pub")) + require.NoError(t, err) + + // Rotate keypair + updatedUser, err := a.RotateKeyPair(userID, "old-pass", "new-pass") + require.NoError(t, err) + require.NotNil(t, updatedUser) + + // New public key should differ from old + newPubKey, err := m.Read(userPath(userID, ".pub")) + require.NoError(t, err) + assert.NotEqual(t, oldPubKey, newPubKey, "public key should change after rotation") + assert.Equal(t, newPubKey, updatedUser.PublicKey) + + // Old password should fail + _, err = a.Login(userID, "old-pass") + assert.Error(t, err, "old password should not work after rotation") + + // New password should succeed + newSession, err := a.Login(userID, "new-pass") + require.NoError(t, err) + assert.NotEmpty(t, newSession.Token) + + // Old session should be invalidated + _, err = a.ValidateSession(session.Token) + assert.Error(t, err, "old session should be invalidated after rotation") + + // Metadata should be decryptable with new key + encMeta, err := m.Read(userPath(userID, ".json")) + require.NoError(t, err) + newPrivKey, err := m.Read(userPath(userID, ".key")) + require.NoError(t, err) + decrypted, err := pgp.Decrypt([]byte(encMeta), newPrivKey, "new-pass") + require.NoError(t, err) + + var meta User + err = json.Unmarshal(decrypted, &meta) + require.NoError(t, err) + assert.Equal(t, userID, meta.KeyID) + assert.True(t, strings.HasPrefix(meta.PasswordHash, "$argon2id$")) +} + +// TestRotateKeyPair_Bad verifies that rotation fails with wrong old password. +func TestRotateKeyPair_Bad(t *testing.T) { + a, _ := newTestAuth() + + _, err := a.Register("rotate-bad", "correct-pass") + require.NoError(t, err) + userID := lthn.Hash("rotate-bad") + + // Wrong old password should fail + _, err = a.RotateKeyPair(userID, "wrong-pass", "new-pass") + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to decrypt metadata") +} + +// TestRotateKeyPair_Ugly verifies rotation for non-existent user. +func TestRotateKeyPair_Ugly(t *testing.T) { + a, _ := newTestAuth() + + _, err := a.RotateKeyPair("nonexistent-user-id", "old", "new") + assert.Error(t, err) + assert.Contains(t, err.Error(), "user not found") +} + +// TestRotateKeyPair_OldKeyCannotDecrypt_Good verifies old private key +// cannot decrypt metadata after rotation. +func TestRotateKeyPair_OldKeyCannotDecrypt_Good(t *testing.T) { + a, m := newTestAuth() + + _, err := a.Register("rotate-crypto", "pass-a") + require.NoError(t, err) + userID := lthn.Hash("rotate-crypto") + + // Save old private key + oldPrivKey, err := m.Read(userPath(userID, ".key")) + require.NoError(t, err) + + // Rotate + _, err = a.RotateKeyPair(userID, "pass-a", "pass-b") + require.NoError(t, err) + + // Old private key should NOT be able to decrypt new metadata + encMeta, err := m.Read(userPath(userID, ".json")) + require.NoError(t, err) + _, err = pgp.Decrypt([]byte(encMeta), oldPrivKey, "pass-a") + assert.Error(t, err, "old private key should not decrypt metadata after rotation") +} + +// --- Phase 2: Key Revocation --- + +// TestRevokeKey_Good verifies the full revocation flow: +// register -> login -> revoke -> login fails -> challenge fails -> sessions invalidated. +func TestRevokeKey_Good(t *testing.T) { + a, m := newTestAuth() + + _, err := a.Register("revoke-user", "pass") + require.NoError(t, err) + userID := lthn.Hash("revoke-user") + + // Login to create a session + session, err := a.Login(userID, "pass") + require.NoError(t, err) + + // User should not be revoked yet + assert.False(t, a.IsRevoked(userID)) + + // Revoke the key + err = a.RevokeKey(userID, "pass", "compromised key material") + require.NoError(t, err) + + // User should now be revoked + assert.True(t, a.IsRevoked(userID)) + + // Verify .rev file contains valid JSON + revContent, err := m.Read(userPath(userID, ".rev")) + require.NoError(t, err) + assert.NotEqual(t, "REVOCATION_PLACEHOLDER", revContent) + + var rev Revocation + err = json.Unmarshal([]byte(revContent), &rev) + require.NoError(t, err) + assert.Equal(t, userID, rev.UserID) + assert.Equal(t, "compromised key material", rev.Reason) + assert.False(t, rev.RevokedAt.IsZero()) + + // Login should fail for revoked user + _, err = a.Login(userID, "pass") + assert.Error(t, err) + assert.Contains(t, err.Error(), "key has been revoked") + + // CreateChallenge should fail for revoked user + _, err = a.CreateChallenge(userID) + assert.Error(t, err) + assert.Contains(t, err.Error(), "key has been revoked") + + // Old session should be invalidated + _, err = a.ValidateSession(session.Token) + assert.Error(t, err) +} + +// TestRevokeKey_Bad verifies revocation fails with wrong password. +func TestRevokeKey_Bad(t *testing.T) { + a, _ := newTestAuth() + + _, err := a.Register("revoke-bad", "correct") + require.NoError(t, err) + userID := lthn.Hash("revoke-bad") + + err = a.RevokeKey(userID, "wrong", "test reason") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid password") + + // Should NOT be revoked after failed attempt + assert.False(t, a.IsRevoked(userID)) +} + +// TestRevokeKey_Ugly verifies revocation for non-existent user. +func TestRevokeKey_Ugly(t *testing.T) { + a, _ := newTestAuth() + + err := a.RevokeKey("nonexistent-user-id", "pass", "reason") + assert.Error(t, err) + assert.Contains(t, err.Error(), "user not found") +} + +// TestIsRevoked_Placeholder_Good verifies that the legacy placeholder is not +// treated as a valid revocation. +func TestIsRevoked_Placeholder_Good(t *testing.T) { + a, m := newTestAuth() + + _, err := a.Register("placeholder-user", "pass") + require.NoError(t, err) + userID := lthn.Hash("placeholder-user") + + // New registrations write "REVOCATION_PLACEHOLDER" + revContent, err := m.Read(userPath(userID, ".rev")) + require.NoError(t, err) + assert.Equal(t, "REVOCATION_PLACEHOLDER", revContent) + + // Should NOT be considered revoked + assert.False(t, a.IsRevoked(userID)) +} + +// TestIsRevoked_NoRevFile_Good verifies that a missing .rev file returns false. +func TestIsRevoked_NoRevFile_Good(t *testing.T) { + a, _ := newTestAuth() + + assert.False(t, a.IsRevoked("completely-nonexistent")) +} + +// TestRevokeKey_LegacyUser_Good verifies revocation works for a legacy user +// with only a .lthn hash file (no .hash file). +func TestRevokeKey_LegacyUser_Good(t *testing.T) { + m := io.NewMockMedium() + a := New(m) + + userID := lthn.Hash("legacy-revoke") + _ = m.EnsureDir("users") + + kp, err := pgp.CreateKeyPair(userID, userID+"@auth.local", "legacy-pass") + require.NoError(t, err) + + _ = m.Write(userPath(userID, ".pub"), kp.PublicKey) + _ = m.Write(userPath(userID, ".key"), kp.PrivateKey) + _ = m.Write(userPath(userID, ".rev"), "REVOCATION_PLACEHOLDER") + _ = m.Write(userPath(userID, ".lthn"), lthn.Hash("legacy-pass")) + + // Revoke with LTHN-verified password + err = a.RevokeKey(userID, "legacy-pass", "decommissioned") + require.NoError(t, err) + + assert.True(t, a.IsRevoked(userID)) +} diff --git a/auth/hardware.go b/auth/hardware.go new file mode 100644 index 0000000..c5017fd --- /dev/null +++ b/auth/hardware.go @@ -0,0 +1,51 @@ +// Package auth — hardware key integration points. +// +// This file defines the HardwareKey interface for future PKCS#11 / YubiKey +// integration. No concrete implementations exist yet; this is a contract-only +// definition that allows Authenticator to be wired up with hardware-backed +// cryptographic operations. +// +// Integration points in auth.go (search for "hardwareKey"): +// - CreateChallenge: could use HardwareKey.Decrypt instead of PGP +// - ValidateResponse: could use HardwareKey.Sign for challenge signing +// - Register: could use HardwareKey.GetPublicKey to store the HW public key +// - Login: could use HardwareKey.Sign for password-less auth +package auth + +// HardwareKey defines the contract for hardware-backed cryptographic operations. +// Implementations should wrap PKCS#11 tokens, YubiKeys, TPM modules, or +// similar tamper-resistant devices. +// +// All methods must be safe for concurrent use. +type HardwareKey interface { + // Sign produces a cryptographic signature over the given data using the + // hardware-stored private key. The signature format depends on the + // underlying device (e.g. ECDSA, RSA-PSS, EdDSA). + Sign(data []byte) ([]byte, error) + + // Decrypt decrypts ciphertext using the hardware-stored private key. + // The ciphertext format must match what the device expects (e.g. RSA-OAEP). + Decrypt(ciphertext []byte) ([]byte, error) + + // GetPublicKey returns the PEM or armored public key corresponding to the + // hardware-stored private key. + GetPublicKey() (string, error) + + // IsAvailable reports whether the hardware key device is currently + // connected and operational. Callers should check this before attempting + // Sign or Decrypt to provide graceful fallback behaviour. + IsAvailable() bool +} + +// WithHardwareKey configures the Authenticator to use a hardware key for +// cryptographic operations where supported. When set, the Authenticator may +// delegate signing, decryption, and public key retrieval to the hardware +// device instead of using software PGP keys. +// +// This is a forward-looking option — integration points are documented in +// auth.go but not yet wired up. +func WithHardwareKey(hk HardwareKey) Option { + return func(a *Authenticator) { + a.hardwareKey = hk + } +}