// 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 "forge.lthn.ai/core/go/pkg/framework/core" "forge.lthn.ai/core/go-crypt/crypt/lthn" "forge.lthn.ai/core/go-crypt/crypt/pgp" "forge.lthn.ai/core/go/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 }