go-crypt/docs/history.md
Snider bbf2322389 docs: graduate TODO/FINDINGS into production documentation
Replace internal task tracking (TODO.md, FINDINGS.md) with structured
documentation in docs/. Trim CLAUDE.md to agent instructions only.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-20 15:01:55 +00:00

10 KiB

Project History — go-crypt

Origin

go-crypt was extracted from forge.lthn.ai/core/go on 16 February 2026 (extraction commit 8498ecf). The repository started with a single extraction commit — no prior per-file history. The original implementation was ported from dAppServer's mod-auth/lethean.service.ts (TypeScript).

At extraction the module contained ~1,938 source LOC across 14 files and ~1,770 test LOC (47.7% test ratio). The auth/ and trust/ packages each had strong test suites; crypt/ sub-packages varied from well-tested (chachapoly/, pgp/) to lightly covered (the top-level crypt.go).


Phase 0: Test Coverage and Hardening

Status: Complete.

auth/ additions: 8 new tests covering concurrent session creation (10 goroutines), session token uniqueness (1,000 tokens), challenge expiry boundary, empty password registration, very long username (10K characters), Unicode username and password, air-gapped round-trip, and refresh of an already-expired session. All pass under -race.

crypt/ additions: 12 new tests covering wrong passphrase decryption (ChaCha20 and AES), empty plaintext round-trip, 1MB payload round-trip (not 10MB — kept fast), ciphertext-too-short rejection, key derivation determinism (Argon2id and scrypt), HKDF with different info strings, HKDF with nil salt, checksum of empty file (SHA-256 and SHA-512), checksum of non-existent file, and checksum consistency with SHA256Sum.

trust/ additions: 9 new tests covering concurrent Register/Get/Remove (10 goroutines), Tier 0 and negative tier rejection, token expiry boundary, zero-value token expiry, concurrent List during mutations, empty ScopedRepos behaviour (documented as Finding F3), capability not in any list, and concurrent Evaluate.

Security audit: Full review of all source files for cryptographic hygiene. Findings documented below. go vet ./... produces no warnings.

Benchmark suite: crypt/bench_test.go (7 benchmarks: Argon2Derive, ChaCha20 1KB, ChaCha20 1MB, AESGCM 1KB, AESGCM 1MB, HMACSHA256 1KB, VerifyHMACSHA256) and trust/bench_test.go (3 benchmarks: PolicyEvaluate 100 agents, RegistryGet, RegistryRegister).


Phase 1: Session Persistence

Status: Complete. Commit 1aeabfd.

Extracted the in-memory session map into a SessionStore interface with Get, Set, Delete, DeleteByUser, and Cleanup methods. ErrSessionNotFound sentinel error added.

MemorySessionStore wraps the original map and mutex pattern. SQLiteSessionStore is backed by go-store (SQLite KV). Sessions are stored as JSON in a "sessions" group. A mutex serialises all operations for SQLite single-writer safety.

Background cleanup via StartCleanup(ctx, interval) goroutine. Stops on context cancellation. All existing tests updated and passing.


Phase 2: Key Management

Status: Complete. Commit 301eac1. 55 tests total, all pass under -race.

Step 2.1: Password Hash Migration (resolves Finding F1)

Register now uses crypt.HashPassword() (Argon2id) and writes a .hash file. Login detects the hash format: tries .hash (Argon2id) first, falls back to .lthn (LTHN). A successful legacy login transparently re-hashes with Argon2id and writes the .hash file (best-effort). A shared verifyPassword() helper handles the dual-path logic and is used by both Login and RevokeKey.

The verifyPassword helper was extracted after TestRevokeKey_Bad failed: new registrations do not write .lthn files, so the original fallback returned "user not found" instead of "invalid password".

Step 2.2: Key Rotation

RotateKeyPair(userID, oldPassword, newPassword) implements full key rotation: load private key → decrypt metadata with old password → generate new PGP keypair → re-encrypt metadata with new public key → overwrite .pub, .key, .json, .hash → invalidate sessions via store.DeleteByUser. 4 tests.

Step 2.3: Key Revocation

Chose JSON revocation record (Option B) over OpenPGP revocation certificate (Option A). Revocation{UserID, Reason, RevokedAt} is written as JSON to .rev. IsRevoked() parses JSON and ignores the legacy "REVOCATION_PLACEHOLDER" string. Both Login and CreateChallenge reject revoked users. 6 tests including legacy user revocation.

Step 2.4: Hardware Key Interface

hardware.go defines the HardwareKey interface: Sign, Decrypt, GetPublicKey, IsAvailable. Configured via WithHardwareKey() option. Contract-only — no concrete implementations exist. Integration points documented in auth.go comments but not wired.


Phase 3: Trust Policy Extensions

Status: Complete.

Approval Workflow

ApprovalQueue with Submit, Approve, Deny, Get, Pending, Len methods. Thread-safe with unique monotonic IDs, status tracking, reviewer attribution, and timestamps. 22 tests including concurrent and end-to-end integration with PolicyEngine.

Audit Log

AuditLog with append-only Record, Entries, EntriesFor, Len methods. Optional io.Writer for JSON-line persistence. Custom Decision MarshalJSON/UnmarshalJSON. 18 tests including writer errors and concurrent logging.

Dynamic Policies

LoadPolicies/LoadPoliciesFromFile parse JSON config. ApplyPolicies/ApplyPoliciesFromFile replace engine policies. ExportPolicies for round-trip serialisation. DisallowUnknownFields for strict parsing. 18 tests including round-trip.

Scope Wildcards

matchScope supports exact match, single-level wildcard (core/*), and recursive wildcard (core/**). repoAllowed updated to use pattern matching. 18 tests covering all edge cases including integration with PolicyEngine.


Security Audit Findings

Conducted 2026-02-20. Full audit reviewed all source files for cryptographic hygiene.

Finding F1: LTHN Hash Used for Password Verification (Medium) — RESOLVED

auth.Login() originally verified passwords via lthn.Verify(), which uses the LTHN quasi-salted hash (RFC-0004) with a non-constant-time string comparison. LTHN was designed for content identifiers, not passwords, and provides no random salt. Resolved in Phase 2 (commit 301eac1) by migrating to Argon2id with transparent legacy path migration.

Finding F2: PGP Private Keys Not Zeroed After Use (Low) — Open

In pgp.Decrypt() and pgp.Sign(), the private key is decrypted into memory via entity.PrivateKey.Decrypt() but the decrypted key material is not zeroed before garbage collection. The ProtonMail go-crypto library does not expose a Wipe() or Zero() method on packet.PrivateKey. Resolving this would require forking or patching the upstream library.

Severity is low: an attacker with read access to process memory already has full access to the process. The Go runtime does not guarantee memory zeroing and GC-managed runtimes inherently have this limitation.

Finding F3: Empty ScopedRepos Bypasses Scope Check on Tier 2 (Medium) — Open

In policy.go, the repo scope check is conditioned on len(agent.ScopedRepos) > 0. A Tier 2 agent with empty ScopedRepos (nil or []string{}) is treated as unrestricted rather than as having no access. If an admin registers a Tier 2 agent without explicitly setting ScopedRepos, it gets access to all repositories for repo-scoped capabilities (repo.push, pr.create, pr.merge, secrets.read).

Potential remediation: treat empty ScopedRepos as no access for Tier 2 agents, requiring explicit ["*"] or ["org/**"] for unrestricted access. This is a design decision with backward-compatibility implications.

Finding F4: go vet Clean — Passed

go vet ./... produces no warnings. All nonces use crypto/rand. No usage of math/rand detected. No secrets in error messages.


Known Limitations

Dual ChaCha20 Implementations

crypt/symmetric.go and crypt/chachapoly/chachapoly.go implement nearly identical ChaCha20-Poly1305 AEAD. The chachapoly sub-package pre-allocates capacity before appending, which is a minor optimisation for small payloads. The two packages have different import paths and test suites. Consolidation would reduce duplication but would require updating all importers.

LTHN Hash Non-Constant-Time Comparison

lthn.Verify() uses direct string comparison (==), not subtle.ConstantTimeCompare. This is acceptable because LTHN is for content identifiers where timing attacks are not a realistic threat model. However, the package comment and doc string document this explicitly to prevent misuse.

Policy Engine Does Not Enforce Workflow

PolicyEngine.Evaluate() returns NeedsApproval as a decision value but provides no enforcement. The caller is responsible for submitting to the ApprovalQueue and polling for resolution. A higher-level package (go-agentic or go-scm) must implement the actual enforcement layer.

Hardware Key Interface Is Contract-Only

The HardwareKey interface in auth/hardware.go has no concrete implementations. PKCS#11, YubiKey, and TPM backends are planned but not implemented. The Authenticator.hardwareKey field is never consulted in the current code.

Session Cleanup Prints to Stdout

StartCleanup logs via fmt.Printf rather than a structured logger. This is acceptable for a library that does not want to impose a logging dependency, but callers that need structured logs should wrap or replace the cleanup goroutine.


Future Considerations

  • Consolidate ChaCha20 wrappers: merge crypt/symmetric.go and crypt/chachapoly into a single implementation.
  • Hardware key backends: implement HardwareKey for PKCS#11 (via miekg/pkcs11 or ThalesIgnite/crypto11) and YubiKey (via go-piv).
  • Resolve Finding F3: require explicit wildcard for unrestricted Tier 2 access; treat empty ScopedRepos as no-access.
  • Structured logging: replace fmt.Printf in StartCleanup with an slog.Logger option on Authenticator.
  • Rate limiting enforcement: the Agent.RateLimit field is stored in the registry but never enforced. An enforcement layer (middleware, interceptor) is needed in the consuming service.
  • Policy persistence: PolicyEngine policies are in-memory only. A storage backend (similar to SQLiteSessionStore) would allow runtime policy changes to survive restarts.