feat(auth): Phase 2 key management — Argon2id, rotation, revocation

- Register now uses Argon2id (crypt.HashPassword) instead of LTHN hash
- Login detects hash format: Argon2id (.hash) first, LTHN (.lthn) fallback
- Transparent migration: successful legacy login re-hashes with Argon2id
- RotateKeyPair: decrypt metadata with old password, generate new PGP
  keypair, re-encrypt, update hash, invalidate all sessions
- RevokeKey: write JSON revocation record to .rev, invalidate sessions
- IsRevoked: parse .rev for valid JSON (ignores legacy placeholder)
- Login/CreateChallenge reject revoked users
- HardwareKey interface (hardware.go): contract for PKCS#11/YubiKey
- verifyPassword helper: shared Argon2id→LTHN fallback logic
- 55 tests total, all pass with -race

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-02-20 02:27:03 +00:00
parent 91a290dc03
commit 301eac1d76
3 changed files with 651 additions and 16 deletions

View file

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

View file

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

51
auth/hardware.go Normal file
View file

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