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>
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.goandcrypt/chachapolyinto a single implementation. - Hardware key backends: implement
HardwareKeyfor PKCS#11 (viamiekg/pkcs11orThalesIgnite/crypto11) and YubiKey (viago-piv). - Resolve Finding F3: require explicit wildcard for unrestricted Tier 2
access; treat empty
ScopedReposas no-access. - Structured logging: replace
fmt.PrintfinStartCleanupwith anslog.Loggeroption onAuthenticator. - Rate limiting enforcement: the
Agent.RateLimitfield is stored in the registry but never enforced. An enforcement layer (middleware, interceptor) is needed in the consuming service. - Policy persistence:
PolicyEnginepolicies are in-memory only. A storage backend (similar toSQLiteSessionStore) would allow runtime policy changes to survive restarts.