21 KiB
| title | description |
|---|---|
| Architecture | Internal design, key types, data flow, and algorithm reference for go-crypt. |
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/
├── auth/ OpenPGP challenge-response authentication
│ ├── auth.go Authenticator: 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 yet)
├── crypt/ Symmetric encryption, hashing, key derivation
│ ├── crypt.go High-level Encrypt/Decrypt and EncryptAES/DecryptAES
│ ├── kdf.go Key derivation: Argon2id, scrypt, HKDF-SHA256
│ ├── symmetric.go Low-level ChaCha20-Poly1305 and AES-256-GCM
│ ├── hash.go Password hashing: Argon2id and bcrypt
│ ├── hmac.go HMAC-SHA256, HMAC-SHA512, constant-time verify
│ ├── checksum.go SHA-256 and SHA-512 file/data checksums
│ ├── 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, capabilities, Evaluate()
│ ├── approval.go ApprovalQueue for NeedsApproval decisions
│ ├── audit.go AuditLog: append-only policy evaluation log
│ └── config.go JSON policy configuration: load, apply, export
└── cmd/
└── crypt/ CLI commands registered with core CLI
crypt/ -- Symmetric Encryption and Hashing
High-Level API
The crypt.Encrypt and crypt.Decrypt functions are the primary entry
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) -> salt || nonce || ciphertext
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. Prepend salt to the result
EncryptAES and DecryptAES follow the same pattern but use AES-256-GCM
with a 12-byte nonce instead of the 24-byte XChaCha20 nonce.
Both ciphers produce self-describing byte layouts. Callers must not alter the layout between encrypt and decrypt.
| 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 |
Key Derivation (kdf.go)
Three key derivation functions serve different use cases:
| 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 |
The Argon2id parameters sit within the OWASP-recommended range for
interactive logins. HKDF is intended for deriving sub-keys from a master
key that already has high entropy; it should not be used directly with
low-entropy passphrases.
Low-Level Symmetric Ciphers (symmetric.go)
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.
// 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>
VerifyPassword re-derives the hash from the parameters encoded in the
string and uses crypto/subtle.ConstantTimeCompare for the final
comparison. This prevents timing side-channels during password verification.
HashBcrypt and VerifyBcrypt wrap golang.org/x/crypto/bcrypt as a
fallback for environments where bcrypt is mandated by policy.
HMAC (hmac.go)
Three functions for message authentication codes:
HMACSHA256(message, key)-- returns raw 32-byte MAC.HMACSHA512(message, key)-- returns raw 64-byte MAC.VerifyHMAC(message, key, mac, hashFunc)-- constant-time verification usingcrypto/hmac.Equal.
Checksums (checksum.go)
File checksums use streaming reads to handle arbitrarily large files without loading them entirely into memory:
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/
A standalone ChaCha20-Poly1305 package that can be imported independently.
It pre-allocates cap(nonce) + len(plaintext) + overhead before appending,
which reduces allocations for small payloads.
import "forge.lthn.ai/core/go-crypt/crypt/chachapoly"
ciphertext, err := chachapoly.Encrypt(plaintext, key) // key must be 32 bytes
plaintext, err := chachapoly.Decrypt(ciphertext, key)
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:
- Reverse the input string.
- Apply "leet speak" character substitutions (
oto0,lto1,eto3,ato4,stoz,tto7, and their inverses). - Concatenate the original input with the derived quasi-salt.
- Return the SHA-256 digest, hex-encoded (64 characters).
import "forge.lthn.ai/core/go-crypt/crypt/lthn"
hash := lthn.Hash("hello")
valid := lthn.Verify("hello", hash) // true
The substitution map can be customised via lthn.SetKeyMap() for
application-specific derivation.
Important: LTHN is designed for content identifiers, cache keys, and
deduplication. It is not suitable for password hashing because it uses no
random salt and the comparison in Verify uses subtle.ConstantTimeCompare
but the hash itself is deterministic and fast.
crypt/pgp/ -- OpenPGP Primitives
Full OpenPGP support via github.com/ProtonMail/go-crypto:
import "forge.lthn.ai/core/go-crypt/crypt/pgp"
// 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
// Encrypt data for a recipient
ciphertext, err := pgp.Encrypt(data, kp.PublicKey)
// 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.
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.
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
Authenticator
The Authenticator struct is the central type for user identity operations.
It takes an io.Medium for storage and supports functional options for
configuration.
a := auth.New(medium,
auth.WithSessionStore(sqliteStore),
auth.WithSessionTTL(8 * time.Hour),
auth.WithChallengeTTL(2 * time.Minute),
auth.WithHardwareKey(yubikey), // future: hardware-backed crypto
)
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 |
users/{userID}.json |
User metadata (PGP-encrypted with user's public key) |
users/{userID}.hash |
Argon2id password hash |
users/{userID}.lthn |
Legacy LTHN hash (migrated transparently on login) |
Registration Flow
Register(username, password):
- Derive
userID = lthn.Hash(username). - Check that
users/{userID}.pubdoes not already exist. - Generate a PGP keypair via
pgp.CreateKeyPair. - Store
.pub,.key,.rev(placeholder). - Hash the password with Argon2id and store as
.hash. - Marshal user metadata as JSON, encrypt with the user's PGP public key,
store as
.json.
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
| |
|-- CreateChallenge(userID) -------> |
| | 1. Generate 32-byte nonce (crypto/rand)
| | 2. PGP-encrypt nonce with user's public key
| | 3. Store pending challenge (default TTL: 5 min)
| <-- Challenge{Encrypted} --------- |
| |
| (decrypt nonce, sign with privkey) |
| |
|-- ValidateResponse(signedNonce) -> |
| | 4. Verify detached PGP signature
| | 5. Create session (32-byte token, default 24h TTL)
| <-- Session{Token} --------------- |
Air-Gapped (Courier) Mode
For agents that cannot receive live HTTP responses:
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 validates it, completing the authentication.
Password-Based Login
Login(userID, password) bypasses the PGP challenge-response flow. It
supports both hash formats with automatic migration:
- If
.hashexists and starts with$argon2id$: verify with constant-time Argon2id comparison. - Otherwise, fall back to
.lthnand verify withlthn.Verify. On success, re-hash with Argon2id and write a.hashfile (best-effort -- login succeeds even if the migration write fails).
Key Management
Rotation via RotateKeyPair(userID, oldPassword, newPassword):
- 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.
- Overwrite
.pub,.key,.json,.hash. - Invalidate all active sessions for the user.
Revocation via RevokeKey(userID, password, reason):
- Verify the password (tries Argon2id first, then LTHN).
- Write a
Revocation{UserID, Reason, RevokedAt}JSON record to.rev. - Invalidate all sessions.
- Both
LoginandCreateChallengeimmediately reject revoked users.
Protected users: The "server" userID cannot be deleted. It holds the
server keypair; deleting it 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:
| Store | Persistence | Concurrency Model |
|---|---|---|
MemorySessionStore |
None (lost on restart) | sync.RWMutex with defensive copies |
SQLiteSessionStore |
SQLite via go-store | Single sync.Mutex (SQLite single-writer) |
Session tokens are 32 bytes from crypto/rand, hex-encoded to 64
characters (256-bit entropy). Expired sessions are cleaned up either on
access or via the StartCleanup(ctx, interval) background goroutine.
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). No concrete implementations exist
yet -- this is a contract-only definition.
trust/ -- Agent Trust and Policy Engine
Trust Tiers
Agents are assigned one of three trust tiers:
| Tier | Name | Value | Default Rate Limit | Typical Agents |
|---|---|---|---|---|
| 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:
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
Nine capabilities are defined as typed constants:
| Capability | Constant | Description |
|---|---|---|
repo.push |
CapPushRepo |
Push commits to a repository |
pr.create |
CapCreatePR |
Open a pull request |
pr.merge |
CapMergePR |
Merge a pull request |
issue.create |
CapCreateIssue |
Create an issue |
issue.comment |
CapCommentIssue |
Comment on an issue |
secrets.read |
CapReadSecrets |
Read repository secrets |
cmd.privileged |
CapRunPrivileged |
Run privileged shell commands |
workspace.access |
CapAccessWorkspace |
Access another agent's workspace |
flows.modify |
CapModifyFlows |
Modify CI/CD flow definitions |
Policy Engine
NewPolicyEngine(registry) creates an engine with default policies.
Evaluate returns one of three decisions:
engine := trust.NewPolicyEngine(registry)
result := engine.Evaluate("Clotho", trust.CapPushRepo, "core/go-crypt")
switch result.Decision {
case trust.Allow:
// Proceed
case trust.Deny:
// Reject with result.Reason
case trust.NeedsApproval:
// Submit to ApprovalQueue
}
Evaluation order:
- Agent not in registry --
Deny. - No policy for the agent's tier --
Deny. - Capability in the
Deniedlist --Deny. - Capability in the
RequiresApprovallist --NeedsApproval. - Capability in the
Allowedlist:- If the capability is repo-scoped and the agent has
ScopedRepos: check the repo against scope patterns. No match --Deny. - Otherwise --
Allow.
- If the capability is repo-scoped and the agent has
- 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 |
| Untrusted (1) | pr.create, issue.comment | -- | repo.push, pr.merge, issue.create, secrets.read, cmd.privileged, workspace.access, flows.modify |
Repo Scope Matching
Tier 2 agents can have their repository access restricted via ScopedRepos.
Three pattern types are supported:
| Pattern | Matches | Does Not Match |
|---|---|---|
core/go-crypt |
core/go-crypt (exact) |
core/go-crypt/sub |
core/* |
core/go-crypt, core/php |
core/go-crypt/sub, other/repo |
core/** |
core/go-crypt, core/php/sub, core/a/b/c |
other/repo |
Wildcards are only supported at the end of patterns.
Approval Queue
When the policy engine returns NeedsApproval, the caller is responsible
for submitting the request to an ApprovalQueue:
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
Every policy evaluation can be recorded in an append-only audit log:
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
Policies can be loaded from JSON at runtime:
// Load from file
engine.ApplyPoliciesFromFile("/etc/agent/policies.json")
// Load from reader
engine.ApplyPolicies(reader)
// Export current policies
engine.ExportPolicies(os.Stdout)
JSON format:
{
"policies": [
{
"tier": 2,
"allowed": ["repo.push", "pr.create", "issue.create"],
"requires_approval": ["pr.merge"],
"denied": ["cmd.privileged", "workspace.access"]
}
]
}
The JSON decoder uses DisallowUnknownFields() 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) | XChaCha20-Poly1305 | 24-byte nonce, 32-byte key |
| Symmetric (alternative) | AES-256-GCM | 12-byte nonce, 32-byte key |
| Password hash (primary) | Argon2id | $argon2id$ format with random 16-byte salt |
| Password hash (fallback) | bcrypt | Configurable cost |
| Content ID | LTHN quasi-salted SHA-256 | RFC-0004 (deterministic, no random salt) |
| Asymmetric | RSA-OAEP-SHA256 | 2048+ bit keys |
| PGP (pgp/) | DSA primary + RSA subkey | ProtonMail go-crypto |
| PGP (openpgp/) | RSA-4096 + AES-256 + SHA-256 | core.Crypt interface |
| HMAC | HMAC-SHA256 / HMAC-SHA512 | Constant-time verification |
| Challenge nonce | crypto/rand | 32 bytes (256-bit entropy) |
| Session token | crypto/rand | 32 bytes, hex-encoded (64 chars) |
Security Notes
-
The LTHN hash (
crypt/lthn) is not suitable for password hashing. It is deterministic with no random salt. Usecrypt.HashPassword(Argon2id) for passwords. -
PGP private keys are not zeroed after use. The ProtonMail go-crypto library does not expose a
Wipemethod. This is a known upstream limitation. -
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 must handleNeedsApprovalby routing through theApprovalQueue. -
All randomness uses
crypto/rand. Never usemath/randfor cryptographic purposes in this codebase. -
Error messages never include secret material. Strings are kept generic:
"invalid password","session not found","failed to decrypt".