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:
parent
91a290dc03
commit
301eac1d76
3 changed files with 651 additions and 16 deletions
258
auth/auth.go
258
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) {
|
||||
|
|
|
|||
|
|
@ -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
51
auth/hardware.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue