docs: add human-friendly documentation
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 29s

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-11 13:02:40 +00:00
parent e561e1ee1f
commit a009a8d1eb
3 changed files with 697 additions and 355 deletions

View file

@ -1,207 +1,299 @@
# Architecture — go-crypt ---
title: Architecture
`forge.lthn.ai/core/go-crypt` provides cryptographic primitives, authentication, description: Internal design, key types, data flow, and algorithm reference for go-crypt.
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 # Architecture
`forge.lthn.ai/core/go-crypt` is organised into three top-level packages
(`crypt`, `auth`, `trust`) and five sub-packages under `crypt/`. Each
package is self-contained and can be imported independently.
``` ```
go-crypt/ go-crypt/
├── auth/ OpenPGP challenge-response authentication, sessions, key management ├── auth/ OpenPGP challenge-response authentication
│ ├── auth.go Authenticator struct, registration, login, key rotation/revocation │ ├── auth.go Authenticator: registration, login, key rotation, revocation
│ ├── session_store.go SessionStore interface + MemorySessionStore │ ├── session_store.go SessionStore interface + MemorySessionStore
│ ├── session_store_sqlite.go SQLiteSessionStore (persistent via go-store) │ ├── session_store_sqlite.go SQLiteSessionStore (persistent via go-store)
│ └── hardware.go HardwareKey interface (contract only, no implementations) │ └── hardware.go HardwareKey interface (contract only, no implementations yet)
├── crypt/ Symmetric encryption, key derivation, hashing ├── crypt/ Symmetric encryption, hashing, key derivation
│ ├── crypt.go High-level Encrypt/Decrypt (ChaCha20) and EncryptAES/DecryptAES │ ├── crypt.go High-level Encrypt/Decrypt and EncryptAES/DecryptAES
│ ├── kdf.go DeriveKey (Argon2id), DeriveKeyScrypt, HKDF │ ├── kdf.go Key derivation: Argon2id, scrypt, HKDF-SHA256
│ ├── symmetric.go ChaCha20Encrypt/Decrypt, AESGCMEncrypt/Decrypt │ ├── symmetric.go Low-level ChaCha20-Poly1305 and AES-256-GCM
│ ├── hash.go HashPassword/VerifyPassword (Argon2id), HashBcrypt/VerifyBcrypt │ ├── hash.go Password hashing: Argon2id and bcrypt
│ ├── hmac.go HMACSHA256, HMACSHA512, VerifyHMAC │ ├── hmac.go HMAC-SHA256, HMAC-SHA512, constant-time verify
│ ├── checksum.go SHA256File, SHA512File, SHA256Sum, SHA512Sum │ ├── checksum.go SHA-256 and SHA-512 file/data checksums
│ ├── chachapoly/ Standalone ChaCha20-Poly1305 AEAD wrapper │ ├── chachapoly/ Standalone ChaCha20-Poly1305 AEAD wrapper
│ ├── lthn/ RFC-0004 quasi-salted deterministic hash │ ├── lthn/ RFC-0004 quasi-salted deterministic hash
│ ├── pgp/ OpenPGP primitives (ProtonMail go-crypto) │ ├── pgp/ OpenPGP primitives (ProtonMail go-crypto)
│ ├── rsa/ RSA OAEP-SHA256 key generation and encryption │ ├── rsa/ RSA-OAEP-SHA256 key generation and encryption
│ └── openpgp/ Service wrapper implementing core.Crypt interface │ └── openpgp/ Service wrapper implementing core.Crypt interface
└── trust/ Agent trust model and policy engine ├── trust/ Agent trust model and policy engine
├── trust.go Registry, Agent struct, Tier enum │ ├── trust.go Registry, Agent struct, Tier enum
├── policy.go PolicyEngine, 9 capabilities, Evaluate │ ├── policy.go PolicyEngine, capabilities, Evaluate()
├── approval.go ApprovalQueue for NeedsApproval workflow │ ├── approval.go ApprovalQueue for NeedsApproval decisions
├── audit.go AuditLog — append-only policy evaluation log │ ├── audit.go AuditLog: append-only policy evaluation log
├── config.go LoadPolicies/ExportPolicies — JSON config round-trip │ └── config.go JSON policy configuration: load, apply, export
└── scope.go matchScope — wildcard pattern matching for repo scopes └── cmd/
└── crypt/ CLI commands registered with core CLI
``` ```
--- ---
## crypt/ Symmetric Encryption and Hashing ## crypt/ -- Symmetric Encryption and Hashing
### High-Level API (`crypt.go`) ### High-Level API
The entry point for most callers. `Encrypt`/`Decrypt` chain Argon2id key The `crypt.Encrypt` and `crypt.Decrypt` functions are the primary entry
derivation with ChaCha20-Poly1305 AEAD: points. They chain Argon2id key derivation with XChaCha20-Poly1305 AEAD
encryption. A random salt is generated and prepended to the output so that
callers need only track the passphrase.
``` ```
Encrypt(plaintext, passphrase): Encrypt(plaintext, passphrase) -> salt || nonce || ciphertext
1. Generate 16-byte random salt (crypto/rand) 1. Generate 16-byte random salt (crypto/rand)
2. DeriveKey(passphrase, salt) 32-byte key via Argon2id 2. DeriveKey(passphrase, salt) -> 32-byte key via Argon2id
3. ChaCha20Encrypt(plaintext, key) 24-byte nonce || ciphertext 3. ChaCha20Encrypt(plaintext, key) -> 24-byte nonce || ciphertext
4. Output: salt || nonce || ciphertext 4. Prepend salt to the result
``` ```
`EncryptAES`/`DecryptAES` follow the same structure but use AES-256-GCM `EncryptAES` and `DecryptAES` follow the same pattern but use AES-256-GCM
with a 12-byte nonce instead of the 24-byte XChaCha20 nonce. with a 12-byte nonce instead of the 24-byte XChaCha20 nonce.
### Key Derivation (`kdf.go`) Both ciphers produce self-describing byte layouts. Callers must not alter
the layout between encrypt and decrypt.
Three KDF functions are provided: | Function | Cipher | Nonce | Wire Format |
|----------|--------|-------|-------------|
| `Encrypt` / `Decrypt` | XChaCha20-Poly1305 | 24 bytes | salt(16) + nonce(24) + ciphertext |
| `EncryptAES` / `DecryptAES` | AES-256-GCM | 12 bytes | salt(16) + nonce(12) + ciphertext |
| Function | Algorithm | Parameters | ### Key Derivation (kdf.go)
|----------|-----------|------------|
| `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 Three key derivation functions serve different use cases:
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`) | Function | Algorithm | Parameters | Use Case |
|----------|-----------|------------|----------|
| `DeriveKey` | Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 | Primary KDF for passphrase-based encryption |
| `DeriveKeyScrypt` | scrypt | N=32768, r=8, p=1 | Alternative KDF where Argon2id is unavailable |
| `HKDF` | HKDF-SHA256 | Variable key length, optional salt/info | Key expansion from high-entropy secrets |
`ChaCha20Encrypt` prepends the 24-byte nonce to the ciphertext and returns a The Argon2id parameters sit within the OWASP-recommended range for
single byte slice. `AESGCMEncrypt` prepends the 12-byte nonce. Both use interactive logins. `HKDF` is intended for deriving sub-keys from a master
`crypto/rand` for nonce generation. The ciphertext format self-describes the key that already has high entropy; it should not be used directly with
nonce position; callers must not alter the layout between encrypt and decrypt. low-entropy passphrases.
### Password Hashing (`hash.go`) ### Low-Level Symmetric Ciphers (symmetric.go)
`HashPassword` produces an Argon2id format string: `ChaCha20Encrypt` and `AESGCMEncrypt` each generate a random nonce via
`crypto/rand` and prepend it to the ciphertext. The corresponding decrypt
functions extract the nonce from the front of the byte slice.
```go
// ChaCha20-Poly1305: 32-byte key required
ciphertext, err := crypt.ChaCha20Encrypt(plaintext, key)
plaintext, err := crypt.ChaCha20Decrypt(ciphertext, key)
// AES-256-GCM: 32-byte key required
ciphertext, err := crypt.AESGCMEncrypt(plaintext, key)
plaintext, err := crypt.AESGCMDecrypt(ciphertext, key)
```
### Password Hashing (hash.go)
`HashPassword` produces a self-describing Argon2id hash string:
``` ```
$argon2id$v=19$m=65536,t=3,p=4$<base64-salt>$<base64-hash> $argon2id$v=19$m=65536,t=3,p=4$<base64-salt>$<base64-hash>
``` ```
`VerifyPassword` re-derives the hash from the stored parameters and uses `VerifyPassword` re-derives the hash from the parameters encoded in the
`crypto/subtle.ConstantTimeCompare` for the final comparison. This avoids string and uses `crypto/subtle.ConstantTimeCompare` for the final
timing side-channels during password verification. comparison. This prevents timing side-channels during password verification.
`HashBcrypt`/`VerifyBcrypt` wrap `golang.org/x/crypto/bcrypt` as a fallback `HashBcrypt` and `VerifyBcrypt` wrap `golang.org/x/crypto/bcrypt` as a
for systems where bcrypt is required by policy. fallback for environments where bcrypt is mandated by policy.
### HMAC (`hmac.go`) ### HMAC (hmac.go)
`HMACSHA256`/`HMACSHA512` return raw MAC bytes. `VerifyHMAC` uses Three functions for message authentication codes:
`crypto/hmac.Equal` (constant-time) to compare a computed MAC against an
expected value.
### Checksums (`checksum.go`) - `HMACSHA256(message, key)` -- returns raw 32-byte MAC.
- `HMACSHA512(message, key)` -- returns raw 64-byte MAC.
- `VerifyHMAC(message, key, mac, hashFunc)` -- constant-time verification
using `crypto/hmac.Equal`.
`SHA256File`/`SHA512File` compute checksums of files via streaming reads. ### Checksums (checksum.go)
`SHA256Sum`/`SHA512Sum` operate on byte slices. All return lowercase hex strings.
File checksums use streaming reads to handle arbitrarily large files without
loading them entirely into memory:
```go
hash, err := crypt.SHA256File("/path/to/file") // hex string
hash, err := crypt.SHA512File("/path/to/file") // hex string
// In-memory checksums
hash := crypt.SHA256Sum(data) // hex string
hash := crypt.SHA512Sum(data) // hex string
```
### crypt/chachapoly/ ### crypt/chachapoly/
A standalone AEAD wrapper with slightly different capacity pre-allocation. The A standalone ChaCha20-Poly1305 package that can be imported independently.
nonce (24 bytes) is prepended to the ciphertext on encrypt and stripped on It pre-allocates `cap(nonce) + len(plaintext) + overhead` before appending,
decrypt. This package exists separately from `crypt/symmetric.go` for callers which reduces allocations for small payloads.
that import only ChaCha20-Poly1305 without the full `crypt` package.
Note: the two implementations are nearly identical. The main difference is that ```go
`chachapoly` pre-allocates `cap(nonce) + len(plaintext) + overhead` before import "forge.lthn.ai/core/go-crypt/crypt/chachapoly"
appending, which can reduce allocations for small payloads.
### crypt/lthn/ ciphertext, err := chachapoly.Encrypt(plaintext, key) // key must be 32 bytes
plaintext, err := chachapoly.Decrypt(ciphertext, key)
```
RFC-0004 quasi-salted deterministic hash. The algorithm: This is functionally identical to `crypt.ChaCha20Encrypt` and exists as a
separate import path for callers that only need the AEAD primitive.
### crypt/lthn/ -- RFC-0004 Deterministic Hash
The LTHN hash produces a deterministic, verifiable identifier from any
input string. The algorithm:
1. Reverse the input string. 1. Reverse the input string.
2. Apply leet-speak character substitutions (`o``0`, `l``1`, `e``3`, 2. Apply "leet speak" character substitutions (`o` to `0`, `l` to `1`,
`a``4`, `s``z`, `t``7`, and inverses). `e` to `3`, `a` to `4`, `s` to `z`, `t` to `7`, and their inverses).
3. Concatenate original input with the derived quasi-salt. 3. Concatenate the original input with the derived quasi-salt.
4. Return SHA-256 of the concatenation, hex-encoded. 4. Return the SHA-256 digest, hex-encoded (64 characters).
This is deterministic — the same input always produces the same output. It is ```go
designed for content identifiers, cache keys, and deduplication. It is **not** import "forge.lthn.ai/core/go-crypt/crypt/lthn"
suitable for password hashing because there is no random salt and the
comparison in `Verify` is not constant-time.
### crypt/pgp/ hash := lthn.Hash("hello")
valid := lthn.Verify("hello", hash) // true
```
OpenPGP primitives via `github.com/ProtonMail/go-crypto`: The substitution map can be customised via `lthn.SetKeyMap()` for
application-specific derivation.
- `CreateKeyPair(name, email, password)` — generates a DSA primary key with an **Important**: LTHN is designed for content identifiers, cache keys, and
RSA encryption subkey; returns armored public and private keys. deduplication. It is not suitable for password hashing because it uses no
- `Encrypt(plaintext, publicKey)` — produces an armored PGP message. random salt and the comparison in `Verify` uses `subtle.ConstantTimeCompare`
- `Decrypt(ciphertext, privateKey, password)` — decrypts an armored message. but the hash itself is deterministic and fast.
- `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 ### crypt/pgp/ -- OpenPGP Primitives
to raw binary. For large payloads consider compression before encryption.
### crypt/rsa/ Full OpenPGP support via `github.com/ProtonMail/go-crypto`:
RSA OAEP-SHA256. `GenerateKeyPair(bits)` generates an RSA keypair (minimum ```go
2048 bit is enforced at the call site). `Encrypt`/`Decrypt` use import "forge.lthn.ai/core/go-crypt/crypt/pgp"
`crypto/rsa.EncryptOAEP` with SHA-256. Keys are serialised as PEM blocks.
### crypt/openpgp/ // Generate a keypair (private key optionally password-protected)
kp, err := pgp.CreateKeyPair("Alice", "alice@example.com", "password")
// kp.PublicKey -- armored PGP public key
// kp.PrivateKey -- armored PGP private key
Service wrapper that implements the `core.Crypt` interface from `forge.lthn.ai/core/go`. // Encrypt data for a recipient
Uses RSA-4096 with SHA-256 and AES-256. This is the only IPC-aware component ciphertext, err := pgp.Encrypt(data, kp.PublicKey)
in go-crypt: `HandleIPCEvents` dispatches the `"openpgp.create_key_pair"` action
when registered with a Core instance. // Decrypt with private key
plaintext, err := pgp.Decrypt(ciphertext, kp.PrivateKey, "password")
// Sign data (detached armored signature)
signature, err := pgp.Sign(data, kp.PrivateKey, "password")
// Verify a detached signature
err := pgp.Verify(data, signature, kp.PublicKey)
```
All PGP output is Base64-armored, adding approximately 33% overhead
relative to raw binary. For large payloads, consider compression before
encryption.
### crypt/rsa/ -- RSA-OAEP
RSA encryption with OAEP-SHA256 padding. A minimum key size of 2048 bits
is enforced at the API level.
```go
import "forge.lthn.ai/core/go-crypt/crypt/rsa"
svc := rsa.NewService()
// Generate a keypair (PEM-encoded)
pubKey, privKey, err := svc.GenerateKeyPair(4096)
// Encrypt / Decrypt with optional label
ciphertext, err := svc.Encrypt(pubKey, plaintext, label)
plaintext, err := svc.Decrypt(privKey, ciphertext, label)
```
### crypt/openpgp/ -- Core Service Integration
A service wrapper that implements the `core.Crypt` interface from
`forge.lthn.ai/core/go`. This is the only component in go-crypt that
integrates with the Core framework's IPC system.
```go
import "forge.lthn.ai/core/go-crypt/crypt/openpgp"
// Register as a Core service
core.New(core.WithService(openpgp.New))
// Handles the "openpgp.create_key_pair" IPC action
```
The service generates RSA-4096 keypairs with SHA-256 hashing and AES-256
encryption for private key protection.
--- ---
## auth/ — OpenPGP Authentication ## auth/ -- OpenPGP Authentication
### Authenticator ### Authenticator
The `Authenticator` struct manages all user identity operations. It takes an The `Authenticator` struct is the central type for user identity operations.
`io.Medium` (from `forge.lthn.ai/core/go`) for storage and an optional It takes an `io.Medium` for storage and supports functional options for
`SessionStore` for session persistence. configuration.
```go ```go
a := auth.New(medium, a := auth.New(medium,
auth.WithSessionStore(auth.NewSQLiteSessionStore("/var/lib/app/sessions.db")), auth.WithSessionStore(sqliteStore),
auth.WithSessionTTL(8*time.Hour), auth.WithSessionTTL(8 * time.Hour),
auth.WithChallengeTTL(2*time.Minute), auth.WithChallengeTTL(2 * time.Minute),
auth.WithHardwareKey(yubikey), // future: hardware-backed crypto
) )
``` ```
### Storage Layout ### Storage Layout
All user artefacts are stored under `users/` on the Medium, keyed by a userID All user artefacts are stored under `users/` on the Medium, keyed by a
derived from `lthn.Hash(username)`: userID derived from `lthn.Hash(username)`:
| File | Content | | File | Content |
|------|---------| |------|---------|
| `users/{userID}.pub` | Armored PGP public key | | `users/{userID}.pub` | Armored PGP public key |
| `users/{userID}.key` | Armored PGP private key (password-encrypted) | | `users/{userID}.key` | Armored PGP private key (password-encrypted) |
| `users/{userID}.rev` | JSON revocation record, or legacy placeholder string | | `users/{userID}.rev` | JSON revocation record, or legacy placeholder |
| `users/{userID}.json` | User metadata, PGP-encrypted with the user's public key | | `users/{userID}.json` | User metadata (PGP-encrypted with user's public key) |
| `users/{userID}.hash` | Argon2id password hash (new registrations and migrated accounts) | | `users/{userID}.hash` | Argon2id password hash |
| `users/{userID}.lthn` | Legacy LTHN hash (pre-Phase-2 registrations only) | | `users/{userID}.lthn` | Legacy LTHN hash (migrated transparently on login) |
### Registration ### Registration Flow
`Register(username, password)`: `Register(username, password)`:
1. Derive `userID = lthn.Hash(username)`. 1. Derive `userID = lthn.Hash(username)`.
2. Check `users/{userID}.pub` does not exist. 2. Check that `users/{userID}.pub` does not already exist.
3. `pgp.CreateKeyPair(userID, ...)` → armored keypair. 3. Generate a PGP keypair via `pgp.CreateKeyPair`.
4. Write `.pub`, `.key`, `.rev` (placeholder). 4. Store `.pub`, `.key`, `.rev` (placeholder).
5. `crypt.HashPassword(password)` → Argon2id hash string → write `.hash`. 5. Hash the password with Argon2id and store as `.hash`.
6. JSON-marshal `User` metadata, PGP-encrypt with public key, write `.json`. 6. Marshal user metadata as JSON, encrypt with the user's PGP public key,
store as `.json`.
### Online Challenge-Response ### Online Challenge-Response Flow
This is the primary authentication mechanism. It proves that the client
holds the private key corresponding to a registered public key.
``` ```
Client Server Client Server
@ -209,55 +301,58 @@ Client Server
|-- CreateChallenge(userID) -------> | |-- CreateChallenge(userID) -------> |
| | 1. Generate 32-byte nonce (crypto/rand) | | 1. Generate 32-byte nonce (crypto/rand)
| | 2. PGP-encrypt nonce with user's public key | | 2. PGP-encrypt nonce with user's public key
| | 3. Store pending challenge (TTL: 5 min) | | 3. Store pending challenge (default TTL: 5 min)
| <-- Challenge{Encrypted} --------- | | <-- Challenge{Encrypted} --------- |
| | | |
| (client decrypts nonce, signs it) | | (decrypt nonce, sign with privkey) |
| | | |
|-- ValidateResponse(signedNonce) -> | |-- ValidateResponse(signedNonce) -> |
| | 4. Verify detached PGP signature | | 4. Verify detached PGP signature
| | 5. Create session (32-byte token, 24h TTL) | | 5. Create session (32-byte token, default 24h TTL)
| <-- Session{Token} --------------- | | <-- Session{Token} --------------- |
``` ```
### Air-Gapped (Courier) Mode ### Air-Gapped (Courier) Mode
`WriteChallengeFile(userID, path)` writes the encrypted challenge as JSON to For agents that cannot receive live HTTP responses:
the Medium. The client signs the nonce offline. `ReadResponseFile(userID, path)`
reads the armored signature and calls `ValidateResponse` to complete authentication. - `WriteChallengeFile(userID, path)` writes the encrypted challenge as JSON
This mode supports agents or users who cannot receive live HTTP responses. to the Medium.
- The client signs the nonce offline.
- `ReadResponseFile(userID, path)` reads the armored signature and validates
it, completing the authentication.
### Password-Based Login ### Password-Based Login
`Login(userID, password)` bypasses the PGP challenge-response flow and verifies `Login(userID, password)` bypasses the PGP challenge-response flow. It
the password directly. It supports both hash formats via a dual-path strategy: supports both hash formats with automatic migration:
1. If `users/{userID}.hash` exists and starts with `$argon2id$`: verify with 1. If `.hash` exists and starts with `$argon2id$`: verify with
`crypt.VerifyPassword` (constant-time Argon2id comparison). constant-time Argon2id comparison.
2. Otherwise fall back to `users/{userID}.lthn`: verify with `lthn.Verify`. 2. Otherwise, fall back to `.lthn` and verify with `lthn.Verify`.
On success, transparently re-hash the password with Argon2id and write a On success, re-hash with Argon2id and write a `.hash` file
`.hash` file (best-effort, does not fail the login if the write fails). (best-effort -- login succeeds even if the migration write fails).
### Key Management ### Key Management
**Rotation** (`RotateKeyPair(userID, oldPassword, newPassword)`): **Rotation** via `RotateKeyPair(userID, oldPassword, newPassword)`:
- Load and decrypt current metadata using the old private key and password.
- Generate a new PGP keypair. - Decrypt current metadata with the old private key and password.
- Generate a new PGP keypair protected by the new password.
- Re-encrypt metadata with the new public key. - Re-encrypt metadata with the new public key.
- Overwrite `.pub`, `.key`, `.json`, `.hash`. - Overwrite `.pub`, `.key`, `.json`, `.hash`.
- Invalidate all active sessions for the user via `store.DeleteByUser`. - Invalidate all active sessions for the user.
**Revocation** (`RevokeKey(userID, password, reason)`): **Revocation** via `RevokeKey(userID, password, reason)`:
- Verify password (dual-path, same as Login).
- Verify the password (tries Argon2id first, then LTHN).
- Write a `Revocation{UserID, Reason, RevokedAt}` JSON record to `.rev`. - Write a `Revocation{UserID, Reason, RevokedAt}` JSON record to `.rev`.
- Invalidate all sessions. - Invalidate all sessions.
- `IsRevoked` returns true only when the `.rev` file contains valid JSON with a - Both `Login` and `CreateChallenge` immediately reject revoked users.
non-zero `RevokedAt`. The legacy `"REVOCATION_PLACEHOLDER"` string is treated
as non-revoked for backward compatibility.
- Both `Login` and `CreateChallenge` reject revoked users immediately.
**Protected users**: The `"server"` userID cannot be deleted. It holds the **Protected users**: The `"server"` userID cannot be deleted. It holds the
server keypair; deletion would permanently destroy the server's joining data. server keypair; deleting it would permanently destroy the server's joining
data.
### Session Management ### Session Management
@ -275,20 +370,19 @@ type SessionStore interface {
Two implementations are provided: Two implementations are provided:
| Implementation | Persistence | Concurrency | | Store | Persistence | Concurrency Model |
|----------------|-------------|-------------| |-------|-------------|-------------------|
| `MemorySessionStore` | None (lost on restart) | `sync.RWMutex` | | `MemorySessionStore` | None (lost on restart) | `sync.RWMutex` with defensive copies |
| `SQLiteSessionStore` | SQLite via go-store | Single mutex (SQLite single-writer) | | `SQLiteSessionStore` | SQLite via go-store | Single `sync.Mutex` (SQLite single-writer) |
Session tokens are 32 bytes from `crypto/rand`, hex-encoded to 64 characters Session tokens are 32 bytes from `crypto/rand`, hex-encoded to 64
(256-bit entropy). Expiry is checked on every `ValidateSession` and characters (256-bit entropy). Expired sessions are cleaned up either on
`RefreshSession` call; expired sessions are deleted on access. Background access or via the `StartCleanup(ctx, interval)` background goroutine.
cleanup runs via `StartCleanup(ctx, interval)`.
### Hardware Key Interface ### Hardware Key Interface
`hardware.go` defines a `HardwareKey` interface for future PKCS#11, YubiKey, `hardware.go` defines a `HardwareKey` interface for future PKCS#11,
or TPM integration: YubiKey, or TPM integration:
```go ```go
type HardwareKey interface { type HardwareKey interface {
@ -299,110 +393,166 @@ type HardwareKey interface {
} }
``` ```
Configured via `WithHardwareKey(hk)`. Integration points are documented in Configured via `WithHardwareKey(hk)`. No concrete implementations exist
`auth.go` but not yet wired — there are no concrete implementations in this yet -- this is a contract-only definition.
module.
--- ---
## trust/ — Agent Trust and Policy Engine ## 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 ### Trust Tiers
| Tier | Name | Default Rate Limit | Typical Agents | Agents are assigned one of three trust tiers:
|------|------|-------------------|----------------|
| 3 | Full | Unlimited | Athena, Virgil, Charon | | Tier | Name | Value | Default Rate Limit | Typical Agents |
| 2 | Verified | 60/min | Clotho, Hypnos (scoped repos) | |------|------|-------|-------------------|----------------|
| 1 | Untrusted | 10/min | BugSETI community instances | | Full | `TierFull` | 3 | Unlimited | Athena, Virgil, Charon |
| Verified | `TierVerified` | 2 | 60 req/min | Clotho, Hypnos |
| Untrusted | `TierUntrusted` | 1 | 10 req/min | Community instances |
### Registry
`Registry` is a thread-safe map of agent names to `Agent` structs:
```go
registry := trust.NewRegistry()
err := registry.Register(trust.Agent{
Name: "Clotho",
Tier: trust.TierVerified,
ScopedRepos: []string{"core/*"},
RateLimit: 30,
})
agent := registry.Get("Clotho")
agents := registry.List() // snapshot slice
for a := range registry.ListSeq() { ... } // iterator
```
### Capabilities ### Capabilities
Nine capabilities are defined: Nine capabilities are defined as typed constants:
| Capability | Description | | Capability | Constant | Description |
|------------|-------------| |------------|----------|-------------|
| `repo.push` | Push commits to a repository | | `repo.push` | `CapPushRepo` | Push commits to a repository |
| `pr.create` | Open a pull request | | `pr.create` | `CapCreatePR` | Open a pull request |
| `pr.merge` | Merge a pull request | | `pr.merge` | `CapMergePR` | Merge a pull request |
| `issue.create` | Create an issue | | `issue.create` | `CapCreateIssue` | Create an issue |
| `issue.comment` | Comment on an issue | | `issue.comment` | `CapCommentIssue` | Comment on an issue |
| `secrets.read` | Read repository secrets | | `secrets.read` | `CapReadSecrets` | Read repository secrets |
| `cmd.privileged` | Run privileged shell commands | | `cmd.privileged` | `CapRunPrivileged` | Run privileged shell commands |
| `workspace.access` | Access another agent's workspace | | `workspace.access` | `CapAccessWorkspace` | Access another agent's workspace |
| `flows.modify` | Modify CI/CD flow definitions | | `flows.modify` | `CapModifyFlows` | Modify CI/CD flow definitions |
### Policy Engine ### Policy Engine
`NewPolicyEngine(registry)` loads default policies. Evaluation order in `NewPolicyEngine(registry)` creates an engine with default policies.
`Evaluate(agentName, cap, repo)`: `Evaluate` returns one of three decisions:
1. Agent not in registry → Deny. ```go
2. No policy for agent's tier → Deny. engine := trust.NewPolicyEngine(registry)
3. Capability in `Denied` list → Deny. result := engine.Evaluate("Clotho", trust.CapPushRepo, "core/go-crypt")
4. Capability in `RequiresApproval` list → NeedsApproval.
5. Capability in `Allowed` list:
- If repo-scoped capability and `len(agent.ScopedRepos) > 0`: check repo
against scope patterns → Deny if no match.
- Otherwise → Allow.
6. Capability not in any list → Deny.
Default policies by tier: switch result.Decision {
case trust.Allow:
// Proceed
case trust.Deny:
// Reject with result.Reason
case trust.NeedsApproval:
// Submit to ApprovalQueue
}
```
| Tier | Allowed | RequiresApproval | Denied | **Evaluation order**:
|------|---------|-----------------|--------|
| Full (3) | All 9 capabilities | — | — | 1. Agent not in registry -- `Deny`.
2. No policy for the agent's tier -- `Deny`.
3. Capability in the `Denied` list -- `Deny`.
4. Capability in the `RequiresApproval` list -- `NeedsApproval`.
5. Capability in the `Allowed` list:
- If the capability is repo-scoped and the agent has `ScopedRepos`:
check the repo against scope patterns. No match -- `Deny`.
- Otherwise -- `Allow`.
6. Capability not in any list -- `Deny`.
**Default policies by tier**:
| Tier | Allowed | Requires Approval | 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 | | 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 | | Untrusted (1) | pr.create, issue.comment | -- | repo.push, pr.merge, issue.create, secrets.read, cmd.privileged, workspace.access, flows.modify |
### Repo Scope Matching ### Repo Scope Matching
`matchScope(pattern, repo)` supports three forms: Tier 2 agents can have their repository access restricted via `ScopedRepos`.
Three pattern types are supported:
| Pattern | Matches | Does Not Match | | Pattern | Matches | Does Not Match |
|---------|---------|----------------| |---------|---------|----------------|
| `core/go-crypt` | `core/go-crypt` | `core/go-crypt/sub` | | `core/go-crypt` | `core/go-crypt` (exact) | `core/go-crypt/sub` |
| `core/*` | `core/go-crypt` | `core/go-crypt/sub` | | `core/*` | `core/go-crypt`, `core/php` | `core/go-crypt/sub`, `other/repo` |
| `core/**` | `core/go-crypt`, `core/go-crypt/sub` | `other/repo` | | `core/**` | `core/go-crypt`, `core/php/sub`, `core/a/b/c` | `other/repo` |
Empty `ScopedRepos` on a Tier 2 agent is treated as unrestricted (no scope Wildcards are only supported at the end of patterns.
check is applied). See known limitations in `docs/history.md` (Finding F3).
### Approval Queue ### Approval Queue
`ApprovalQueue` is a thread-safe queue for `NeedsApproval` decisions. It is When the policy engine returns `NeedsApproval`, the caller is responsible
separate from the `PolicyEngine` — the engine returns `NeedsApproval` as a for submitting the request to an `ApprovalQueue`:
decision, and the caller is responsible for submitting to the queue and polling
for resolution. The queue tracks: submitting agent, capability, repo context, ```go
status (pending/approved/denied), reviewer identity, and timestamps. queue := trust.NewApprovalQueue()
// Submit a request
id, err := queue.Submit("Clotho", trust.CapMergePR, "core/go-crypt")
// Review pending requests
for _, req := range queue.Pending() { ... }
// Approve or deny
queue.Approve(id, "admin", "Looks good")
queue.Deny(id, "admin", "Not yet authorised")
// Check status
req := queue.Get(id) // req.Status == trust.ApprovalApproved
```
The queue is thread-safe and tracks timestamps, reviewer identity, and
reasons for each decision.
### Audit Log ### Audit Log
`AuditLog` records every policy evaluation as an `AuditEntry`. Entries are Every policy evaluation can be recorded in an append-only audit log:
stored in-memory and optionally streamed as JSON lines to an `io.Writer` for
persistence. `Decision` marshals to/from string (`"allow"`, `"deny"`, ```go
`"needs_approval"`). `EntriesFor(agent)` filters by agent name. log := trust.NewAuditLog(os.Stdout) // or nil for in-memory only
result := engine.Evaluate("Clotho", trust.CapPushRepo, "core/php")
log.Record(result, "core/php")
// Query entries
entries := log.Entries()
agentEntries := log.EntriesFor("Clotho")
for e := range log.EntriesForSeq("Clotho") { ... }
```
When an `io.Writer` is provided, each entry is serialised as a JSON line
for persistent storage. The `Decision` type marshals to and from string
values (`"allow"`, `"deny"`, `"needs_approval"`).
### Dynamic Policy Configuration ### Dynamic Policy Configuration
Policies can be loaded from JSON and applied at runtime: Policies can be loaded from JSON at runtime:
```go ```go
// Load from file
engine.ApplyPoliciesFromFile("/etc/agent/policies.json") engine.ApplyPoliciesFromFile("/etc/agent/policies.json")
// Export current state // Load from reader
engine.ApplyPolicies(reader)
// Export current policies
engine.ExportPolicies(os.Stdout) engine.ExportPolicies(os.Stdout)
``` ```
@ -412,16 +562,17 @@ JSON format:
{ {
"policies": [ "policies": [
{ {
"tier": 1, "tier": 2,
"allowed": ["pr.create", "issue.comment"], "allowed": ["repo.push", "pr.create", "issue.create"],
"denied": ["repo.push", "pr.merge"] "requires_approval": ["pr.merge"],
"denied": ["cmd.privileged", "workspace.access"]
} }
] ]
} }
``` ```
`json.Decoder.DisallowUnknownFields()` is set during load to catch The JSON decoder uses `DisallowUnknownFields()` to catch configuration
configuration errors early. errors early.
--- ---
@ -432,56 +583,41 @@ configuration errors early.
| KDF (primary) | Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 | | KDF (primary) | Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 |
| KDF (alternative) | scrypt | N=32768, r=8, p=1 | | KDF (alternative) | scrypt | N=32768, r=8, p=1 |
| KDF (expansion) | HKDF-SHA256 | Variable key length | | KDF (expansion) | HKDF-SHA256 | Variable key length |
| Symmetric (primary) | ChaCha20-Poly1305 | 24-byte nonce (XChaCha20), 32-byte key | | Symmetric (primary) | XChaCha20-Poly1305 | 24-byte nonce, 32-byte key |
| Symmetric (alternative) | AES-256-GCM | 12-byte nonce, 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 (primary) | Argon2id | `$argon2id$` format with random 16-byte salt |
| Password hash (legacy) | LTHN quasi-salted SHA-256 | RFC-0004 (deterministic, no random salt) | | Password hash (fallback) | bcrypt | Configurable cost |
| Password hash (fallback) | Bcrypt | Configurable cost | | Content ID | LTHN quasi-salted SHA-256 | RFC-0004 (deterministic, no random salt) |
| Content ID | LTHN quasi-salted SHA-256 | RFC-0004 | | Asymmetric | RSA-OAEP-SHA256 | 2048+ bit keys |
| Asymmetric | RSA-OAEP-SHA256 | 2048+ bit | | PGP (pgp/) | DSA primary + RSA subkey | ProtonMail go-crypto |
| PGP keypair | DSA primary + RSA subkey | ProtonMail go-crypto | | PGP (openpgp/) | RSA-4096 + AES-256 + SHA-256 | core.Crypt interface |
| PGP service | RSA-4096 + AES-256 + SHA-256 | core.Crypt interface | | HMAC | HMAC-SHA256 / HMAC-SHA512 | Constant-time verification |
| HMAC | HMAC-SHA256 / HMAC-SHA512 | Constant-time verify | | Challenge nonce | crypto/rand | 32 bytes (256-bit entropy) |
| Challenge nonce | crypto/rand | 32 bytes (256-bit) |
| Session token | crypto/rand | 32 bytes, hex-encoded (64 chars) | | 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 ## Security Notes
1. The LTHN hash (`crypt/lthn`) is **not** suitable for password hashing. It 1. The LTHN hash (`crypt/lthn`) is **not** suitable for password hashing.
is deterministic with no random salt. Use `crypt.HashPassword` (Argon2id). It is deterministic with no random salt. Use `crypt.HashPassword`
2. PGP private keys are not zeroed after use. The ProtonMail `go-crypto` (Argon2id) for passwords.
2. PGP private keys are not zeroed after use. The ProtonMail go-crypto
library does not expose a `Wipe` method. This is a known upstream library does not expose a `Wipe` method. This is a known upstream
limitation; mitigating it would require forking the library. limitation.
3. Empty `ScopedRepos` on a Tier 2 agent currently bypasses the repo scope
check (treated as unrestricted). Explicit `["*"]` or `["org/**"]` should be 3. Empty `ScopedRepos` on a Tier 2 agent currently bypasses the repo
required for unrestricted Tier 2 access if this design is revisited. scope check (treated as unrestricted). Explicit `["*"]` or
`["org/**"]` should be required for unrestricted Tier 2 access if
this design is revisited.
4. The `PolicyEngine` returns decisions but does not enforce the approval 4. The `PolicyEngine` returns decisions but does not enforce the approval
workflow. A higher-level layer (go-agentic, go-scm) must handle the workflow. A higher-level layer must handle `NeedsApproval` by routing
`NeedsApproval` case by routing through the `ApprovalQueue`. through the `ApprovalQueue`.
5. The `MemorySessionStore` is the default. Use `WithSessionStore(NewSQLiteSessionStore(path))`
for persistence across restarts. 5. All randomness uses `crypto/rand`. Never use `math/rand` for
cryptographic purposes in this codebase.
6. Error messages never include secret material. Strings are kept generic:
`"invalid password"`, `"session not found"`, `"failed to decrypt"`.

View file

@ -1,21 +1,29 @@
# Development Guide — go-crypt ---
title: Development Guide
description: How to build, test, and contribute to go-crypt.
---
# Development Guide
## Prerequisites ## Prerequisites
- Go 1.25 or later (the module declares `go 1.25.5`). - **Go 1.26** or later (the module declares `go 1.26.0`).
- A Go workspace (`go.work`) that resolves the local replace directives for - A Go workspace (`go.work`) that resolves the local dependencies:
`forge.lthn.ai/core/go` (at `../go`) and `forge.lthn.ai/core/go-store` `forge.lthn.ai/core/go`, `forge.lthn.ai/core/go-store`,
(at `../go-store`). If you are working outside the full monorepo, edit `forge.lthn.ai/core/go-io`, `forge.lthn.ai/core/go-log`, and
`go.mod` replace directives to point to your local checkouts. `forge.lthn.ai/core/cli`. If you are working outside the full monorepo,
- No C toolchain, CGo, or system libraries are required. create a `go.work` at the parent directory pointing to your local
checkouts.
- No C toolchain, CGo, or system libraries are required. All cryptographic
operations use pure Go implementations.
## Build and Test Commands ## Build and Test
```bash ```bash
# Run all tests # Run all tests
go test ./... go test ./...
# Run with race detector (always use before committing) # Run with race detector (required before committing)
go test -race ./... go test -race ./...
# Run a single test by name # Run a single test by name
@ -26,79 +34,96 @@ go test ./auth/...
go test ./crypt/... go test ./crypt/...
go test ./trust/... go test ./trust/...
# Static analysis # Static analysis (must be clean before committing)
go vet ./... go vet ./...
# Run benchmarks # Run benchmarks
go test -bench=. -benchmem ./crypt/... go test -bench=. -benchmem ./crypt/...
go test -bench=. -benchmem ./trust/... go test -bench=. -benchmem ./trust/...
# Extended benchmark run
go test -bench=. -benchmem -benchtime=3s ./crypt/...
``` ```
There is no build step — this is a library module with no binaries. The If using the `core` CLI:
`go vet ./...` check must pass cleanly before any commit.
```bash
core go test
core go test --run TestName
core go qa # fmt + vet + lint + test
core go qa full # + race, vuln, security
```
## Repository Layout ## Repository Layout
``` ```
go-crypt/ go-crypt/
├── auth/ Authentication package ├── auth/ Authentication: Authenticator, sessions, key management
├── crypt/ Cryptographic utilities ├── cmd/
│ ├── chachapoly/ Standalone ChaCha20-Poly1305 sub-package │ ├── crypt/ CLI commands: encrypt, decrypt, hash, keygen, checksum
│ ├── lthn/ RFC-0004 quasi-salted hash │ └── testcmd/ Test runner commands
│ ├── openpgp/ Service wrapper (core.Crypt interface) ├── crypt/ Symmetric encryption, hashing, key derivation
│ ├── chachapoly/ Standalone ChaCha20-Poly1305 AEAD
│ ├── lthn/ RFC-0004 quasi-salted deterministic hash
│ ├── openpgp/ core.Crypt service wrapper
│ ├── pgp/ OpenPGP primitives │ ├── pgp/ OpenPGP primitives
│ └── rsa/ RSA OAEP-SHA256 │ └── rsa/ RSA-OAEP-SHA256
├── docs/ Architecture, development, and history docs ├── docs/ Documentation
├── trust/ Agent trust model and policy engine ├── trust/ Agent trust model, policy engine, audit log
├── go.mod ├── go.mod
└── go.sum └── go.sum
``` ```
## Test Patterns ## Test Patterns
Tests use the `github.com/stretchr/testify` library (`assert` and `require`). Tests use `github.com/stretchr/testify` (`assert` and `require`). The
The naming convention follows three suffixes: naming convention uses three suffixes to categorise test intent:
| Suffix | Purpose | | Suffix | Purpose |
|--------|---------| |--------|---------|
| `_Good` | Happy path expected success | | `_Good` | Happy path -- expected success |
| `_Bad` | Expected failure invalid input, wrong credentials, not-found errors | | `_Bad` | Expected failure -- invalid input, wrong credentials, not-found errors |
| `_Ugly` | Edge cases panics, zero values, empty inputs, extreme lengths | | `_Ugly` | Edge cases -- panics, zero values, empty inputs, extreme lengths |
Example: Example:
```go ```go
func TestLogin_Good(t *testing.T) { ... } func TestLogin_Good(t *testing.T) {
func TestLogin_Bad(t *testing.T) { ... } // Register a user, log in with correct password, verify session
func TestLogin_Ugly(t *testing.T) { ... } }
func TestLogin_Bad(t *testing.T) {
// Attempt login with wrong password, verify rejection
}
func TestLogin_Ugly(t *testing.T) {
// Empty password, very long input, Unicode edge cases
}
``` ```
Concurrency tests use `t.Parallel()` and typically spawn 10 goroutines via a ### Concurrency Tests
`sync.WaitGroup`. The race detector (`-race`) must pass for all concurrent tests.
## Benchmark Structure Concurrent tests spawn 10 goroutines via a `sync.WaitGroup` and use
`t.Parallel()`. The race detector (`go test -race`) must pass for all
concurrent tests. Examples include concurrent session creation, concurrent
registry access, and concurrent policy evaluation.
Benchmarks live in `bench_test.go` files alongside the packages they cover. ### Benchmarks
Benchmark names follow the `BenchmarkFuncName_Context` pattern:
```go Benchmarks live in `bench_test.go` files alongside the packages they cover:
func BenchmarkArgon2Derive(b *testing.B) { ... }
func BenchmarkChaCha20_1KB(b *testing.B) { ... }
func BenchmarkChaCha20_1MB(b *testing.B) { ... }
```
Run benchmarks with: - `crypt/bench_test.go`: Argon2id derivation, ChaCha20 and AES-GCM at
1KB and 1MB payloads, HMAC-SHA256, HMAC verification.
- `trust/bench_test.go`: policy evaluation with 100 agents, registry
get, registry register.
```bash **Note**: The Argon2id KDF is intentionally slow (~200ms on typical
go test -bench=. -benchmem -benchtime=3s ./crypt/... hardware). This is a security property, not a performance defect. Do not
``` optimise KDF parameters without understanding the security implications.
Do not optimise without measuring first. The Argon2id KDF is intentionally slow
(~200ms on typical hardware) — this is a security property, not a defect.
## Adding a New Cryptographic Primitive ## Adding a New Cryptographic Primitive
1. Add the implementation in the appropriate sub-package. 1. Add the implementation in the appropriate sub-package under `crypt/`.
2. Write tests covering `_Good`, `_Bad`, and `_Ugly` cases. 2. Write tests covering `_Good`, `_Bad`, and `_Ugly` cases.
3. Add a benchmark if the function is called on hot paths. 3. Add a benchmark if the function is called on hot paths.
4. Update `docs/architecture.md` with the algorithm reference entry. 4. Update `docs/architecture.md` with the algorithm reference entry.
@ -107,8 +132,8 @@ Do not optimise without measuring first. The Argon2id KDF is intentionally slow
## Adding a New Trust Capability ## Adding a New Trust Capability
1. Add the `Capability` constant in `trust/trust.go`. 1. Add the `Capability` constant in `trust/trust.go`.
2. Update `isRepoScoped()` in `trust/policy.go` if the capability is 2. If the capability is repository-scoped, update `isRepoScoped()` in
repository-scoped. `trust/policy.go`.
3. Update the default policies in `loadDefaults()` in `trust/policy.go`. 3. Update the default policies in `loadDefaults()` in `trust/policy.go`.
4. Add tests covering all three tiers. 4. Add tests covering all three tiers.
5. Update the capability table in `docs/architecture.md`. 5. Update the capability table in `docs/architecture.md`.
@ -122,29 +147,30 @@ _licence_ (noun), _license_ (verb), _behaviour_, _initialise_, _serialise_.
### Go Style ### Go Style
- `declare(strict_types=1)` is a PHP convention; Go has no equivalent. Use
explicit type assertions and avoid `any` except at interface boundaries.
- Every exported function and type must have a doc comment. - Every exported function and type must have a doc comment.
- Error strings are lowercase and do not end with a full stop, per Go convention. - Error strings are lowercase and do not end with a full stop, per Go
- Use the `core.E(op, msg, err)` helper from `forge.lthn.ai/core/go` for convention.
contextual error wrapping: `op` is `"package.Function"`, `msg` is a brief - Use the `core.E(op, msg, err)` helper for contextual error wrapping:
lowercase description. `op` is `"package.Function"`, `msg` is a brief lowercase description.
- Import groups: stdlib → `forge.lthn.ai/core` → third-party. Separate each - Import groups, separated by blank lines: stdlib, then `forge.lthn.ai/core`,
group with a blank line. then third-party.
- Avoid `any` except at interface boundaries. Prefer explicit type
assertions.
### Cryptography ### Cryptographic Safety
- All randomness from `crypto/rand`. Never use `math/rand` for cryptographic - All randomness from `crypto/rand`. Never use `math/rand` for
purposes. cryptographic purposes.
- Use `crypto/subtle.ConstantTimeCompare` for any comparison of secret material - Use `crypto/subtle.ConstantTimeCompare` for any comparison of secret
(MACs, hashes). The one exception is `lthn.Verify`, which compares content material (MACs, password hashes, session tokens).
identifiers (not secrets) and documents this explicitly. - Never log or return secrets in error messages. Keep error strings
- Never log or return secrets in error messages. Error strings should be generic: generic: `"invalid password"`, `"session not found"`, `"failed to
`"invalid password"`, `"session not found"`, `"failed to decrypt"`. decrypt"`.
### Licence ### Licence
All files are licenced under EUPL-1.2. Do not add files under a different licence. All files are licenced under EUPL-1.2. Do not add files under a different
licence.
## Commit Convention ## Commit Convention
@ -158,10 +184,10 @@ Optional body explaining motivation and context.
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Virgil <virgil@lethean.io>
``` ```
Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`. **Types**: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`.
Scopes match package names: `auth`, `crypt`, `trust`, `pgp`, `lthn`, `rsa`, **Scopes** match package names: `auth`, `crypt`, `trust`, `pgp`, `lthn`,
`openpgp`, `chachapoly`. `rsa`, `openpgp`, `chachapoly`.
Examples: Examples:
@ -169,29 +195,46 @@ Examples:
feat(auth): add SQLite session store for crash recovery feat(auth): add SQLite session store for crash recovery
fix(trust): reject empty ScopedRepos as no-access for Tier 2 fix(trust): reject empty ScopedRepos as no-access for Tier 2
test(crypt): add benchmark suite for Argon2 and ChaCha20 test(crypt): add benchmark suite for Argon2 and ChaCha20
docs(trust): document approval queue workflow
``` ```
## Forge Push ## Pushing to Forge
The canonical remote is `forge.lthn.ai`. Push via SSH only; HTTPS authentication The canonical remote is `forge.lthn.ai`. Push via SSH only:
is not configured:
```bash ```bash
git push forge main git push forge main
# remote: ssh://git@forge.lthn.ai:2223/core/go-crypt.git # remote: ssh://git@forge.lthn.ai:2223/core/go-crypt.git
``` ```
## Local Replace Directives HTTPS authentication is not configured for this repository.
The `go.mod` contains: ## Local Dependencies
``` The `go.mod` depends on several `forge.lthn.ai/core/*` modules. These are
replace ( resolved through the Go workspace (`~/Code/go.work`). Do not modify the
forge.lthn.ai/core/go => ../go replace directives in `go.mod` directly -- use the workspace file instead.
forge.lthn.ai/core/go-store => ../go-store
)
```
Do not modify these paths. If you need to work with a different local checkout, | Module | Local Path | Purpose |
use a Go workspace (`go.work`) at the parent directory level rather than editing |--------|-----------|---------|
the replace directives directly. | `forge.lthn.ai/core/go` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store for session persistence |
| `forge.lthn.ai/core/go-io` | `../go-io` | `io.Medium` storage abstraction |
| `forge.lthn.ai/core/go-log` | `../go-log` | `core.E()` contextual error wrapping |
| `forge.lthn.ai/core/cli` | `../cli` | CLI framework for `cmd/crypt` commands |
## Known Limitations
For a full list of known limitations and open security findings, see
[history.md](history.md).
Key items:
- **Dual ChaCha20 implementations**: `crypt/symmetric.go` and
`crypt/chachapoly/` are nearly identical. Consolidation would reduce
duplication but requires updating all importers.
- **Hardware key interface**: contract-only, no concrete implementations.
- **Session cleanup logging**: uses `fmt.Printf` rather than a structured
logger. Callers needing structured logs should wrap the cleanup goroutine.
- **Rate limiting**: the `Agent.RateLimit` field is stored but never
enforced. Enforcement belongs in a higher-level middleware layer.

163
docs/index.md Normal file
View file

@ -0,0 +1,163 @@
---
title: go-crypt
description: Cryptographic primitives, authentication, and trust policy engine for the Lethean agent platform.
---
# go-crypt
**Module**: `forge.lthn.ai/core/go-crypt`
**Licence**: EUPL-1.2
**Language**: Go 1.26
Cryptographic primitives, authentication, and trust policy engine for the
Lethean agent platform. Provides symmetric encryption, password hashing,
OpenPGP authentication with both online and air-gapped modes, RSA key
management, deterministic content hashing, and a three-tier agent access
control system with an audit log and approval queue.
## Quick Start
```go
import (
"forge.lthn.ai/core/go-crypt/crypt"
"forge.lthn.ai/core/go-crypt/auth"
"forge.lthn.ai/core/go-crypt/trust"
)
```
### Encrypt and Decrypt Data
The default cipher is XChaCha20-Poly1305 with Argon2id key derivation. A
random salt and nonce are generated automatically and prepended to the
ciphertext.
```go
// Encrypt with ChaCha20-Poly1305 + Argon2id KDF
ciphertext, err := crypt.Encrypt(plaintext, []byte("my passphrase"))
// Decrypt
plaintext, err := crypt.Decrypt(ciphertext, []byte("my passphrase"))
// Or use AES-256-GCM instead
ciphertext, err := crypt.EncryptAES(plaintext, []byte("my passphrase"))
plaintext, err := crypt.DecryptAES(ciphertext, []byte("my passphrase"))
```
### Hash and Verify Passwords
```go
// Hash with Argon2id (recommended)
hash, err := crypt.HashPassword("hunter2")
// Returns: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
// Verify (constant-time comparison)
match, err := crypt.VerifyPassword("hunter2", hash)
```
### OpenPGP Authentication
```go
// Create an authenticator backed by a storage medium
a := auth.New(medium,
auth.WithSessionStore(sqliteStore),
auth.WithSessionTTL(8 * time.Hour),
)
// Register a user (generates PGP keypair, stores credentials)
user, err := a.Register("alice", "password123")
// Password-based login (bypasses PGP challenge-response)
session, err := a.Login(userID, "password123")
// Validate a session token
session, err := a.ValidateSession(token)
```
### Trust Policy Evaluation
```go
// Set up a registry and register agents
registry := trust.NewRegistry()
registry.Register(trust.Agent{
Name: "Athena",
Tier: trust.TierFull,
})
registry.Register(trust.Agent{
Name: "Clotho",
Tier: trust.TierVerified,
ScopedRepos: []string{"core/*"},
})
// Evaluate capabilities
engine := trust.NewPolicyEngine(registry)
result := engine.Evaluate("Athena", trust.CapPushRepo, "core/go-crypt")
// result.Decision == trust.Allow
result = engine.Evaluate("Clotho", trust.CapMergePR, "core/go-crypt")
// result.Decision == trust.NeedsApproval
```
## Package Layout
| Package | Import Path | Description |
|---------|-------------|-------------|
| `crypt` | `go-crypt/crypt` | High-level encrypt/decrypt (ChaCha20 + AES), password hashing, HMAC, checksums, key derivation |
| `crypt/chachapoly` | `go-crypt/crypt/chachapoly` | Standalone ChaCha20-Poly1305 AEAD wrapper |
| `crypt/lthn` | `go-crypt/crypt/lthn` | RFC-0004 quasi-salted deterministic hash for content identifiers |
| `crypt/pgp` | `go-crypt/crypt/pgp` | OpenPGP key generation, encryption, decryption, signing, verification |
| `crypt/rsa` | `go-crypt/crypt/rsa` | RSA-OAEP-SHA256 key generation and encryption (2048+ bit) |
| `crypt/openpgp` | `go-crypt/crypt/openpgp` | Service wrapper implementing the `core.Crypt` interface with IPC support |
| `auth` | `go-crypt/auth` | OpenPGP challenge-response authentication, session management, key rotation/revocation |
| `trust` | `go-crypt/trust` | Agent trust model, policy engine, approval queue, audit log |
| `cmd/crypt` | `go-crypt/cmd/crypt` | CLI commands: `crypt encrypt`, `crypt decrypt`, `crypt hash`, `crypt keygen`, `crypt checksum` |
## CLI Commands
The `cmd/crypt` package registers a `crypt` command group with the `core` CLI:
```bash
# Encrypt a file (ChaCha20-Poly1305 by default)
core crypt encrypt myfile.txt -p "passphrase"
core crypt encrypt myfile.txt --aes -p "passphrase"
# Decrypt
core crypt decrypt myfile.txt.enc -p "passphrase"
# Hash a password
core crypt hash "my password" # Argon2id
core crypt hash "my password" --bcrypt # Bcrypt
# Verify a password against a hash
core crypt hash "my password" --verify '$argon2id$v=19$...'
# Generate a random key
core crypt keygen # 32 bytes, hex
core crypt keygen -l 64 --base64 # 64 bytes, base64
# Compute file checksums
core crypt checksum myfile.txt # SHA-256
core crypt checksum myfile.txt --sha512
core crypt checksum myfile.txt --verify "abc123..."
```
## Dependencies
| Module | Role |
|--------|------|
| `forge.lthn.ai/core/go` | Framework: `core.E` error helper, `core.Crypt` interface, `io.Medium` storage abstraction |
| `forge.lthn.ai/core/go-store` | SQLite KV store for persistent session storage |
| `forge.lthn.ai/core/go-io` | `io.Medium` interface used by the auth package |
| `forge.lthn.ai/core/go-log` | Contextual error wrapping via `core.E()` |
| `forge.lthn.ai/core/cli` | CLI framework for the `cmd/crypt` commands |
| `github.com/ProtonMail/go-crypto` | OpenPGP implementation (actively maintained, post-quantum research) |
| `golang.org/x/crypto` | Argon2id, ChaCha20-Poly1305, scrypt, HKDF, bcrypt |
| `github.com/stretchr/testify` | Test assertions (`assert`, `require`) |
No C toolchain or CGo is required. All cryptographic operations use pure Go
implementations.
## Further Reading
- [Architecture](architecture.md) -- internals, data flow, algorithm reference
- [Development](development.md) -- building, testing, contributing
- [History](history.md) -- completed phases, security audit findings, known limitations