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

236 lines
10 KiB
Markdown

# 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.