Co-authored-by: Claude <developers@lethean.io> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4e2327b0c9
commit
091b6a73b9
2 changed files with 1036 additions and 0 deletions
455
pkg/auth/auth.go
Normal file
455
pkg/auth/auth.go
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
// Package auth implements OpenPGP challenge-response authentication with
|
||||
// support for both online (HTTP) and air-gapped (file-based) transport.
|
||||
//
|
||||
// Ported from dAppServer's mod-auth/lethean.service.ts.
|
||||
//
|
||||
// Authentication Flow (Online):
|
||||
//
|
||||
// 1. Client sends public key to server
|
||||
// 2. Server generates a random nonce, encrypts it with client's public key
|
||||
// 3. Client decrypts the nonce and signs it with their private key
|
||||
// 4. Server verifies the signature, creates a session token
|
||||
//
|
||||
// Authentication Flow (Air-Gapped / Courier):
|
||||
//
|
||||
// Same crypto but challenge/response are exchanged via files on a Medium.
|
||||
//
|
||||
// Storage Layout (via Medium):
|
||||
//
|
||||
// users/
|
||||
// {userID}.pub PGP public key (armored)
|
||||
// {userID}.key PGP private key (armored, password-encrypted)
|
||||
// {userID}.rev Revocation certificate (placeholder)
|
||||
// {userID}.json User metadata (encrypted with user's public key)
|
||||
// {userID}.lthn LTHN password hash
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "github.com/host-uk/core/pkg/framework/core"
|
||||
|
||||
"github.com/host-uk/core/pkg/crypt/lthn"
|
||||
"github.com/host-uk/core/pkg/crypt/pgp"
|
||||
"github.com/host-uk/core/pkg/io"
|
||||
)
|
||||
|
||||
// Default durations for challenge and session lifetimes.
|
||||
const (
|
||||
DefaultChallengeTTL = 5 * time.Minute
|
||||
DefaultSessionTTL = 24 * time.Hour
|
||||
nonceBytes = 32
|
||||
)
|
||||
|
||||
// protectedUsers lists usernames that cannot be deleted.
|
||||
// The "server" user holds the server keypair; deleting it would
|
||||
// permanently destroy all joining data and require a full rebuild.
|
||||
var protectedUsers = map[string]bool{
|
||||
"server": true,
|
||||
}
|
||||
|
||||
// User represents a registered user with PGP credentials.
|
||||
type User struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
KeyID string `json:"key_id"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PasswordHash string `json:"password_hash"` // LTHN hash
|
||||
Created time.Time `json:"created"`
|
||||
LastLogin time.Time `json:"last_login"`
|
||||
}
|
||||
|
||||
// Challenge is a PGP-encrypted nonce sent to a client during authentication.
|
||||
type Challenge struct {
|
||||
Nonce []byte `json:"nonce"`
|
||||
Encrypted string `json:"encrypted"` // PGP-encrypted nonce (armored)
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Session represents an authenticated session.
|
||||
type Session struct {
|
||||
Token string `json:"token"`
|
||||
UserID string `json:"user_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// Option configures an Authenticator.
|
||||
type Option func(*Authenticator)
|
||||
|
||||
// WithChallengeTTL sets the lifetime of a challenge before it expires.
|
||||
func WithChallengeTTL(d time.Duration) Option {
|
||||
return func(a *Authenticator) {
|
||||
a.challengeTTL = d
|
||||
}
|
||||
}
|
||||
|
||||
// WithSessionTTL sets the lifetime of a session before it expires.
|
||||
func WithSessionTTL(d time.Duration) Option {
|
||||
return func(a *Authenticator) {
|
||||
a.sessionTTL = d
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticator manages PGP-based challenge-response authentication.
|
||||
// All user data and keys are persisted through an io.Medium, which may
|
||||
// be backed by disk, memory (MockMedium), or any other storage backend.
|
||||
type Authenticator struct {
|
||||
medium io.Medium
|
||||
sessions map[string]*Session
|
||||
challenges map[string]*Challenge // userID -> pending challenge
|
||||
mu sync.RWMutex
|
||||
challengeTTL time.Duration
|
||||
sessionTTL time.Duration
|
||||
}
|
||||
|
||||
// New creates an Authenticator that persists user data via the given Medium.
|
||||
func New(m io.Medium, opts ...Option) *Authenticator {
|
||||
a := &Authenticator{
|
||||
medium: m,
|
||||
sessions: make(map[string]*Session),
|
||||
challenges: make(map[string]*Challenge),
|
||||
challengeTTL: DefaultChallengeTTL,
|
||||
sessionTTL: DefaultSessionTTL,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(a)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// userPath returns the storage path for a user artifact.
|
||||
func userPath(userID, ext string) string {
|
||||
return "users/" + userID + ext
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (a *Authenticator) Register(username, password string) (*User, error) {
|
||||
const op = "auth.Register"
|
||||
|
||||
userID := lthn.Hash(username)
|
||||
|
||||
// Check if user already exists
|
||||
if a.medium.IsFile(userPath(userID, ".pub")) {
|
||||
return nil, coreerr.E(op, "user already exists", nil)
|
||||
}
|
||||
|
||||
// Ensure users directory exists
|
||||
if err := a.medium.EnsureDir("users"); err != nil {
|
||||
return nil, coreerr.E(op, "failed to create users directory", err)
|
||||
}
|
||||
|
||||
// Generate PGP keypair
|
||||
kp, err := pgp.CreateKeyPair(userID, userID+"@auth.local", password)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "failed to create PGP keypair", err)
|
||||
}
|
||||
|
||||
// Store public key
|
||||
if err := a.medium.Write(userPath(userID, ".pub"), kp.PublicKey); err != nil {
|
||||
return nil, coreerr.E(op, "failed to write public key", err)
|
||||
}
|
||||
|
||||
// Store private key (already encrypted by PGP if password is non-empty)
|
||||
if err := a.medium.Write(userPath(userID, ".key"), kp.PrivateKey); err != nil {
|
||||
return nil, coreerr.E(op, "failed to write private key", err)
|
||||
}
|
||||
|
||||
// Store revocation certificate placeholder
|
||||
if err := a.medium.Write(userPath(userID, ".rev"), "REVOCATION_PLACEHOLDER"); err != nil {
|
||||
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 {
|
||||
return nil, coreerr.E(op, "failed to write password hash", err)
|
||||
}
|
||||
|
||||
// Build user metadata
|
||||
now := time.Now()
|
||||
user := &User{
|
||||
PublicKey: kp.PublicKey,
|
||||
KeyID: userID,
|
||||
Fingerprint: lthn.Hash(kp.PublicKey),
|
||||
PasswordHash: passwordHash,
|
||||
Created: now,
|
||||
LastLogin: time.Time{},
|
||||
}
|
||||
|
||||
// Encrypt metadata with the user's public key and store
|
||||
metaJSON, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "failed to marshal user metadata", err)
|
||||
}
|
||||
|
||||
encMeta, err := pgp.Encrypt(metaJSON, kp.PublicKey)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "failed to encrypt user metadata", err)
|
||||
}
|
||||
|
||||
if err := a.medium.Write(userPath(userID, ".json"), string(encMeta)); err != nil {
|
||||
return nil, coreerr.E(op, "failed to write user metadata", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CreateChallenge generates a cryptographic challenge for the given user.
|
||||
// A random nonce is created and encrypted with the user's PGP public key.
|
||||
// The client must decrypt the nonce and sign it to prove key ownership.
|
||||
func (a *Authenticator) CreateChallenge(userID string) (*Challenge, error) {
|
||||
const op = "auth.CreateChallenge"
|
||||
|
||||
// Read user's public key
|
||||
pubKey, err := a.medium.Read(userPath(userID, ".pub"))
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "user not found", err)
|
||||
}
|
||||
|
||||
// Generate random nonce
|
||||
nonce := make([]byte, nonceBytes)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return nil, coreerr.E(op, "failed to generate nonce", err)
|
||||
}
|
||||
|
||||
// Encrypt nonce with user's public key
|
||||
encrypted, err := pgp.Encrypt(nonce, pubKey)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "failed to encrypt nonce", err)
|
||||
}
|
||||
|
||||
challenge := &Challenge{
|
||||
Nonce: nonce,
|
||||
Encrypted: string(encrypted),
|
||||
ExpiresAt: time.Now().Add(a.challengeTTL),
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.challenges[userID] = challenge
|
||||
a.mu.Unlock()
|
||||
|
||||
return challenge, nil
|
||||
}
|
||||
|
||||
// ValidateResponse verifies a signed nonce from the client. The client must
|
||||
// have decrypted the challenge nonce and signed it with their private key.
|
||||
// On success, a new session is created and returned.
|
||||
func (a *Authenticator) ValidateResponse(userID string, signedNonce []byte) (*Session, error) {
|
||||
const op = "auth.ValidateResponse"
|
||||
|
||||
a.mu.Lock()
|
||||
challenge, exists := a.challenges[userID]
|
||||
if exists {
|
||||
delete(a.challenges, userID)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return nil, coreerr.E(op, "no pending challenge for user", nil)
|
||||
}
|
||||
|
||||
// Check challenge expiry
|
||||
if time.Now().After(challenge.ExpiresAt) {
|
||||
return nil, coreerr.E(op, "challenge expired", nil)
|
||||
}
|
||||
|
||||
// Read user's public key
|
||||
pubKey, err := a.medium.Read(userPath(userID, ".pub"))
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "user not found", err)
|
||||
}
|
||||
|
||||
// Verify signature over the original nonce
|
||||
if err := pgp.Verify(challenge.Nonce, signedNonce, pubKey); err != nil {
|
||||
return nil, coreerr.E(op, "signature verification failed", err)
|
||||
}
|
||||
|
||||
return a.createSession(userID)
|
||||
}
|
||||
|
||||
// ValidateSession checks whether a token maps to a valid, non-expired session.
|
||||
func (a *Authenticator) ValidateSession(token string) (*Session, error) {
|
||||
const op = "auth.ValidateSession"
|
||||
|
||||
a.mu.RLock()
|
||||
session, exists := a.sessions[token]
|
||||
a.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return nil, coreerr.E(op, "session not found", nil)
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
a.mu.Lock()
|
||||
delete(a.sessions, token)
|
||||
a.mu.Unlock()
|
||||
return nil, coreerr.E(op, "session expired", nil)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// RefreshSession extends the expiry of an existing valid session.
|
||||
func (a *Authenticator) RefreshSession(token string) (*Session, error) {
|
||||
const op = "auth.RefreshSession"
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
session, exists := a.sessions[token]
|
||||
if !exists {
|
||||
return nil, coreerr.E(op, "session not found", nil)
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
delete(a.sessions, token)
|
||||
return nil, coreerr.E(op, "session expired", nil)
|
||||
}
|
||||
|
||||
session.ExpiresAt = time.Now().Add(a.sessionTTL)
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// RevokeSession removes a session, invalidating the token immediately.
|
||||
func (a *Authenticator) RevokeSession(token string) error {
|
||||
const op = "auth.RevokeSession"
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if _, exists := a.sessions[token]; !exists {
|
||||
return coreerr.E(op, "session not found", nil)
|
||||
}
|
||||
|
||||
delete(a.sessions, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser removes a user and all associated keys from storage.
|
||||
// The "server" user is protected and cannot be deleted (mirroring the
|
||||
// original TypeScript implementation's safeguard).
|
||||
func (a *Authenticator) DeleteUser(userID string) error {
|
||||
const op = "auth.DeleteUser"
|
||||
|
||||
// Protect special users
|
||||
if protectedUsers[userID] {
|
||||
return coreerr.E(op, "cannot delete protected user", nil)
|
||||
}
|
||||
|
||||
// Check user exists
|
||||
if !a.medium.IsFile(userPath(userID, ".pub")) {
|
||||
return coreerr.E(op, "user not found", nil)
|
||||
}
|
||||
|
||||
// Remove all artifacts
|
||||
extensions := []string{".pub", ".key", ".rev", ".json", ".lthn"}
|
||||
for _, ext := range extensions {
|
||||
p := userPath(userID, ext)
|
||||
if a.medium.IsFile(p) {
|
||||
if err := a.medium.Delete(p); err != nil {
|
||||
return coreerr.E(op, "failed to delete "+ext, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Revoke any active sessions for this user
|
||||
a.mu.Lock()
|
||||
for token, session := range a.sessions {
|
||||
if session.UserID == userID {
|
||||
delete(a.sessions, token)
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login performs password-based authentication as a convenience method.
|
||||
// It verifies the password against the stored LTHN hash and, on success,
|
||||
// creates a new session. This bypasses the PGP challenge-response flow.
|
||||
func (a *Authenticator) Login(userID, password string) (*Session, error) {
|
||||
const op = "auth.Login"
|
||||
|
||||
// Read stored password hash
|
||||
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)
|
||||
}
|
||||
|
||||
return a.createSession(userID)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (a *Authenticator) WriteChallengeFile(userID, path string) error {
|
||||
const op = "auth.WriteChallengeFile"
|
||||
|
||||
challenge, err := a.CreateChallenge(userID)
|
||||
if err != nil {
|
||||
return coreerr.E(op, "failed to create challenge", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(challenge)
|
||||
if err != nil {
|
||||
return coreerr.E(op, "failed to marshal challenge", err)
|
||||
}
|
||||
|
||||
if err := a.medium.Write(path, string(data)); err != nil {
|
||||
return coreerr.E(op, "failed to write challenge file", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadResponseFile reads a signed response from a file and validates it,
|
||||
// completing the air-gapped authentication flow. The file must contain the
|
||||
// raw PGP signature bytes (armored).
|
||||
func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error) {
|
||||
const op = "auth.ReadResponseFile"
|
||||
|
||||
content, err := a.medium.Read(path)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "failed to read response file", err)
|
||||
}
|
||||
|
||||
session, err := a.ValidateResponse(userID, []byte(content))
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "failed to validate response", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// createSession generates a cryptographically random session token and
|
||||
// stores the session in the in-memory session map.
|
||||
func (a *Authenticator) createSession(userID string) (*Session, error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return nil, fmt.Errorf("auth: failed to generate session token: %w", err)
|
||||
}
|
||||
|
||||
session := &Session{
|
||||
Token: hex.EncodeToString(tokenBytes),
|
||||
UserID: userID,
|
||||
ExpiresAt: time.Now().Add(a.sessionTTL),
|
||||
}
|
||||
|
||||
a.mu.Lock()
|
||||
a.sessions[session.Token] = session
|
||||
a.mu.Unlock()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
581
pkg/auth/auth_test.go
Normal file
581
pkg/auth/auth_test.go
Normal file
|
|
@ -0,0 +1,581 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/host-uk/core/pkg/crypt/lthn"
|
||||
"github.com/host-uk/core/pkg/crypt/pgp"
|
||||
"github.com/host-uk/core/pkg/io"
|
||||
)
|
||||
|
||||
// helper creates a fresh Authenticator backed by MockMedium.
|
||||
func newTestAuth(opts ...Option) (*Authenticator, *io.MockMedium) {
|
||||
m := io.NewMockMedium()
|
||||
a := New(m, opts...)
|
||||
return a, m
|
||||
}
|
||||
|
||||
// --- Register ---
|
||||
|
||||
func TestRegister_Good(t *testing.T) {
|
||||
a, m := newTestAuth()
|
||||
|
||||
user, err := a.Register("alice", "hunter2")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
||||
userID := lthn.Hash("alice")
|
||||
|
||||
// Verify public key is stored
|
||||
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")))
|
||||
|
||||
// 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.False(t, user.Created.IsZero())
|
||||
}
|
||||
|
||||
func TestRegister_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Register first time succeeds
|
||||
_, err := a.Register("bob", "pass1")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Duplicate registration should fail
|
||||
_, err = a.Register("bob", "pass2")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user already exists")
|
||||
}
|
||||
|
||||
func TestRegister_Ugly(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Empty username/password should still work (PGP allows it)
|
||||
user, err := a.Register("", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
}
|
||||
|
||||
// --- CreateChallenge ---
|
||||
|
||||
func TestCreateChallenge_Good(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
user, err := a.Register("charlie", "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
challenge, err := a.CreateChallenge(user.KeyID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, challenge)
|
||||
|
||||
assert.Len(t, challenge.Nonce, nonceBytes)
|
||||
assert.NotEmpty(t, challenge.Encrypted)
|
||||
assert.True(t, challenge.ExpiresAt.After(time.Now()))
|
||||
}
|
||||
|
||||
func TestCreateChallenge_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Challenge for non-existent user
|
||||
_, err := a.CreateChallenge("nonexistent-user-id")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user not found")
|
||||
}
|
||||
|
||||
func TestCreateChallenge_Ugly(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Empty userID
|
||||
_, err := a.CreateChallenge("")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- ValidateResponse (full challenge-response flow) ---
|
||||
|
||||
func TestValidateResponse_Good(t *testing.T) {
|
||||
a, m := newTestAuth()
|
||||
|
||||
// Register user
|
||||
_, err := a.Register("dave", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
userID := lthn.Hash("dave")
|
||||
|
||||
// Create challenge
|
||||
challenge, err := a.CreateChallenge(userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Client-side: decrypt nonce, then sign it
|
||||
privKey, err := m.Read(userPath(userID, ".key"))
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "password123")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, challenge.Nonce, decryptedNonce)
|
||||
|
||||
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate response
|
||||
session, err := a.ValidateResponse(userID, signedNonce)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, session)
|
||||
|
||||
assert.NotEmpty(t, session.Token)
|
||||
assert.Equal(t, userID, session.UserID)
|
||||
assert.True(t, session.ExpiresAt.After(time.Now()))
|
||||
}
|
||||
|
||||
func TestValidateResponse_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
_, err := a.Register("eve", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("eve")
|
||||
|
||||
// No pending challenge
|
||||
_, err = a.ValidateResponse(userID, []byte("fake-signature"))
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no pending challenge")
|
||||
}
|
||||
|
||||
func TestValidateResponse_Ugly(t *testing.T) {
|
||||
a, m := newTestAuth(WithChallengeTTL(1 * time.Millisecond))
|
||||
|
||||
_, err := a.Register("frank", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("frank")
|
||||
|
||||
// Create challenge and let it expire
|
||||
challenge, err := a.CreateChallenge(userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
// Sign with valid key but expired challenge
|
||||
privKey, err := m.Read(userPath(userID, ".key"))
|
||||
require.NoError(t, err)
|
||||
|
||||
signedNonce, err := pgp.Sign(challenge.Nonce, privKey, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = a.ValidateResponse(userID, signedNonce)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "challenge expired")
|
||||
}
|
||||
|
||||
// --- ValidateSession ---
|
||||
|
||||
func TestValidateSession_Good(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
_, err := a.Register("grace", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("grace")
|
||||
|
||||
session, err := a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
validated, err := a.ValidateSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.Token, validated.Token)
|
||||
assert.Equal(t, userID, validated.UserID)
|
||||
}
|
||||
|
||||
func TestValidateSession_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
_, err := a.ValidateSession("nonexistent-token")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "session not found")
|
||||
}
|
||||
|
||||
func TestValidateSession_Ugly(t *testing.T) {
|
||||
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
|
||||
|
||||
_, err := a.Register("heidi", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("heidi")
|
||||
|
||||
session, err := a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
_, err = a.ValidateSession(session.Token)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "session expired")
|
||||
}
|
||||
|
||||
// --- RefreshSession ---
|
||||
|
||||
func TestRefreshSession_Good(t *testing.T) {
|
||||
a, _ := newTestAuth(WithSessionTTL(1 * time.Hour))
|
||||
|
||||
_, err := a.Register("ivan", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("ivan")
|
||||
|
||||
session, err := a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
originalExpiry := session.ExpiresAt
|
||||
|
||||
// Small delay to ensure time moves forward
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
refreshed, err := a.RefreshSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, refreshed.ExpiresAt.After(originalExpiry))
|
||||
}
|
||||
|
||||
func TestRefreshSession_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
_, err := a.RefreshSession("nonexistent-token")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "session not found")
|
||||
}
|
||||
|
||||
func TestRefreshSession_Ugly(t *testing.T) {
|
||||
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
|
||||
|
||||
_, err := a.Register("judy", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("judy")
|
||||
|
||||
session, err := a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
|
||||
_, err = a.RefreshSession(session.Token)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "session expired")
|
||||
}
|
||||
|
||||
// --- RevokeSession ---
|
||||
|
||||
func TestRevokeSession_Good(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
_, err := a.Register("karl", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("karl")
|
||||
|
||||
session, err := a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = a.RevokeSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Token should no longer be valid
|
||||
_, err = a.ValidateSession(session.Token)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRevokeSession_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
err := a.RevokeSession("nonexistent-token")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "session not found")
|
||||
}
|
||||
|
||||
func TestRevokeSession_Ugly(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Revoke empty token
|
||||
err := a.RevokeSession("")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- DeleteUser ---
|
||||
|
||||
func TestDeleteUser_Good(t *testing.T) {
|
||||
a, m := newTestAuth()
|
||||
|
||||
_, err := a.Register("larry", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("larry")
|
||||
|
||||
// Also create a session that should be cleaned up
|
||||
_, err = a.Login(userID, "pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = a.DeleteUser(userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// All files should be gone
|
||||
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, ".lthn")))
|
||||
|
||||
// Session should be gone
|
||||
a.mu.RLock()
|
||||
sessionCount := 0
|
||||
for _, s := range a.sessions {
|
||||
if s.UserID == userID {
|
||||
sessionCount++
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
assert.Equal(t, 0, sessionCount)
|
||||
}
|
||||
|
||||
func TestDeleteUser_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Protected user "server" cannot be deleted
|
||||
err := a.DeleteUser("server")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot delete protected user")
|
||||
}
|
||||
|
||||
func TestDeleteUser_Ugly(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Non-existent user
|
||||
err := a.DeleteUser("nonexistent-user-id")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user not found")
|
||||
}
|
||||
|
||||
// --- Login ---
|
||||
|
||||
func TestLogin_Good(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
_, err := a.Register("mallory", "secret")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("mallory")
|
||||
|
||||
session, err := a.Login(userID, "secret")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, session)
|
||||
|
||||
assert.NotEmpty(t, session.Token)
|
||||
assert.Equal(t, userID, session.UserID)
|
||||
assert.True(t, session.ExpiresAt.After(time.Now()))
|
||||
}
|
||||
|
||||
func TestLogin_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
_, err := a.Register("nancy", "correct-password")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("nancy")
|
||||
|
||||
// Wrong password
|
||||
_, err = a.Login(userID, "wrong-password")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid password")
|
||||
}
|
||||
|
||||
func TestLogin_Ugly(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Login for non-existent user
|
||||
_, err := a.Login("nonexistent-user-id", "pass")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "user not found")
|
||||
}
|
||||
|
||||
// --- WriteChallengeFile / ReadResponseFile (Air-Gapped) ---
|
||||
|
||||
func TestAirGappedFlow_Good(t *testing.T) {
|
||||
a, m := newTestAuth()
|
||||
|
||||
_, err := a.Register("oscar", "airgap-pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("oscar")
|
||||
|
||||
// Write challenge to file
|
||||
challengePath := "transfer/challenge.json"
|
||||
err = a.WriteChallengeFile(userID, challengePath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, m.IsFile(challengePath))
|
||||
|
||||
// Read challenge file to get the encrypted nonce (simulating courier)
|
||||
challengeData, err := m.Read(challengePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var challenge Challenge
|
||||
err = json.Unmarshal([]byte(challengeData), &challenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Client-side: decrypt nonce and sign it
|
||||
privKey, err := m.Read(userPath(userID, ".key"))
|
||||
require.NoError(t, err)
|
||||
|
||||
decryptedNonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "airgap-pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
signedNonce, err := pgp.Sign(decryptedNonce, privKey, "airgap-pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write signed response to file
|
||||
responsePath := "transfer/response.sig"
|
||||
err = m.Write(responsePath, string(signedNonce))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Server reads response file
|
||||
session, err := a.ReadResponseFile(userID, responsePath)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, session)
|
||||
|
||||
assert.NotEmpty(t, session.Token)
|
||||
assert.Equal(t, userID, session.UserID)
|
||||
}
|
||||
|
||||
func TestWriteChallengeFile_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Challenge for non-existent user
|
||||
err := a.WriteChallengeFile("nonexistent-user", "challenge.json")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReadResponseFile_Bad(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
// Response file does not exist
|
||||
_, err := a.ReadResponseFile("some-user", "nonexistent-file.sig")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReadResponseFile_Ugly(t *testing.T) {
|
||||
a, m := newTestAuth()
|
||||
|
||||
_, err := a.Register("peggy", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("peggy")
|
||||
|
||||
// Create a challenge
|
||||
_, err = a.CreateChallenge(userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Write garbage to response file
|
||||
responsePath := "transfer/bad-response.sig"
|
||||
err = m.Write(responsePath, "not-a-valid-signature")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = a.ReadResponseFile(userID, responsePath)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- Options ---
|
||||
|
||||
func TestWithChallengeTTL_Good(t *testing.T) {
|
||||
ttl := 30 * time.Second
|
||||
a, _ := newTestAuth(WithChallengeTTL(ttl))
|
||||
assert.Equal(t, ttl, a.challengeTTL)
|
||||
}
|
||||
|
||||
func TestWithSessionTTL_Good(t *testing.T) {
|
||||
ttl := 2 * time.Hour
|
||||
a, _ := newTestAuth(WithSessionTTL(ttl))
|
||||
assert.Equal(t, ttl, a.sessionTTL)
|
||||
}
|
||||
|
||||
// --- Full Round-Trip (Online Flow) ---
|
||||
|
||||
func TestFullRoundTrip_Good(t *testing.T) {
|
||||
a, m := newTestAuth()
|
||||
|
||||
// 1. Register
|
||||
user, err := a.Register("quinn", "roundtrip-pass")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, user)
|
||||
|
||||
userID := lthn.Hash("quinn")
|
||||
|
||||
// 2. Create challenge
|
||||
challenge, err := a.CreateChallenge(userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 3. Client decrypts + signs
|
||||
privKey, err := m.Read(userPath(userID, ".key"))
|
||||
require.NoError(t, err)
|
||||
|
||||
nonce, err := pgp.Decrypt([]byte(challenge.Encrypted), privKey, "roundtrip-pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
sig, err := pgp.Sign(nonce, privKey, "roundtrip-pass")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 4. Server validates, issues session
|
||||
session, err := a.ValidateResponse(userID, sig)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, session)
|
||||
|
||||
// 5. Validate session
|
||||
validated, err := a.ValidateSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.Token, validated.Token)
|
||||
|
||||
// 6. Refresh session
|
||||
refreshed, err := a.RefreshSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, session.Token, refreshed.Token)
|
||||
|
||||
// 7. Revoke session
|
||||
err = a.RevokeSession(session.Token)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 8. Session should be invalid now
|
||||
_, err = a.ValidateSession(session.Token)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- Concurrent Access ---
|
||||
|
||||
func TestConcurrentSessions_Good(t *testing.T) {
|
||||
a, _ := newTestAuth()
|
||||
|
||||
_, err := a.Register("ruth", "pass")
|
||||
require.NoError(t, err)
|
||||
userID := lthn.Hash("ruth")
|
||||
|
||||
// Create multiple sessions concurrently
|
||||
const n = 10
|
||||
sessions := make(chan *Session, n)
|
||||
errs := make(chan error, n)
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
s, err := a.Login(userID, "pass")
|
||||
if err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
sessions <- s
|
||||
}()
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case s := <-sessions:
|
||||
require.NotNil(t, s)
|
||||
// Validate each session
|
||||
_, err := a.ValidateSession(s.Token)
|
||||
assert.NoError(t, err)
|
||||
case err := <-errs:
|
||||
t.Fatalf("concurrent login failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue