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>
19 KiB
Architecture — go-crypt
forge.lthn.ai/core/go-crypt provides cryptographic primitives, authentication,
and a trust policy engine for the Lethean agent platform. The module is ~1,938
source LOC across three top-level packages (auth, crypt, trust) and five
sub-packages (crypt/chachapoly, crypt/lthn, crypt/pgp, crypt/rsa,
crypt/openpgp).
Package Map
go-crypt/
├── auth/ OpenPGP challenge-response authentication, sessions, key management
│ ├── auth.go Authenticator struct, registration, login, key rotation/revocation
│ ├── session_store.go SessionStore interface + MemorySessionStore
│ ├── session_store_sqlite.go SQLiteSessionStore (persistent via go-store)
│ └── hardware.go HardwareKey interface (contract only, no implementations)
├── crypt/ Symmetric encryption, key derivation, hashing
│ ├── crypt.go High-level Encrypt/Decrypt (ChaCha20) and EncryptAES/DecryptAES
│ ├── kdf.go DeriveKey (Argon2id), DeriveKeyScrypt, HKDF
│ ├── symmetric.go ChaCha20Encrypt/Decrypt, AESGCMEncrypt/Decrypt
│ ├── hash.go HashPassword/VerifyPassword (Argon2id), HashBcrypt/VerifyBcrypt
│ ├── hmac.go HMACSHA256, HMACSHA512, VerifyHMAC
│ ├── checksum.go SHA256File, SHA512File, SHA256Sum, SHA512Sum
│ ├── chachapoly/ Standalone ChaCha20-Poly1305 AEAD wrapper
│ ├── lthn/ RFC-0004 quasi-salted deterministic hash
│ ├── pgp/ OpenPGP primitives (ProtonMail go-crypto)
│ ├── rsa/ RSA OAEP-SHA256 key generation and encryption
│ └── openpgp/ Service wrapper implementing core.Crypt interface
└── trust/ Agent trust model and policy engine
├── trust.go Registry, Agent struct, Tier enum
├── policy.go PolicyEngine, 9 capabilities, Evaluate
├── approval.go ApprovalQueue for NeedsApproval workflow
├── audit.go AuditLog — append-only policy evaluation log
├── config.go LoadPolicies/ExportPolicies — JSON config round-trip
└── scope.go matchScope — wildcard pattern matching for repo scopes
crypt/ — Symmetric Encryption and Hashing
High-Level API (crypt.go)
The entry point for most callers. Encrypt/Decrypt chain Argon2id key
derivation with ChaCha20-Poly1305 AEAD:
Encrypt(plaintext, passphrase):
1. Generate 16-byte random salt (crypto/rand)
2. DeriveKey(passphrase, salt) → 32-byte key via Argon2id
3. ChaCha20Encrypt(plaintext, key) → 24-byte nonce || ciphertext
4. Output: salt || nonce || ciphertext
EncryptAES/DecryptAES follow the same structure but use AES-256-GCM
with a 12-byte nonce instead of the 24-byte XChaCha20 nonce.
Key Derivation (kdf.go)
Three KDF functions are provided:
| Function | Algorithm | Parameters |
|---|---|---|
DeriveKey |
Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 |
DeriveKeyScrypt |
scrypt | N=32768, r=8, p=1 |
HKDF |
HKDF-SHA256 | Variable key length, optional salt and info |
Argon2id parameters are within the OWASP recommended range for interactive
logins. HKDF is used for key expansion when a high-entropy secret is already
available (e.g. deriving sub-keys from a master key).
Low-Level Symmetric (symmetric.go)
ChaCha20Encrypt prepends the 24-byte nonce to the ciphertext and returns a
single byte slice. AESGCMEncrypt prepends the 12-byte nonce. Both use
crypto/rand for nonce generation. The ciphertext format self-describes the
nonce position; callers must not alter the layout between encrypt and decrypt.
Password Hashing (hash.go)
HashPassword produces an Argon2id format string:
$argon2id$v=19$m=65536,t=3,p=4$<base64-salt>$<base64-hash>
VerifyPassword re-derives the hash from the stored parameters and uses
crypto/subtle.ConstantTimeCompare for the final comparison. This avoids
timing side-channels during password verification.
HashBcrypt/VerifyBcrypt wrap golang.org/x/crypto/bcrypt as a fallback
for systems where bcrypt is required by policy.
HMAC (hmac.go)
HMACSHA256/HMACSHA512 return raw MAC bytes. VerifyHMAC uses
crypto/hmac.Equal (constant-time) to compare a computed MAC against an
expected value.
Checksums (checksum.go)
SHA256File/SHA512File compute checksums of files via streaming reads.
SHA256Sum/SHA512Sum operate on byte slices. All return lowercase hex strings.
crypt/chachapoly/
A standalone AEAD wrapper with slightly different capacity pre-allocation. The
nonce (24 bytes) is prepended to the ciphertext on encrypt and stripped on
decrypt. This package exists separately from crypt/symmetric.go for callers
that import only ChaCha20-Poly1305 without the full crypt package.
Note: the two implementations are nearly identical. The main difference is that
chachapoly pre-allocates cap(nonce) + len(plaintext) + overhead before
appending, which can reduce allocations for small payloads.
crypt/lthn/
RFC-0004 quasi-salted deterministic hash. The algorithm:
- Reverse the input string.
- Apply leet-speak character substitutions (
o→0,l→1,e→3,a→4,s→z,t→7, and inverses). - Concatenate original input with the derived quasi-salt.
- Return SHA-256 of the concatenation, hex-encoded.
This is deterministic — the same input always produces the same output. It is
designed for content identifiers, cache keys, and deduplication. It is not
suitable for password hashing because there is no random salt and the
comparison in Verify is not constant-time.
crypt/pgp/
OpenPGP primitives via github.com/ProtonMail/go-crypto:
CreateKeyPair(name, email, password)— generates a DSA primary key with an RSA encryption subkey; returns armored public and private keys.Encrypt(plaintext, publicKey)— produces an armored PGP message.Decrypt(ciphertext, privateKey, password)— decrypts an armored message.Sign(data, privateKey, password)— creates a detached armored signature.Verify(data, signature, publicKey)— verifies a detached signature.
PGP output is Base64-armored, which adds approximately 33% overhead relative to raw binary. For large payloads consider compression before encryption.
crypt/rsa/
RSA OAEP-SHA256. GenerateKeyPair(bits) generates an RSA keypair (minimum
2048 bit is enforced at the call site). Encrypt/Decrypt use
crypto/rsa.EncryptOAEP with SHA-256. Keys are serialised as PEM blocks.
crypt/openpgp/
Service wrapper that implements the core.Crypt interface from forge.lthn.ai/core/go.
Uses RSA-4096 with SHA-256 and AES-256. This is the only IPC-aware component
in go-crypt: HandleIPCEvents dispatches the "openpgp.create_key_pair" action
when registered with a Core instance.
auth/ — OpenPGP Authentication
Authenticator
The Authenticator struct manages all user identity operations. It takes an
io.Medium (from forge.lthn.ai/core/go) for storage and an optional
SessionStore for session persistence.
a := auth.New(medium,
auth.WithSessionStore(auth.NewSQLiteSessionStore("/var/lib/app/sessions.db")),
auth.WithSessionTTL(8*time.Hour),
auth.WithChallengeTTL(2*time.Minute),
)
Storage Layout
All user artefacts are stored under users/ on the Medium, keyed by a userID
derived from lthn.Hash(username):
| File | Content |
|---|---|
users/{userID}.pub |
Armored PGP public key |
users/{userID}.key |
Armored PGP private key (password-encrypted) |
users/{userID}.rev |
JSON revocation record, or legacy placeholder string |
users/{userID}.json |
User metadata, PGP-encrypted with the user's public key |
users/{userID}.hash |
Argon2id password hash (new registrations and migrated accounts) |
users/{userID}.lthn |
Legacy LTHN hash (pre-Phase-2 registrations only) |
Registration
Register(username, password):
- Derive
userID = lthn.Hash(username). - Check
users/{userID}.pubdoes not exist. pgp.CreateKeyPair(userID, ...)→ armored keypair.- Write
.pub,.key,.rev(placeholder). crypt.HashPassword(password)→ Argon2id hash string → write.hash.- JSON-marshal
Usermetadata, PGP-encrypt with public key, write.json.
Online Challenge-Response
Client Server
| |
|-- CreateChallenge(userID) -------> |
| | 1. Generate 32-byte nonce (crypto/rand)
| | 2. PGP-encrypt nonce with user's public key
| | 3. Store pending challenge (TTL: 5 min)
| <-- Challenge{Encrypted} --------- |
| |
| (client decrypts nonce, signs it) |
| |
|-- ValidateResponse(signedNonce) -> |
| | 4. Verify detached PGP signature
| | 5. Create session (32-byte token, 24h TTL)
| <-- Session{Token} --------------- |
Air-Gapped (Courier) Mode
WriteChallengeFile(userID, path) writes the encrypted challenge as JSON to
the Medium. The client signs the nonce offline. ReadResponseFile(userID, path)
reads the armored signature and calls ValidateResponse to complete authentication.
This mode supports agents or users who cannot receive live HTTP responses.
Password-Based Login
Login(userID, password) bypasses the PGP challenge-response flow and verifies
the password directly. It supports both hash formats via a dual-path strategy:
- If
users/{userID}.hashexists and starts with$argon2id$: verify withcrypt.VerifyPassword(constant-time Argon2id comparison). - Otherwise fall back to
users/{userID}.lthn: verify withlthn.Verify. On success, transparently re-hash the password with Argon2id and write a.hashfile (best-effort, does not fail the login if the write fails).
Key Management
Rotation (RotateKeyPair(userID, oldPassword, newPassword)):
- Load and decrypt current metadata using the old private key and password.
- Generate a new PGP keypair.
- Re-encrypt metadata with the new public key.
- Overwrite
.pub,.key,.json,.hash. - Invalidate all active sessions for the user via
store.DeleteByUser.
Revocation (RevokeKey(userID, password, reason)):
- Verify password (dual-path, same as Login).
- Write a
Revocation{UserID, Reason, RevokedAt}JSON record to.rev. - Invalidate all sessions.
IsRevokedreturns true only when the.revfile contains valid JSON with a non-zeroRevokedAt. The legacy"REVOCATION_PLACEHOLDER"string is treated as non-revoked for backward compatibility.- Both
LoginandCreateChallengereject revoked users immediately.
Protected users: The "server" userID cannot be deleted. It holds the
server keypair; deletion would permanently destroy the server's joining data.
Session Management
Sessions are managed through the SessionStore interface:
type SessionStore interface {
Get(token string) (*Session, error)
Set(session *Session) error
Delete(token string) error
DeleteByUser(userID string) error
Cleanup() (int, error)
}
Two implementations are provided:
| Implementation | Persistence | Concurrency |
|---|---|---|
MemorySessionStore |
None (lost on restart) | sync.RWMutex |
SQLiteSessionStore |
SQLite via go-store | Single mutex (SQLite single-writer) |
Session tokens are 32 bytes from crypto/rand, hex-encoded to 64 characters
(256-bit entropy). Expiry is checked on every ValidateSession and
RefreshSession call; expired sessions are deleted on access. Background
cleanup runs via StartCleanup(ctx, interval).
Hardware Key Interface
hardware.go defines a HardwareKey interface for future PKCS#11, YubiKey,
or TPM integration:
type HardwareKey interface {
Sign(data []byte) ([]byte, error)
Decrypt(ciphertext []byte) ([]byte, error)
GetPublicKey() (string, error)
IsAvailable() bool
}
Configured via WithHardwareKey(hk). Integration points are documented in
auth.go but not yet wired — there are no concrete implementations in this
module.
trust/ — Agent Trust and Policy Engine
Registry
Registry is a thread-safe map of agent names to Agent structs, protected by
sync.RWMutex. An Agent carries:
Name— unique identifier (e.g."Athena","BugSETI-42").Tier— trust level (1, 2, or 3).ScopedRepos— repository patterns constraining Tier 2 repo access.RateLimit— requests per minute (0 = unlimited for Tier 3).TokenExpiresAt— optional token expiry.
Default rate limits by tier: Tier 1 = 10/min, Tier 2 = 60/min, Tier 3 = unlimited.
Trust Tiers
| Tier | Name | Default Rate Limit | Typical Agents |
|---|---|---|---|
| 3 | Full | Unlimited | Athena, Virgil, Charon |
| 2 | Verified | 60/min | Clotho, Hypnos (scoped repos) |
| 1 | Untrusted | 10/min | BugSETI community instances |
Capabilities
Nine capabilities are defined:
| Capability | Description |
|---|---|
repo.push |
Push commits to a repository |
pr.create |
Open a pull request |
pr.merge |
Merge a pull request |
issue.create |
Create an issue |
issue.comment |
Comment on an issue |
secrets.read |
Read repository secrets |
cmd.privileged |
Run privileged shell commands |
workspace.access |
Access another agent's workspace |
flows.modify |
Modify CI/CD flow definitions |
Policy Engine
NewPolicyEngine(registry) loads default policies. Evaluation order in
Evaluate(agentName, cap, repo):
- Agent not in registry → Deny.
- No policy for agent's tier → Deny.
- Capability in
Deniedlist → Deny. - Capability in
RequiresApprovallist → NeedsApproval. - Capability in
Allowedlist:- If repo-scoped capability and
len(agent.ScopedRepos) > 0: check repo against scope patterns → Deny if no match. - Otherwise → Allow.
- If repo-scoped capability and
- Capability not in any list → Deny.
Default policies by tier:
| Tier | Allowed | RequiresApproval | Denied |
|---|---|---|---|
| Full (3) | All 9 capabilities | — | — |
| Verified (2) | repo.push, pr.create, issue.create, issue.comment, secrets.read | pr.merge | workspace.access, flows.modify, cmd.privileged |
| Untrusted (1) | pr.create, issue.comment | — | repo.push, pr.merge, issue.create, secrets.read, cmd.privileged, workspace.access, flows.modify |
Repo Scope Matching
matchScope(pattern, repo) supports three forms:
| Pattern | Matches | Does Not Match |
|---|---|---|
core/go-crypt |
core/go-crypt |
core/go-crypt/sub |
core/* |
core/go-crypt |
core/go-crypt/sub |
core/** |
core/go-crypt, core/go-crypt/sub |
other/repo |
Empty ScopedRepos on a Tier 2 agent is treated as unrestricted (no scope
check is applied). See known limitations in docs/history.md (Finding F3).
Approval Queue
ApprovalQueue is a thread-safe queue for NeedsApproval decisions. It is
separate from the PolicyEngine — the engine returns NeedsApproval as a
decision, and the caller is responsible for submitting to the queue and polling
for resolution. The queue tracks: submitting agent, capability, repo context,
status (pending/approved/denied), reviewer identity, and timestamps.
Audit Log
AuditLog records every policy evaluation as an AuditEntry. Entries are
stored in-memory and optionally streamed as JSON lines to an io.Writer for
persistence. Decision marshals to/from string ("allow", "deny",
"needs_approval"). EntriesFor(agent) filters by agent name.
Dynamic Policy Configuration
Policies can be loaded from JSON and applied at runtime:
engine.ApplyPoliciesFromFile("/etc/agent/policies.json")
// Export current state
engine.ExportPolicies(os.Stdout)
JSON format:
{
"policies": [
{
"tier": 1,
"allowed": ["pr.create", "issue.comment"],
"denied": ["repo.push", "pr.merge"]
}
]
}
json.Decoder.DisallowUnknownFields() is set during load to catch
configuration errors early.
Algorithm Reference
| Component | Algorithm | Parameters |
|---|---|---|
| KDF (primary) | Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 |
| KDF (alternative) | scrypt | N=32768, r=8, p=1 |
| KDF (expansion) | HKDF-SHA256 | Variable key length |
| Symmetric (primary) | ChaCha20-Poly1305 | 24-byte nonce (XChaCha20), 32-byte key |
| Symmetric (alternative) | AES-256-GCM | 12-byte nonce, 32-byte key |
| Password hash | Argon2id | Custom $argon2id$ format string with random salt |
| Password hash (legacy) | LTHN quasi-salted SHA-256 | RFC-0004 (deterministic, no random salt) |
| Password hash (fallback) | Bcrypt | Configurable cost |
| Content ID | LTHN quasi-salted SHA-256 | RFC-0004 |
| Asymmetric | RSA-OAEP-SHA256 | 2048+ bit |
| PGP keypair | DSA primary + RSA subkey | ProtonMail go-crypto |
| PGP service | RSA-4096 + AES-256 + SHA-256 | core.Crypt interface |
| HMAC | HMAC-SHA256 / HMAC-SHA512 | Constant-time verify |
| Challenge nonce | crypto/rand | 32 bytes (256-bit) |
| Session token | crypto/rand | 32 bytes, hex-encoded (64 chars) |
Dependencies
| Module | Version | Role |
|---|---|---|
forge.lthn.ai/core/go |
local | core.E error helper, core.Crypt interface, io.Medium storage |
forge.lthn.ai/core/go-store |
local | SQLite KV store for session persistence |
github.com/ProtonMail/go-crypto |
v1.3.0 | OpenPGP (actively maintained fork, post-quantum research) |
golang.org/x/crypto |
v0.48.0 | Argon2, ChaCha20-Poly1305, scrypt, HKDF, bcrypt |
github.com/cloudflare/circl |
v1.6.3 | Indirect; elliptic curves via ProtonMail |
Integration Points
| Consumer | Package Used | Purpose |
|---|---|---|
| go-p2p | crypt/ |
UEPS consent-gated encryption |
| go-scm / AgentCI | trust/ |
Agent capability evaluation before CI operations |
| go-agentic | auth/ |
Agent session management |
| core/go | crypt/openpgp/ |
Service registered via core.Crypt interface |
Security Notes
- The LTHN hash (
crypt/lthn) is not suitable for password hashing. It is deterministic with no random salt. Usecrypt.HashPassword(Argon2id). - PGP private keys are not zeroed after use. The ProtonMail
go-cryptolibrary does not expose aWipemethod. This is a known upstream limitation; mitigating it would require forking the library. - Empty
ScopedReposon a Tier 2 agent currently bypasses the repo scope check (treated as unrestricted). Explicit["*"]or["org/**"]should be required for unrestricted Tier 2 access if this design is revisited. - The
PolicyEnginereturns decisions but does not enforce the approval workflow. A higher-level layer (go-agentic, go-scm) must handle theNeedsApprovalcase by routing through theApprovalQueue. - The
MemorySessionStoreis the default. UseWithSessionStore(NewSQLiteSessionStore(path))for persistence across restarts.