Compare commits

..

23 commits
v0.1.4 ... dev

Author SHA1 Message Date
Virgil
c9a7a6fb4b fix(trust): enforce scoped repository defaults
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Failing after 33s
2026-03-30 10:42:05 +00:00
86c68ad1c9 Merge pull request '[agent/codex:gpt-5.3-codex-spark] Read .core/reference/RFC-CORE-008-AGENT-EXPERIENCE.md (the A...' (#14) from main into dev
Some checks failed
Security Scan / security (push) Failing after 15s
Test / test (push) Successful in 10m52s
2026-03-29 15:26:33 +00:00
Virgil
e80ef94552 fix(crypt): align AX error handling and cleanup checks
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 9m7s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 15:25:12 +00:00
f37f5b3a14 Merge pull request 'Fix CodeRabbit findings' (#12) from agent/fix-coderabbit-findings--verify-each-aga into dev
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 8m44s
Reviewed-on: #12
2026-03-24 11:33:05 +00:00
12281f9e76 Merge pull request '[agent/claude] Update go.mod require lines from forge.lthn.ai to dappco.re ...' (#6) from agent/update-go-mod-require-lines-from-forge-l into main
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 10m27s
2026-03-22 01:44:22 +00:00
Snider
62482c7dc9 refactor: migrate imports to dappco.re/go/core/* paths
Some checks failed
Security Scan / security (pull_request) Failing after 9s
Test / test (pull_request) Failing after 8m29s
Update module path from forge.lthn.ai/core/go-crypt to
dappco.re/go/core/crypt. Migrate go-log, go-io, go-i18n imports to
their new dappco.re/go/core/* paths with updated versions (core v0.5.0,
log v0.1.0, io v0.2.0, i18n v0.2.0). Un-migrated modules (cli,
go-store, go-inference) remain at forge.lthn.ai paths.

Also fixes merge conflict marker and duplicate imports in
crypt/openpgp/service.go, and updates CLAUDE.md to reflect new paths.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 01:43:27 +00:00
Snider
69464fe503 refactor: migrate core import to dappco.re/go/core
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 7m16s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:56:26 +00:00
Snider
b85319ae6b chore: sync dependencies for v0.1.12
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:54:33 +00:00
Snider
f5b4c971a2 chore: sync dependencies for v0.1.11
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:54:33 +00:00
Snider
36bf16b06e fix(coderabbit): address review findings
Some checks failed
Security Scan / security (pull_request) Failing after 8s
Test / test (pull_request) Failing after 4m46s
- auth: prevent legacy .lthn fallback when .hash file exists but is
  unreadable or has unexpected format (security fix in verifyPassword
  and Login)
- chachapoly: wrap raw error returns in Decrypt with coreerr.E()
- trust: reject trailing data in LoadPolicies JSON decoder

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 13:32:21 +00:00
e691a9ce51 Merge pull request '[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate...' (#3) from agent/dx-audit-and-fix--1--review-claude-md into main
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Successful in 1m15s
2026-03-17 08:03:20 +00:00
Snider
703dd4588c refactor: standardise coreerr import alias and fix shortenPackageName
Some checks failed
Security Scan / security (pull_request) Failing after 7s
Test / test (pull_request) Successful in 11m55s
- CLAUDE.md: update error convention from core.E() to coreerr.E() to
  match actual codebase usage
- Standardise go-log import alias from `core` to `coreerr` across 6
  files (crypt/symmetric.go, crypt/kdf.go, crypt/crypt.go, crypt/hash.go,
  crypt/checksum.go, crypt/openpgp/service.go) for consistency with the
  11 files already using `coreerr`
- Fix shortenPackageName to handle all forge.lthn.ai/core/* module
  prefixes instead of only cli/ and gui/, fixing TestShortenPackageName

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 07:22:34 +00:00
Snider
f4a219816a chore: sync dependencies for v0.1.10
Some checks failed
Security Scan / security (push) Failing after 13s
Test / test (push) Failing after 8m11s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 22:19:33 +00:00
Snider
60de3e1943 refactor: replace remaining fmt.Errorf/os.* with go-io/go-log conventions
Some checks failed
Security Scan / security (push) Failing after 6s
Test / test (push) Failing after 6m13s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 20:50:38 +00:00
Snider
eacbb025b3 fix(rsa): update test for Go 1.26 GenerateKey resilience
Some checks failed
Security Scan / security (push) Failing after 7s
Test / test (push) Failing after 6m11s
Go 1.26 rsa.GenerateKey recovers from reader errors internally.
Test now verifies no panic instead of expecting an error.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 18:41:47 +00:00
Snider
39643ddba0 fix: replace fmt.Errorf and errors.New with coreerr.E()
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 6m20s
Replace all fmt.Errorf/errors.New calls in auth/auth.go,
crypt/pgp/pgp.go, crypt/rsa/rsa.go, crypt/chachapoly/chachapoly.go,
and trust/trust.go with coreerr.E(op, msg, err) from go-log.
No stale pkg/framework imports found.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 18:19:51 +00:00
Snider
fb55abc52e chore: sync go.mod dependencies
Some checks failed
Security Scan / security (push) Failing after 11s
Test / test (push) Failing after 9m43s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-15 15:36:00 +00:00
Snider
70ebe68cc9 chore: add .core/ and .idea/ to .gitignore
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 8m4s
2026-03-15 10:17:49 +00:00
Snider
13b459a361 fix: update stale import paths and dependency versions from extraction
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Failing after 3m51s
Resolve stale forge.lthn.ai/core/cli v0.1.0 references (tag never existed,
earliest is v0.0.1) and regenerate go.sum via workspace-aware go mod tidy.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-14 13:38:59 +00:00
Snider
55ffb09c84 refactor: migrate cobra → cli.Command, update CLAUDE.md
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Failing after 23s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-13 13:42:49 +00:00
Snider
a009a8d1eb 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>
2026-03-11 13:02:40 +00:00
Snider
e561e1ee1f security: use constant-time comparison for auth credentials
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 21s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:27:38 +00:00
Snider
999b115315 chore: add .core/ build and release configs
Some checks failed
Security Scan / security (push) Failing after 7s
Test / test (push) Failing after 21s
Add go-devops build system configuration for standardised
build, test, and release workflows across the Go ecosystem.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-06 18:52:36 +00:00
42 changed files with 1216 additions and 698 deletions

24
.core/build.yaml Normal file
View file

@ -0,0 +1,24 @@
version: 1
project:
name: go-crypt
description: Cryptography utilities
binary: ""
build:
cgo: false
flags:
- -trimpath
ldflags:
- -s
- -w
targets:
- os: linux
arch: amd64
- os: linux
arch: arm64
- os: darwin
arch: arm64
- os: windows
arch: amd64

20
.core/release.yaml Normal file
View file

@ -0,0 +1,20 @@
version: 1
project:
name: go-crypt
repository: core/go-crypt
publishers: []
changelog:
include:
- feat
- fix
- perf
- refactor
exclude:
- chore
- docs
- style
- test
- ci

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
.core/
.idea/

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/go-crypt.iml" filepath="$PROJECT_DIR$/.idea/go-crypt.iml" />
</modules>
</component>
</project>

View file

@ -1,20 +1,27 @@
# CLAUDE.md — go-crypt # CLAUDE.md
You are a dedicated domain expert for `forge.lthn.ai/core/go-crypt`. Virgil (in This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You are a dedicated domain expert for `dappco.re/go/core/crypt`. Virgil (in
core/go) orchestrates your work. Pick up tasks in phase order, mark `[x]` when core/go) orchestrates your work. Pick up tasks in phase order, mark `[x]` when
done, commit and push. done, commit and push.
## What This Package Does ## What This Package Does
Cryptographic primitives, authentication, and trust policy engine for the Cryptographic primitives, authentication, and trust policy engine for the
Lethean agent platform. Provides: Lethean agent platform. Three independent top-level packages:
- Symmetric encryption — ChaCha20-Poly1305 and AES-256-GCM with Argon2id KDF - **`crypt/`** — Symmetric encryption (ChaCha20-Poly1305, AES-256-GCM), Argon2id
- OpenPGP authentication — challenge-response (online + air-gapped courier mode) KDF, password hashing, HMAC, checksums. Sub-packages: `chachapoly/`, `lthn/`,
- Password hashing — Argon2id (primary) + Bcrypt (fallback) `pgp/`, `rsa/`, `openpgp/`.
- Trust policy engine — 3-tier agent access control with capability evaluation - **`auth/`** — OpenPGP challenge-response authentication (online + air-gapped
- RSA — OAEP-SHA256 key generation and encryption (2048+ bit) courier mode), password-based login with Argon2id→LTHN migration, session
- LTHN hash — RFC-0004 quasi-salted deterministic hash (content IDs, NOT passwords) management via `SessionStore` interface, key rotation and revocation.
- **`trust/`** — 3-tier agent access control (`Registry`, `PolicyEngine`,
`ApprovalQueue`, `AuditLog`), capability evaluation with repo scope matching.
Each package can be imported independently. Only `crypt/openpgp/` integrates
with the Core framework's IPC system (`core.Crypt` interface).
For architecture details see `docs/architecture.md`. For history and findings For architecture details see `docs/architecture.md`. For history and findings
see `docs/history.md`. see `docs/history.md`.
@ -22,37 +29,47 @@ see `docs/history.md`.
## Commands ## Commands
```bash ```bash
go test ./... # Run all tests go test ./... # Run all tests
go test -race ./... # Race detector (required before committing) go test -race ./... # Race detector (required before committing)
go test -v -run TestName ./... # Single test go test -v -run TestName ./... # Single test
go vet ./... # Static analysis (must be clean) go test ./auth/... # Single package
go vet ./... # Static analysis (must be clean)
go test -bench=. -benchmem ./crypt/... # Benchmarks
``` ```
## Local Dependencies ## Local Dependencies
| Module | Local Path | Notes | All `dappco.re/go/core/*` and remaining `forge.lthn.ai/core/*` modules are resolved through the Go workspace
|--------|-----------|-------| (`~/Code/go.work`). Do not add replace directives to `go.mod` — use the
| `forge.lthn.ai/core/go` | `../go` | Framework (core.E, core.Crypt, io.Medium) | workspace file instead.
| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store (session persistence) |
Do not change the replace directive paths. Use a `go.work` for local resolution | Module | Local Path | Purpose |
if working outside the full monorepo. |--------|-----------|---------|
| `dappco.re/go/core` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
| `dappco.re/go/core/log` | `../go-log` | `coreerr.E()` contextual error wrapping |
| `dappco.re/go/core/io` | `../go-io` | `io.Medium` storage abstraction |
| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store (session persistence) |
| `forge.lthn.ai/core/cli` | `../cli` | CLI framework for `cmd/crypt` commands |
No C toolchain or CGo required — all crypto uses pure Go implementations.
## Coding Standards ## Coding Standards
- **UK English**: colour, organisation, centre, artefact, licence, serialise - **UK English**: colour, organisation, centre, artefact, licence, serialise
- **Tests**: testify assert/require, `_Good`/`_Bad`/`_Ugly` naming convention - **Tests**: testify assert/require, `_Good`/`_Bad`/`_Ugly` naming convention
- **Concurrency tests**: 10 goroutines via WaitGroup; must pass `-race` - **Concurrency tests**: 10 goroutines via WaitGroup; must pass `-race`
- **Imports**: stdlib → forge.lthn.ai → third-party, separated by blank lines - **Imports**: stdlib → dappco.re/forge.lthn.ai → third-party, separated by blank lines
- **Errors**: use `core.E("package.Function", "lowercase message", err)`; never - **Errors**: use `coreerr.E("package.Function", "lowercase message", err)` (imported
include secrets in error strings as `coreerr "dappco.re/go/core/log"`); never include secrets in error strings
- **Randomness**: `crypto/rand` only; never `math/rand` - **Randomness**: `crypto/rand` only; never `math/rand`
- **Conventional commits**: `feat(auth):`, `fix(crypt):`, `refactor(trust):` - **Conventional commits**: `feat(auth):`, `fix(crypt):`, `refactor(trust):`
Scopes match package names: `auth`, `crypt`, `trust`, `pgp`, `lthn`, `rsa`,
`openpgp`, `chachapoly`
- **Co-Author**: `Co-Authored-By: Virgil <virgil@lethean.io>` - **Co-Author**: `Co-Authored-By: Virgil <virgil@lethean.io>`
- **Licence**: EUPL-1.2 - **Licence**: EUPL-1.2
## Forge ## Forge
- **Repo**: `forge.lthn.ai/core/go-crypt` - **Repo**: `dappco.re/go/core/crypt`
- **Push via SSH**: `git push forge main` - **Push via SSH**: `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`)

View file

@ -30,18 +30,16 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"strings" "strings"
"sync" "sync"
"time" "time"
coreerr "forge.lthn.ai/core/go-log" "dappco.re/go/core/crypt/crypt"
"dappco.re/go/core/crypt/crypt/lthn"
"forge.lthn.ai/core/go-crypt/crypt" "dappco.re/go/core/crypt/crypt/pgp"
"forge.lthn.ai/core/go-crypt/crypt/lthn" "dappco.re/go/core/io"
"forge.lthn.ai/core/go-crypt/crypt/pgp" coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/go-io"
) )
// Default durations for challenge and session lifetimes. // Default durations for challenge and session lifetimes.
@ -325,7 +323,9 @@ func (a *Authenticator) ValidateSession(token string) (*Session, error) {
} }
if time.Now().After(session.ExpiresAt) { if time.Now().After(session.ExpiresAt) {
_ = a.store.Delete(token) if err := a.store.Delete(token); err != nil {
return nil, coreerr.E(op, "session expired", err)
}
return nil, coreerr.E(op, "session expired", nil) return nil, coreerr.E(op, "session expired", nil)
} }
@ -342,7 +342,9 @@ func (a *Authenticator) RefreshSession(token string) (*Session, error) {
} }
if time.Now().After(session.ExpiresAt) { if time.Now().After(session.ExpiresAt) {
_ = a.store.Delete(token) if err := a.store.Delete(token); err != nil {
return nil, coreerr.E(op, "session expired", err)
}
return nil, coreerr.E(op, "session expired", nil) return nil, coreerr.E(op, "session expired", nil)
} }
@ -391,7 +393,9 @@ func (a *Authenticator) DeleteUser(userID string) error {
} }
// Revoke any active sessions for this user // Revoke any active sessions for this user
_ = a.store.DeleteByUser(userID) if err := a.store.DeleteByUser(userID); err != nil {
return coreerr.E(op, "failed to delete user sessions", err)
}
return nil return nil
} }
@ -421,19 +425,21 @@ func (a *Authenticator) Login(userID, password string) (*Session, error) {
return nil, coreerr.E(op, "failed to read password hash", err) return nil, coreerr.E(op, "failed to read password hash", err)
} }
if strings.HasPrefix(storedHash, "$argon2id$") { if !strings.HasPrefix(storedHash, "$argon2id$") {
valid, err := crypt.VerifyPassword(password, storedHash) return nil, coreerr.E(op, "corrupted password hash", nil)
if err != nil {
return nil, coreerr.E(op, "failed to verify password", err)
}
if !valid {
return nil, coreerr.E(op, "invalid password", nil)
}
return a.createSession(userID)
} }
valid, err := crypt.VerifyPassword(password, storedHash)
if err != nil {
return nil, coreerr.E(op, "failed to verify password", err)
}
if !valid {
return nil, coreerr.E(op, "invalid password", nil)
}
return a.createSession(userID)
} }
// Fall back to legacy LTHN hash (.lthn file) // Fall back to legacy LTHN hash (.lthn file) — only when no .hash file exists
storedHash, err := a.medium.Read(userPath(userID, ".lthn")) storedHash, err := a.medium.Read(userPath(userID, ".lthn"))
if err != nil { if err != nil {
return nil, coreerr.E(op, "user not found", err) return nil, coreerr.E(op, "user not found", err)
@ -567,7 +573,9 @@ func (a *Authenticator) RevokeKey(userID, password, reason string) error {
} }
// Invalidate all sessions // Invalidate all sessions
_ = a.store.DeleteByUser(userID) if err := a.store.DeleteByUser(userID); err != nil {
return coreerr.E(op, "failed to delete user sessions", err)
}
return nil return nil
} }
@ -642,28 +650,36 @@ func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error)
// Tries Argon2id (.hash) first, then falls back to legacy LTHN (.lthn). // Tries Argon2id (.hash) first, then falls back to legacy LTHN (.lthn).
// Returns nil on success, or an error describing the failure. // Returns nil on success, or an error describing the failure.
func (a *Authenticator) verifyPassword(userID, password string) error { func (a *Authenticator) verifyPassword(userID, password string) error {
const op = "auth.verifyPassword"
// Try Argon2id hash first (.hash file) // Try Argon2id hash first (.hash file)
if a.medium.IsFile(userPath(userID, ".hash")) { if a.medium.IsFile(userPath(userID, ".hash")) {
storedHash, err := a.medium.Read(userPath(userID, ".hash")) storedHash, err := a.medium.Read(userPath(userID, ".hash"))
if err == nil && strings.HasPrefix(storedHash, "$argon2id$") { if err != nil {
valid, verr := crypt.VerifyPassword(password, storedHash) return coreerr.E(op, "failed to read password hash", err)
if verr != nil {
return errors.New("failed to verify password")
}
if !valid {
return errors.New("invalid password")
}
return nil
} }
if !strings.HasPrefix(storedHash, "$argon2id$") {
return coreerr.E(op, "corrupted password hash", nil)
}
valid, verr := crypt.VerifyPassword(password, storedHash)
if verr != nil {
return coreerr.E(op, "failed to verify password", verr)
}
if !valid {
return coreerr.E(op, "invalid password", nil)
}
return nil
} }
// Fall back to legacy LTHN hash (.lthn file) // Fall back to legacy LTHN hash (.lthn file) — only when no .hash file exists
storedHash, err := a.medium.Read(userPath(userID, ".lthn")) storedHash, err := a.medium.Read(userPath(userID, ".lthn"))
if err != nil { if err != nil {
return errors.New("user not found") return coreerr.E(op, "user not found", nil)
} }
if !lthn.Verify(password, storedHash) { if !lthn.Verify(password, storedHash) {
return errors.New("invalid password") return coreerr.E(op, "invalid password", nil)
} }
return nil return nil
} }
@ -671,9 +687,11 @@ func (a *Authenticator) verifyPassword(userID, password string) error {
// createSession generates a cryptographically random session token and // createSession generates a cryptographically random session token and
// stores the session via the SessionStore. // stores the session via the SessionStore.
func (a *Authenticator) createSession(userID string) (*Session, error) { func (a *Authenticator) createSession(userID string) (*Session, error) {
const op = "auth.createSession"
tokenBytes := make([]byte, 32) tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil { if _, err := rand.Read(tokenBytes); err != nil {
return nil, fmt.Errorf("auth: failed to generate session token: %w", err) return nil, coreerr.E(op, "failed to generate session token", err)
} }
session := &Session{ session := &Session{
@ -683,7 +701,7 @@ func (a *Authenticator) createSession(userID string) (*Session, error) {
} }
if err := a.store.Set(session); err != nil { if err := a.store.Set(session); err != nil {
return nil, fmt.Errorf("auth: failed to persist session: %w", err) return nil, coreerr.E(op, "failed to persist session", err)
} }
return session, nil return session, nil

View file

@ -11,9 +11,9 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"forge.lthn.ai/core/go-crypt/crypt/lthn" "dappco.re/go/core/crypt/crypt/lthn"
"forge.lthn.ai/core/go-crypt/crypt/pgp" "dappco.re/go/core/crypt/crypt/pgp"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/io"
) )
// helper creates a fresh Authenticator backed by MockMedium. // helper creates a fresh Authenticator backed by MockMedium.

View file

@ -1,14 +1,15 @@
package auth package auth
import ( import (
"errors"
"maps" "maps"
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log"
) )
// ErrSessionNotFound is returned when a session token is not found. // ErrSessionNotFound is returned when a session token is not found.
var ErrSessionNotFound = errors.New("auth: session not found") var ErrSessionNotFound = coreerr.E("auth", "session not found", nil)
// SessionStore abstracts session persistence. // SessionStore abstracts session persistence.
type SessionStore interface { type SessionStore interface {

View file

@ -12,8 +12,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"forge.lthn.ai/core/go-crypt/crypt/lthn" "dappco.re/go/core/crypt/crypt/lthn"
"forge.lthn.ai/core/go-io" "dappco.re/go/core/io"
) )
// --- MemorySessionStore --- // --- MemorySessionStore ---

View file

@ -4,8 +4,8 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"dappco.re/go/core/crypt/crypt"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-crypt/crypt"
) )
// Checksum command flags // Checksum command flags

View file

@ -2,11 +2,11 @@ package crypt
import ( import (
"fmt" "fmt"
"os"
"strings" "strings"
"dappco.re/go/core/crypt/crypt"
coreio "dappco.re/go/core/io"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-crypt/crypt"
) )
// Encrypt command flags // Encrypt command flags
@ -53,10 +53,11 @@ func runEncrypt(path string) error {
return cli.Err("passphrase cannot be empty") return cli.Err("passphrase cannot be empty")
} }
data, err := os.ReadFile(path) raw, err := coreio.Local.Read(path)
if err != nil { if err != nil {
return cli.Wrap(err, "failed to read file") return cli.Wrap(err, "failed to read file")
} }
data := []byte(raw)
var encrypted []byte var encrypted []byte
if encryptAES { if encryptAES {
@ -69,7 +70,7 @@ func runEncrypt(path string) error {
} }
outPath := path + ".enc" outPath := path + ".enc"
if err := os.WriteFile(outPath, encrypted, 0o600); err != nil { if err := coreio.Local.Write(outPath, string(encrypted)); err != nil {
return cli.Wrap(err, "failed to write encrypted file") return cli.Wrap(err, "failed to write encrypted file")
} }
@ -86,10 +87,11 @@ func runDecrypt(path string) error {
return cli.Err("passphrase cannot be empty") return cli.Err("passphrase cannot be empty")
} }
data, err := os.ReadFile(path) raw, err := coreio.Local.Read(path)
if err != nil { if err != nil {
return cli.Wrap(err, "failed to read file") return cli.Wrap(err, "failed to read file")
} }
data := []byte(raw)
var decrypted []byte var decrypted []byte
if encryptAES { if encryptAES {
@ -106,7 +108,7 @@ func runDecrypt(path string) error {
outPath = path + ".dec" outPath = path + ".dec"
} }
if err := os.WriteFile(outPath, decrypted, 0o600); err != nil { if err := coreio.Local.Write(outPath, string(decrypted)); err != nil {
return cli.Wrap(err, "failed to write decrypted file") return cli.Wrap(err, "failed to write decrypted file")
} }

View file

@ -3,8 +3,9 @@ package crypt
import ( import (
"fmt" "fmt"
"dappco.re/go/core/crypt/crypt"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-crypt/crypt"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )

View file

@ -4,9 +4,8 @@
package testcmd package testcmd
import ( import (
"dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra"
) )
// Style aliases from shared // Style aliases from shared
@ -32,11 +31,11 @@ var (
testJSON bool testJSON bool
) )
var testCmd = &cobra.Command{ var testCmd = &cli.Command{
Use: "test", Use: "test",
Short: i18n.T("cmd.test.short"), Short: i18n.T("cmd.test.short"),
Long: i18n.T("cmd.test.long"), Long: i18n.T("cmd.test.long"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cli.Command, args []string) error {
return runTest(testVerbose, testCoverage, testShort, testPkg, testRun, testRace, testJSON) return runTest(testVerbose, testCoverage, testShort, testPkg, testRun, testRace, testJSON)
}, },
} }
@ -52,7 +51,7 @@ func initTestFlags() {
} }
// AddTestCommands registers the 'test' command and all subcommands. // AddTestCommands registers the 'test' command and all subcommands.
func AddTestCommands(root *cobra.Command) { func AddTestCommands(root *cli.Command) {
initTestFlags() initTestFlags()
root.AddCommand(testCmd) root.AddCommand(testCmd)
} }

View file

@ -10,7 +10,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"forge.lthn.ai/core/go-i18n" "dappco.re/go/core/i18n"
) )
type packageCoverage struct { type packageCoverage struct {
@ -33,8 +33,8 @@ func parseTestOutput(output string) testResults {
results := testResults{} results := testResults{}
// Regex patterns - handle both timed and cached test results // Regex patterns - handle both timed and cached test results
// Example: ok forge.lthn.ai/core/go-crypt/crypt 0.015s coverage: 91.2% of statements // Example: ok dappco.re/go/core/crypt/crypt 0.015s coverage: 91.2% of statements
// Example: ok forge.lthn.ai/core/go-crypt/crypt (cached) coverage: 91.2% of statements // Example: ok dappco.re/go/core/crypt/crypt (cached) coverage: 91.2% of statements
okPattern := regexp.MustCompile(`^ok\s+(\S+)\s+(?:[\d.]+s|\(cached\))(?:\s+coverage:\s+([\d.]+)%)?`) okPattern := regexp.MustCompile(`^ok\s+(\S+)\s+(?:[\d.]+s|\(cached\))(?:\s+coverage:\s+([\d.]+)%)?`)
failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`) failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`)
skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`) skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`)
@ -171,15 +171,15 @@ func formatCoverage(cov float64) string {
} }
func shortenPackageName(name string) string { func shortenPackageName(name string) string {
// Remove common prefixes const modulePrefix = "dappco.re/go/"
prefixes := []string{ if strings.HasPrefix(name, modulePrefix) {
"forge.lthn.ai/core/cli/", remainder := strings.TrimPrefix(name, modulePrefix)
"forge.lthn.ai/core/gui/", // If there's a sub-path (e.g. "go/pkg/foo"), strip the module name
} if idx := strings.Index(remainder, "/"); idx >= 0 {
for _, prefix := range prefixes { return remainder[idx+1:]
if strings.HasPrefix(name, prefix) {
return strings.TrimPrefix(name, prefix)
} }
// Module root (e.g. "cli-php") — return as-is
return remainder
} }
return filepath.Base(name) return filepath.Base(name)
} }

View file

@ -2,7 +2,6 @@ package testcmd
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -10,13 +9,14 @@ import (
"runtime" "runtime"
"strings" "strings"
"forge.lthn.ai/core/go-i18n" "dappco.re/go/core/i18n"
coreerr "dappco.re/go/core/log"
) )
func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error { func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error {
// Detect if we're in a Go project // Detect if we're in a Go project
if _, err := os.Stat("go.mod"); os.IsNotExist(err) { if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
return errors.New(i18n.T("cmd.test.error.no_go_mod")) return coreerr.E("cmd.test", i18n.T("cmd.test.error.no_go_mod"), nil)
} }
// Build command arguments // Build command arguments
@ -49,7 +49,11 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
// Create command // Create command
cmd := exec.Command("go", args...) cmd := exec.Command("go", args...)
cmd.Dir, _ = os.Getwd() cwd, err := os.Getwd()
if err != nil {
return coreerr.E("cmd.test", "failed to determine working directory", err)
}
cmd.Dir = cwd
// Set environment to suppress macOS linker warnings // Set environment to suppress macOS linker warnings
cmd.Env = append(os.Environ(), getMacOSDeploymentTarget()) cmd.Env = append(os.Environ(), getMacOSDeploymentTarget())
@ -76,7 +80,7 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
cmd.Stderr = &stderr cmd.Stderr = &stderr
} }
err := cmd.Run() err = cmd.Run()
exitCode := 0 exitCode := 0
if err != nil { if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok { if exitErr, ok := err.(*exec.ExitError); ok {
@ -94,7 +98,7 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
// JSON output for CI/agents // JSON output for CI/agents
printJSONResults(results, exitCode) printJSONResults(results, exitCode)
if exitCode != 0 { if exitCode != 0 {
return errors.New(i18n.T("i18n.fail.run", "tests")) return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), nil)
} }
return nil return nil
} }
@ -110,7 +114,7 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
if exitCode != 0 { if exitCode != 0 {
fmt.Printf("\n%s %s\n", testFailStyle.Render(i18n.T("cli.fail")), i18n.T("cmd.test.tests_failed")) fmt.Printf("\n%s %s\n", testFailStyle.Render(i18n.T("cli.fail")), i18n.T("cmd.test.tests_failed"))
return errors.New(i18n.T("i18n.fail.run", "tests")) return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), nil)
} }
fmt.Printf("\n%s %s\n", testPassStyle.Render(i18n.T("cli.pass")), i18n.T("common.result.all_passed")) fmt.Printf("\n%s %s\n", testPassStyle.Render(i18n.T("cli.pass")), i18n.T("common.result.all_passed"))

View file

@ -7,7 +7,7 @@ import (
) )
func TestShortenPackageName(t *testing.T) { func TestShortenPackageName(t *testing.T) {
assert.Equal(t, "pkg/foo", shortenPackageName("forge.lthn.ai/core/go/pkg/foo")) assert.Equal(t, "pkg/foo", shortenPackageName("dappco.re/go/core/pkg/foo"))
assert.Equal(t, "cli-php", shortenPackageName("forge.lthn.ai/core/cli-php")) assert.Equal(t, "cli-php", shortenPackageName("forge.lthn.ai/core/cli-php"))
assert.Equal(t, "bar", shortenPackageName("github.com/other/bar")) assert.Equal(t, "bar", shortenPackageName("github.com/other/bar"))
} }
@ -19,16 +19,16 @@ func TestFormatCoverageTest(t *testing.T) {
} }
func TestParseTestOutput(t *testing.T) { func TestParseTestOutput(t *testing.T) {
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements output := `ok dappco.re/go/core/pkg/foo 0.100s coverage: 50.0% of statements
FAIL forge.lthn.ai/core/go/pkg/bar FAIL dappco.re/go/core/pkg/bar
? forge.lthn.ai/core/go/pkg/baz [no test files] ? dappco.re/go/core/pkg/baz [no test files]
` `
results := parseTestOutput(output) results := parseTestOutput(output)
assert.Equal(t, 1, results.passed) assert.Equal(t, 1, results.passed)
assert.Equal(t, 1, results.failed) assert.Equal(t, 1, results.failed)
assert.Equal(t, 1, results.skipped) assert.Equal(t, 1, results.skipped)
assert.Equal(t, 1, len(results.failedPkgs)) assert.Equal(t, 1, len(results.failedPkgs))
assert.Equal(t, "forge.lthn.ai/core/go/pkg/bar", results.failedPkgs[0]) assert.Equal(t, "dappco.re/go/core/pkg/bar", results.failedPkgs[0])
assert.Equal(t, 1, len(results.packages)) assert.Equal(t, 1, len(results.packages))
assert.Equal(t, 50.0, results.packages[0].coverage) assert.Equal(t, 50.0, results.packages[0].coverage)
} }
@ -37,8 +37,8 @@ func TestPrintCoverageSummarySafe(t *testing.T) {
// This tests the bug fix for long package names causing negative Repeat count // This tests the bug fix for long package names causing negative Repeat count
results := testResults{ results := testResults{
packages: []packageCoverage{ packages: []packageCoverage{
{name: "forge.lthn.ai/core/go/pkg/short", coverage: 100, hasCov: true}, {name: "dappco.re/go/core/pkg/short", coverage: 100, hasCov: true},
{name: "forge.lthn.ai/core/go/pkg/a-very-very-very-very-very-long-package-name-that-might-cause-issues", coverage: 80, hasCov: true}, {name: "dappco.re/go/core/pkg/a-very-very-very-very-very-long-package-name-that-might-cause-issues", coverage: 80, hasCov: true},
}, },
passed: 2, passed: 2,
totalCov: 180, totalCov: 180,

View file

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
@ -25,21 +27,23 @@ func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
// Decrypt decrypts data using ChaCha20-Poly1305. // Decrypt decrypts data using ChaCha20-Poly1305.
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
const op = "chachapoly.Decrypt"
aead, err := chacha20poly1305.NewX(key) aead, err := chacha20poly1305.NewX(key)
if err != nil { if err != nil {
return nil, err return nil, coreerr.E(op, "failed to create cipher", err)
} }
minLen := aead.NonceSize() + aead.Overhead() minLen := aead.NonceSize() + aead.Overhead()
if len(ciphertext) < minLen { if len(ciphertext) < minLen {
return nil, fmt.Errorf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen) return nil, coreerr.E(op, fmt.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil)
} }
nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():] nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():]
decrypted, err := aead.Open(nil, nonce, ciphertext, nil) decrypted, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil { if err != nil {
return nil, err return nil, coreerr.E(op, "decryption failed", err)
} }
if len(decrypted) == 0 { if len(decrypted) == 0 {

View file

@ -2,9 +2,10 @@ package chachapoly
import ( import (
"crypto/rand" "crypto/rand"
"errors"
"testing" "testing"
coreerr "dappco.re/go/core/log"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -12,7 +13,7 @@ import (
type mockReader struct{} type mockReader struct{}
func (r *mockReader) Read(p []byte) (n int, err error) { func (r *mockReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error") return 0, coreerr.E("chachapoly.mockReader.Read", "read error", nil)
} }
func TestEncryptDecrypt(t *testing.T) { func TestEncryptDecrypt(t *testing.T) {

View file

@ -7,20 +7,20 @@ import (
"io" "io"
"os" "os"
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
) )
// SHA256File computes the SHA-256 checksum of a file and returns it as a hex string. // SHA256File computes the SHA-256 checksum of a file and returns it as a hex string.
func SHA256File(path string) (string, error) { func SHA256File(path string) (string, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return "", core.E("crypt.SHA256File", "failed to open file", err) return "", coreerr.E("crypt.SHA256File", "failed to open file", err)
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
h := sha256.New() h := sha256.New()
if _, err := io.Copy(h, f); err != nil { if _, err := io.Copy(h, f); err != nil {
return "", core.E("crypt.SHA256File", "failed to read file", err) return "", coreerr.E("crypt.SHA256File", "failed to read file", err)
} }
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil
@ -30,13 +30,13 @@ func SHA256File(path string) (string, error) {
func SHA512File(path string) (string, error) { func SHA512File(path string) (string, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return "", core.E("crypt.SHA512File", "failed to open file", err) return "", coreerr.E("crypt.SHA512File", "failed to open file", err)
} }
defer func() { _ = f.Close() }() defer func() { _ = f.Close() }()
h := sha512.New() h := sha512.New()
if _, err := io.Copy(h, f); err != nil { if _, err := io.Copy(h, f); err != nil {
return "", core.E("crypt.SHA512File", "failed to read file", err) return "", coreerr.E("crypt.SHA512File", "failed to read file", err)
} }
return hex.EncodeToString(h.Sum(nil)), nil return hex.EncodeToString(h.Sum(nil)), nil

View file

@ -1,7 +1,7 @@
package crypt package crypt
import ( import (
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
) )
// Encrypt encrypts data with a passphrase using ChaCha20-Poly1305. // Encrypt encrypts data with a passphrase using ChaCha20-Poly1305.
@ -10,14 +10,14 @@ import (
func Encrypt(plaintext, passphrase []byte) ([]byte, error) { func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
salt, err := generateSalt(argon2SaltLen) salt, err := generateSalt(argon2SaltLen)
if err != nil { if err != nil {
return nil, core.E("crypt.Encrypt", "failed to generate salt", err) return nil, coreerr.E("crypt.Encrypt", "failed to generate salt", err)
} }
key := DeriveKey(passphrase, salt, argon2KeyLen) key := DeriveKey(passphrase, salt, argon2KeyLen)
encrypted, err := ChaCha20Encrypt(plaintext, key) encrypted, err := ChaCha20Encrypt(plaintext, key)
if err != nil { if err != nil {
return nil, core.E("crypt.Encrypt", "failed to encrypt", err) return nil, coreerr.E("crypt.Encrypt", "failed to encrypt", err)
} }
// Prepend salt to the encrypted data (which already has nonce prepended) // Prepend salt to the encrypted data (which already has nonce prepended)
@ -31,7 +31,7 @@ func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
// Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext. // Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
func Decrypt(ciphertext, passphrase []byte) ([]byte, error) { func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
if len(ciphertext) < argon2SaltLen { if len(ciphertext) < argon2SaltLen {
return nil, core.E("crypt.Decrypt", "ciphertext too short", nil) return nil, coreerr.E("crypt.Decrypt", "ciphertext too short", nil)
} }
salt := ciphertext[:argon2SaltLen] salt := ciphertext[:argon2SaltLen]
@ -41,7 +41,7 @@ func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
plaintext, err := ChaCha20Decrypt(encrypted, key) plaintext, err := ChaCha20Decrypt(encrypted, key)
if err != nil { if err != nil {
return nil, core.E("crypt.Decrypt", "failed to decrypt", err) return nil, coreerr.E("crypt.Decrypt", "failed to decrypt", err)
} }
return plaintext, nil return plaintext, nil
@ -53,14 +53,14 @@ func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
func EncryptAES(plaintext, passphrase []byte) ([]byte, error) { func EncryptAES(plaintext, passphrase []byte) ([]byte, error) {
salt, err := generateSalt(argon2SaltLen) salt, err := generateSalt(argon2SaltLen)
if err != nil { if err != nil {
return nil, core.E("crypt.EncryptAES", "failed to generate salt", err) return nil, coreerr.E("crypt.EncryptAES", "failed to generate salt", err)
} }
key := DeriveKey(passphrase, salt, argon2KeyLen) key := DeriveKey(passphrase, salt, argon2KeyLen)
encrypted, err := AESGCMEncrypt(plaintext, key) encrypted, err := AESGCMEncrypt(plaintext, key)
if err != nil { if err != nil {
return nil, core.E("crypt.EncryptAES", "failed to encrypt", err) return nil, coreerr.E("crypt.EncryptAES", "failed to encrypt", err)
} }
result := make([]byte, 0, len(salt)+len(encrypted)) result := make([]byte, 0, len(salt)+len(encrypted))
@ -73,7 +73,7 @@ func EncryptAES(plaintext, passphrase []byte) ([]byte, error) {
// Expects format: salt (16 bytes) + nonce (12 bytes) + ciphertext. // Expects format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
func DecryptAES(ciphertext, passphrase []byte) ([]byte, error) { func DecryptAES(ciphertext, passphrase []byte) ([]byte, error) {
if len(ciphertext) < argon2SaltLen { if len(ciphertext) < argon2SaltLen {
return nil, core.E("crypt.DecryptAES", "ciphertext too short", nil) return nil, coreerr.E("crypt.DecryptAES", "ciphertext too short", nil)
} }
salt := ciphertext[:argon2SaltLen] salt := ciphertext[:argon2SaltLen]
@ -83,7 +83,7 @@ func DecryptAES(ciphertext, passphrase []byte) ([]byte, error) {
plaintext, err := AESGCMDecrypt(encrypted, key) plaintext, err := AESGCMDecrypt(encrypted, key)
if err != nil { if err != nil {
return nil, core.E("crypt.DecryptAES", "failed to decrypt", err) return nil, coreerr.E("crypt.DecryptAES", "failed to decrypt", err)
} }
return plaintext, nil return plaintext, nil

View file

@ -6,7 +6,8 @@ import (
"fmt" "fmt"
"strings" "strings"
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -16,7 +17,7 @@ import (
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
salt, err := generateSalt(argon2SaltLen) salt, err := generateSalt(argon2SaltLen)
if err != nil { if err != nil {
return "", core.E("crypt.HashPassword", "failed to generate salt", err) return "", coreerr.E("crypt.HashPassword", "failed to generate salt", err)
} }
hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Parallelism, argon2KeyLen) hash := argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Parallelism, argon2KeyLen)
@ -36,29 +37,29 @@ func HashPassword(password string) (string, error) {
func VerifyPassword(password, hash string) (bool, error) { func VerifyPassword(password, hash string) (bool, error) {
parts := strings.Split(hash, "$") parts := strings.Split(hash, "$")
if len(parts) != 6 { if len(parts) != 6 {
return false, core.E("crypt.VerifyPassword", "invalid hash format", nil) return false, coreerr.E("crypt.VerifyPassword", "invalid hash format", nil)
} }
var version int var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false, core.E("crypt.VerifyPassword", "failed to parse version", err) return false, coreerr.E("crypt.VerifyPassword", "failed to parse version", err)
} }
var memory uint32 var memory uint32
var time uint32 var time uint32
var parallelism uint8 var parallelism uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &parallelism); err != nil { if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &parallelism); err != nil {
return false, core.E("crypt.VerifyPassword", "failed to parse parameters", err) return false, coreerr.E("crypt.VerifyPassword", "failed to parse parameters", err)
} }
salt, err := base64.RawStdEncoding.DecodeString(parts[4]) salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil { if err != nil {
return false, core.E("crypt.VerifyPassword", "failed to decode salt", err) return false, coreerr.E("crypt.VerifyPassword", "failed to decode salt", err)
} }
expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5]) expectedHash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil { if err != nil {
return false, core.E("crypt.VerifyPassword", "failed to decode hash", err) return false, coreerr.E("crypt.VerifyPassword", "failed to decode hash", err)
} }
computedHash := argon2.IDKey([]byte(password), salt, time, memory, parallelism, uint32(len(expectedHash))) computedHash := argon2.IDKey([]byte(password), salt, time, memory, parallelism, uint32(len(expectedHash)))
@ -71,7 +72,7 @@ func VerifyPassword(password, hash string) (bool, error) {
func HashBcrypt(password string, cost int) (string, error) { func HashBcrypt(password string, cost int) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil { if err != nil {
return "", core.E("crypt.HashBcrypt", "failed to hash password", err) return "", coreerr.E("crypt.HashBcrypt", "failed to hash password", err)
} }
return string(hash), nil return string(hash), nil
} }
@ -83,7 +84,7 @@ func VerifyBcrypt(password, hash string) (bool, error) {
return false, nil return false, nil
} }
if err != nil { if err != nil {
return false, core.E("crypt.VerifyBcrypt", "failed to verify password", err) return false, coreerr.E("crypt.VerifyBcrypt", "failed to verify password", err)
} }
return true, nil return true, nil
} }

View file

@ -7,7 +7,8 @@ import (
"crypto/sha256" "crypto/sha256"
"io" "io"
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"golang.org/x/crypto/hkdf" "golang.org/x/crypto/hkdf"
"golang.org/x/crypto/scrypt" "golang.org/x/crypto/scrypt"
@ -33,7 +34,7 @@ func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte {
func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error) { func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error) {
key, err := scrypt.Key(passphrase, salt, 32768, 8, 1, keyLen) key, err := scrypt.Key(passphrase, salt, 32768, 8, 1, keyLen)
if err != nil { if err != nil {
return nil, core.E("crypt.DeriveKeyScrypt", "failed to derive key", err) return nil, coreerr.E("crypt.DeriveKeyScrypt", "failed to derive key", err)
} }
return key, nil return key, nil
} }
@ -45,7 +46,7 @@ func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error) {
reader := hkdf.New(sha256.New, secret, salt, info) reader := hkdf.New(sha256.New, secret, salt, info)
key := make([]byte, keyLen) key := make([]byte, keyLen)
if _, err := io.ReadFull(reader, key); err != nil { if _, err := io.ReadFull(reader, key); err != nil {
return nil, core.E("crypt.HKDF", "failed to derive key", err) return nil, coreerr.E("crypt.HKDF", "failed to derive key", err)
} }
return key, nil return key, nil
} }
@ -54,7 +55,7 @@ func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error) {
func generateSalt(length int) ([]byte, error) { func generateSalt(length int) ([]byte, error) {
salt := make([]byte, length) salt := make([]byte, length)
if _, err := rand.Read(salt); err != nil { if _, err := rand.Read(salt); err != nil {
return nil, core.E("crypt.generateSalt", "failed to generate random salt", err) return nil, coreerr.E("crypt.generateSalt", "failed to generate random salt", err)
} }
return salt, nil return salt, nil
} }

View file

@ -18,6 +18,7 @@ package lthn
import ( import (
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"encoding/hex" "encoding/hex"
) )
@ -87,8 +88,8 @@ func createSalt(input string) string {
// Verify checks if an input string produces the given hash. // Verify checks if an input string produces the given hash.
// Returns true if Hash(input) equals the provided hash value. // Returns true if Hash(input) equals the provided hash value.
// Uses direct string comparison - for security-critical applications, // Uses constant-time comparison to prevent timing attacks.
// consider using constant-time comparison.
func Verify(input string, hash string) bool { func Verify(input string, hash string) bool {
return Hash(input) == hash computed := Hash(input)
return subtle.ConstantTimeCompare([]byte(computed), []byte(hash)) == 1
} }

View file

@ -6,15 +6,15 @@ import (
goio "io" goio "io"
"strings" "strings"
framework "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor" "github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/ProtonMail/go-crypto/openpgp/packet"
core "forge.lthn.ai/core/go-log"
framework "forge.lthn.ai/core/go/pkg/core"
) )
// Service implements the framework.Crypt interface using OpenPGP. // Service provides OpenPGP cryptographic operations.
type Service struct { type Service struct {
core *framework.Core core *framework.Core
} }
@ -36,19 +36,19 @@ func (s *Service) CreateKeyPair(name, passphrase string) (string, error) {
entity, err := openpgp.NewEntity(name, "Workspace Key", "", config) entity, err := openpgp.NewEntity(name, "Workspace Key", "", config)
if err != nil { if err != nil {
return "", core.E("openpgp.CreateKeyPair", "failed to create entity", err) return "", coreerr.E("openpgp.CreateKeyPair", "failed to create entity", err)
} }
// Encrypt private key if passphrase is provided // Encrypt private key if passphrase is provided
if passphrase != "" { if passphrase != "" {
err = entity.PrivateKey.Encrypt([]byte(passphrase)) err = entity.PrivateKey.Encrypt([]byte(passphrase))
if err != nil { if err != nil {
return "", core.E("openpgp.CreateKeyPair", "failed to encrypt private key", err) return "", coreerr.E("openpgp.CreateKeyPair", "failed to encrypt private key", err)
} }
for _, subkey := range entity.Subkeys { for _, subkey := range entity.Subkeys {
err = subkey.PrivateKey.Encrypt([]byte(passphrase)) err = subkey.PrivateKey.Encrypt([]byte(passphrase))
if err != nil { if err != nil {
return "", core.E("openpgp.CreateKeyPair", "failed to encrypt subkey", err) return "", coreerr.E("openpgp.CreateKeyPair", "failed to encrypt subkey", err)
} }
} }
} }
@ -56,22 +56,22 @@ func (s *Service) CreateKeyPair(name, passphrase string) (string, error) {
var buf bytes.Buffer var buf bytes.Buffer
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
if err != nil { if err != nil {
return "", core.E("openpgp.CreateKeyPair", "failed to create armor encoder", err) return "", coreerr.E("openpgp.CreateKeyPair", "failed to create armor encoder", err)
} }
// Manual serialization to avoid panic from re-signing encrypted keys // Manual serialisation to avoid panic from re-signing encrypted keys
err = s.serializeEntity(w, entity) err = serializeEntity(w, entity)
if err != nil { if err != nil {
w.Close() w.Close()
return "", core.E("openpgp.CreateKeyPair", "failed to serialize private key", err) return "", coreerr.E("openpgp.CreateKeyPair", "failed to serialise private key", err)
} }
w.Close() w.Close()
return buf.String(), nil return buf.String(), nil
} }
// serializeEntity manually serializes an OpenPGP entity to avoid re-signing. // serializeEntity manually serialises an OpenPGP entity to avoid re-signing.
func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error { func serializeEntity(w goio.Writer, e *openpgp.Entity) error {
err := e.PrivateKey.Serialize(w) err := e.PrivateKey.Serialize(w)
if err != nil { if err != nil {
return err return err
@ -104,13 +104,13 @@ func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error {
func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) { func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) {
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath)) entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath))
if err != nil { if err != nil {
return "", core.E("openpgp.EncryptPGP", "failed to read recipient key", err) return "", coreerr.E("openpgp.EncryptPGP", "failed to read recipient key", err)
} }
var armoredBuf bytes.Buffer var armoredBuf bytes.Buffer
armoredWriter, err := armor.Encode(&armoredBuf, "PGP MESSAGE", nil) armoredWriter, err := armor.Encode(&armoredBuf, "PGP MESSAGE", nil)
if err != nil { if err != nil {
return "", core.E("openpgp.EncryptPGP", "failed to create armor encoder", err) return "", coreerr.E("openpgp.EncryptPGP", "failed to create armor encoder", err)
} }
// MultiWriter to write to both the provided writer and our armored buffer // MultiWriter to write to both the provided writer and our armored buffer
@ -119,14 +119,14 @@ func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opt
w, err := openpgp.Encrypt(mw, entityList, nil, nil, nil) w, err := openpgp.Encrypt(mw, entityList, nil, nil, nil)
if err != nil { if err != nil {
armoredWriter.Close() armoredWriter.Close()
return "", core.E("openpgp.EncryptPGP", "failed to start encryption", err) return "", coreerr.E("openpgp.EncryptPGP", "failed to start encryption", err)
} }
_, err = goio.WriteString(w, data) _, err = goio.WriteString(w, data)
if err != nil { if err != nil {
w.Close() w.Close()
armoredWriter.Close() armoredWriter.Close()
return "", core.E("openpgp.EncryptPGP", "failed to write data", err) return "", coreerr.E("openpgp.EncryptPGP", "failed to write data", err)
} }
w.Close() w.Close()
@ -139,35 +139,37 @@ func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opt
func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) { func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) {
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey)) entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey))
if err != nil { if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to read private key", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to read private key", err)
} }
entity := entityList[0] entity := entityList[0]
if entity.PrivateKey.Encrypted { if entity.PrivateKey.Encrypted {
err = entity.PrivateKey.Decrypt([]byte(passphrase)) err = entity.PrivateKey.Decrypt([]byte(passphrase))
if err != nil { if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to decrypt private key", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to decrypt private key", err)
} }
for _, subkey := range entity.Subkeys { for _, subkey := range entity.Subkeys {
_ = subkey.PrivateKey.Decrypt([]byte(passphrase)) if err := subkey.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
return "", coreerr.E("openpgp.DecryptPGP", "failed to decrypt subkey", err)
}
} }
} }
// Decrypt armored message // Decrypt armored message
block, err := armor.Decode(strings.NewReader(message)) block, err := armor.Decode(strings.NewReader(message))
if err != nil { if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to decode armored message", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to decode armored message", err)
} }
md, err := openpgp.ReadMessage(block.Body, entityList, nil, nil) md, err := openpgp.ReadMessage(block.Body, entityList, nil, nil)
if err != nil { if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to read message", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to read message", err)
} }
var buf bytes.Buffer var buf bytes.Buffer
_, err = goio.Copy(&buf, md.UnverifiedBody) _, err = goio.Copy(&buf, md.UnverifiedBody)
if err != nil { if err != nil {
return "", core.E("openpgp.DecryptPGP", "failed to read decrypted body", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to read decrypted body", err)
} }
return buf.String(), nil return buf.String(), nil
@ -188,6 +190,3 @@ func (s *Service) HandleIPCEvents(c *framework.Core, msg framework.Message) erro
} }
return nil return nil
} }
// Ensure Service implements framework.Crypt.
var _ framework.Crypt = (*Service)(nil)

View file

@ -4,40 +4,40 @@ import (
"bytes" "bytes"
"testing" "testing"
framework "forge.lthn.ai/core/go/pkg/core" framework "dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestCreateKeyPair(t *testing.T) { func TestCreateKeyPair(t *testing.T) {
c, _ := framework.New() c := framework.New()
s := &Service{core: c} s := &Service{core: c}
privKey, err := s.CreateKeyPair("test user", "password123") privKey, err := s.CreateKeyPair("test user", "password123")
assert.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, privKey) require.NotEmpty(t, privKey)
assert.Contains(t, privKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----") assert.Contains(t, privKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
} }
func TestEncryptDecrypt(t *testing.T) { func TestEncryptDecrypt(t *testing.T) {
c, _ := framework.New() c := framework.New()
s := &Service{core: c} s := &Service{core: c}
passphrase := "secret" passphrase := "secret"
privKey, err := s.CreateKeyPair("test user", passphrase) privKey, err := s.CreateKeyPair("test user", passphrase)
assert.NoError(t, err) require.NoError(t, err)
// In this simple test, the public key is also in the armored private key string // ReadArmoredKeyRing extracts public keys from armored private key blocks
// (openpgp.ReadArmoredKeyRing reads both)
publicKey := privKey publicKey := privKey
data := "hello openpgp" data := "hello openpgp"
var buf bytes.Buffer var buf bytes.Buffer
armored, err := s.EncryptPGP(&buf, publicKey, data) armored, err := s.EncryptPGP(&buf, publicKey, data)
assert.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, armored) assert.NotEmpty(t, armored)
assert.NotEmpty(t, buf.String()) assert.NotEmpty(t, buf.String())
decrypted, err := s.DecryptPGP(privKey, armored, passphrase) decrypted, err := s.DecryptPGP(privKey, armored, passphrase)
assert.NoError(t, err) require.NoError(t, err)
assert.Equal(t, data, decrypted) assert.Equal(t, data, decrypted)
} }

View file

@ -6,10 +6,10 @@ package pgp
import ( import (
"bytes" "bytes"
"errors"
"fmt"
"io" "io"
coreerr "dappco.re/go/core/log"
"github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp"
"github.com/ProtonMail/go-crypto/openpgp/armor" "github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/ProtonMail/go-crypto/openpgp/packet"
@ -25,26 +25,30 @@ type KeyPair struct {
// If password is non-empty, the private key is encrypted with it. // If password is non-empty, the private key is encrypted with it.
// Returns a KeyPair with armored public and private keys. // Returns a KeyPair with armored public and private keys.
func CreateKeyPair(name, email, password string) (*KeyPair, error) { func CreateKeyPair(name, email, password string) (*KeyPair, error) {
const op = "pgp.CreateKeyPair"
entity, err := openpgp.NewEntity(name, "", email, nil) entity, err := openpgp.NewEntity(name, "", email, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to create entity: %w", err) return nil, coreerr.E(op, "failed to create entity", err)
} }
// Sign all the identities // Sign all the identities
for _, id := range entity.Identities { for _, id := range entity.Identities {
_ = id.SelfSignature.SignUserId(id.UserId.Id, entity.PrimaryKey, entity.PrivateKey, nil) if err := id.SelfSignature.SignUserId(id.UserId.Id, entity.PrimaryKey, entity.PrivateKey, nil); err != nil {
return nil, coreerr.E(op, "failed to sign identity", err)
}
} }
// Encrypt private key with password if provided // Encrypt private key with password if provided
if password != "" { if password != "" {
err = entity.PrivateKey.Encrypt([]byte(password)) err = entity.PrivateKey.Encrypt([]byte(password))
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to encrypt private key: %w", err) return nil, coreerr.E(op, "failed to encrypt private key", err)
} }
for _, subkey := range entity.Subkeys { for _, subkey := range entity.Subkeys {
err = subkey.PrivateKey.Encrypt([]byte(password)) err = subkey.PrivateKey.Encrypt([]byte(password))
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to encrypt subkey: %w", err) return nil, coreerr.E(op, "failed to encrypt subkey", err)
} }
} }
} }
@ -53,11 +57,11 @@ func CreateKeyPair(name, email, password string) (*KeyPair, error) {
pubKeyBuf := new(bytes.Buffer) pubKeyBuf := new(bytes.Buffer)
pubKeyWriter, err := armor.Encode(pubKeyBuf, openpgp.PublicKeyType, nil) pubKeyWriter, err := armor.Encode(pubKeyBuf, openpgp.PublicKeyType, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to create armored public key writer: %w", err) return nil, coreerr.E(op, "failed to create armored public key writer", err)
} }
if err := entity.Serialize(pubKeyWriter); err != nil { if err := entity.Serialize(pubKeyWriter); err != nil {
pubKeyWriter.Close() pubKeyWriter.Close()
return nil, fmt.Errorf("pgp: failed to serialize public key: %w", err) return nil, coreerr.E(op, "failed to serialize public key", err)
} }
pubKeyWriter.Close() pubKeyWriter.Close()
@ -65,18 +69,18 @@ func CreateKeyPair(name, email, password string) (*KeyPair, error) {
privKeyBuf := new(bytes.Buffer) privKeyBuf := new(bytes.Buffer)
privKeyWriter, err := armor.Encode(privKeyBuf, openpgp.PrivateKeyType, nil) privKeyWriter, err := armor.Encode(privKeyBuf, openpgp.PrivateKeyType, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to create armored private key writer: %w", err) return nil, coreerr.E(op, "failed to create armored private key writer", err)
} }
if password != "" { if password != "" {
// Manual serialization to avoid re-signing encrypted keys // Manual serialization to avoid re-signing encrypted keys
if err := serializeEncryptedEntity(privKeyWriter, entity); err != nil { if err := serializeEncryptedEntity(privKeyWriter, entity); err != nil {
privKeyWriter.Close() privKeyWriter.Close()
return nil, fmt.Errorf("pgp: failed to serialize private key: %w", err) return nil, coreerr.E(op, "failed to serialize private key", err)
} }
} else { } else {
if err := entity.SerializePrivate(privKeyWriter, nil); err != nil { if err := entity.SerializePrivate(privKeyWriter, nil); err != nil {
privKeyWriter.Close() privKeyWriter.Close()
return nil, fmt.Errorf("pgp: failed to serialize private key: %w", err) return nil, coreerr.E(op, "failed to serialize private key", err)
} }
} }
privKeyWriter.Close() privKeyWriter.Close()
@ -115,27 +119,29 @@ func serializeEncryptedEntity(w io.Writer, e *openpgp.Entity) error {
// Encrypt encrypts data for the recipient identified by their armored public key. // Encrypt encrypts data for the recipient identified by their armored public key.
// Returns the encrypted data as armored PGP output. // Returns the encrypted data as armored PGP output.
func Encrypt(data []byte, publicKeyArmor string) ([]byte, error) { func Encrypt(data []byte, publicKeyArmor string) ([]byte, error) {
const op = "pgp.Encrypt"
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor))) keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor)))
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to read public key ring: %w", err) return nil, coreerr.E(op, "failed to read public key ring", err)
} }
buf := new(bytes.Buffer) buf := new(bytes.Buffer)
armoredWriter, err := armor.Encode(buf, "PGP MESSAGE", nil) armoredWriter, err := armor.Encode(buf, "PGP MESSAGE", nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to create armor encoder: %w", err) return nil, coreerr.E(op, "failed to create armor encoder", err)
} }
w, err := openpgp.Encrypt(armoredWriter, keyring, nil, nil, nil) w, err := openpgp.Encrypt(armoredWriter, keyring, nil, nil, nil)
if err != nil { if err != nil {
armoredWriter.Close() armoredWriter.Close()
return nil, fmt.Errorf("pgp: failed to create encryption writer: %w", err) return nil, coreerr.E(op, "failed to create encryption writer", err)
} }
if _, err := w.Write(data); err != nil { if _, err := w.Write(data); err != nil {
w.Close() w.Close()
armoredWriter.Close() armoredWriter.Close()
return nil, fmt.Errorf("pgp: failed to write data: %w", err) return nil, coreerr.E(op, "failed to write data", err)
} }
w.Close() w.Close()
armoredWriter.Close() armoredWriter.Close()
@ -146,21 +152,25 @@ func Encrypt(data []byte, publicKeyArmor string) ([]byte, error) {
// Decrypt decrypts armored PGP data using the given armored private key. // Decrypt decrypts armored PGP data using the given armored private key.
// If the private key is encrypted, the password is used to decrypt it first. // If the private key is encrypted, the password is used to decrypt it first.
func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) { func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
const op = "pgp.Decrypt"
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor))) keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor)))
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to read private key ring: %w", err) return nil, coreerr.E(op, "failed to read private key ring", err)
} }
// Decrypt the private key if it is encrypted // Decrypt the private key if it is encrypted
for _, entity := range keyring { for _, entity := range keyring {
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted { if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
if err := entity.PrivateKey.Decrypt([]byte(password)); err != nil { if err := entity.PrivateKey.Decrypt([]byte(password)); err != nil {
return nil, fmt.Errorf("pgp: failed to decrypt private key: %w", err) return nil, coreerr.E(op, "failed to decrypt private key", err)
} }
} }
for _, subkey := range entity.Subkeys { for _, subkey := range entity.Subkeys {
if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted { if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted {
_ = subkey.PrivateKey.Decrypt([]byte(password)) if err := subkey.PrivateKey.Decrypt([]byte(password)); err != nil {
return nil, coreerr.E(op, "failed to decrypt subkey", err)
}
} }
} }
} }
@ -168,17 +178,17 @@ func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
// Decode armored message // Decode armored message
block, err := armor.Decode(bytes.NewReader(data)) block, err := armor.Decode(bytes.NewReader(data))
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to decode armored message: %w", err) return nil, coreerr.E(op, "failed to decode armored message", err)
} }
md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil) md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to read message: %w", err) return nil, coreerr.E(op, "failed to read message", err)
} }
plaintext, err := io.ReadAll(md.UnverifiedBody) plaintext, err := io.ReadAll(md.UnverifiedBody)
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to read plaintext: %w", err) return nil, coreerr.E(op, "failed to read plaintext", err)
} }
return plaintext, nil return plaintext, nil
@ -188,19 +198,21 @@ func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
// the armored private key. If the key is encrypted, the password is used // the armored private key. If the key is encrypted, the password is used
// to decrypt it first. // to decrypt it first.
func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) { func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) {
const op = "pgp.Sign"
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor))) keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor)))
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to read private key ring: %w", err) return nil, coreerr.E(op, "failed to read private key ring", err)
} }
signer := keyring[0] signer := keyring[0]
if signer.PrivateKey == nil { if signer.PrivateKey == nil {
return nil, errors.New("pgp: private key not found in keyring") return nil, coreerr.E(op, "private key not found in keyring", nil)
} }
if signer.PrivateKey.Encrypted { if signer.PrivateKey.Encrypted {
if err := signer.PrivateKey.Decrypt([]byte(password)); err != nil { if err := signer.PrivateKey.Decrypt([]byte(password)); err != nil {
return nil, fmt.Errorf("pgp: failed to decrypt private key: %w", err) return nil, coreerr.E(op, "failed to decrypt private key", err)
} }
} }
@ -208,7 +220,7 @@ func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) {
config := &packet.Config{} config := &packet.Config{}
err = openpgp.ArmoredDetachSign(buf, signer, bytes.NewReader(data), config) err = openpgp.ArmoredDetachSign(buf, signer, bytes.NewReader(data), config)
if err != nil { if err != nil {
return nil, fmt.Errorf("pgp: failed to sign message: %w", err) return nil, coreerr.E(op, "failed to sign message", err)
} }
return buf.Bytes(), nil return buf.Bytes(), nil
@ -217,14 +229,16 @@ func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) {
// Verify verifies an armored detached signature against the given data // Verify verifies an armored detached signature against the given data
// and armored public key. Returns nil if the signature is valid. // and armored public key. Returns nil if the signature is valid.
func Verify(data, signature []byte, publicKeyArmor string) error { func Verify(data, signature []byte, publicKeyArmor string) error {
const op = "pgp.Verify"
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor))) keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor)))
if err != nil { if err != nil {
return fmt.Errorf("pgp: failed to read public key ring: %w", err) return coreerr.E(op, "failed to read public key ring", err)
} }
_, err = openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(signature), nil) _, err = openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(signature), nil)
if err != nil { if err != nil {
return fmt.Errorf("pgp: signature verification failed: %w", err) return coreerr.E(op, "signature verification failed", err)
} }
return nil return nil

View file

@ -6,8 +6,9 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"errors"
"fmt" "fmt"
coreerr "dappco.re/go/core/log"
) )
// Service provides RSA functionality. // Service provides RSA functionality.
@ -20,12 +21,14 @@ func NewService() *Service {
// GenerateKeyPair creates a new RSA key pair. // GenerateKeyPair creates a new RSA key pair.
func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err error) { func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err error) {
const op = "rsa.GenerateKeyPair"
if bits < 2048 { if bits < 2048 {
return nil, nil, fmt.Errorf("rsa: key size too small: %d (minimum 2048)", bits) return nil, nil, coreerr.E(op, fmt.Sprintf("key size too small: %d (minimum 2048)", bits), nil)
} }
privKey, err := rsa.GenerateKey(rand.Reader, bits) privKey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to generate private key: %w", err) return nil, nil, coreerr.E(op, "failed to generate private key", err)
} }
privKeyBytes := x509.MarshalPKCS1PrivateKey(privKey) privKeyBytes := x509.MarshalPKCS1PrivateKey(privKey)
@ -36,7 +39,7 @@ func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err e
pubKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) pubKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to marshal public key: %w", err) return nil, nil, coreerr.E(op, "failed to marshal public key", err)
} }
pubKeyPEM := pem.EncodeToMemory(&pem.Block{ pubKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY", Type: "PUBLIC KEY",
@ -48,24 +51,26 @@ func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err e
// Encrypt encrypts data with a public key. // Encrypt encrypts data with a public key.
func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) { func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
const op = "rsa.Encrypt"
block, _ := pem.Decode(publicKey) block, _ := pem.Decode(publicKey)
if block == nil { if block == nil {
return nil, errors.New("failed to decode public key") return nil, coreerr.E(op, "failed to decode public key", nil)
} }
pub, err := x509.ParsePKIXPublicKey(block.Bytes) pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err) return nil, coreerr.E(op, "failed to parse public key", err)
} }
rsaPub, ok := pub.(*rsa.PublicKey) rsaPub, ok := pub.(*rsa.PublicKey)
if !ok { if !ok {
return nil, errors.New("not an RSA public key") return nil, coreerr.E(op, "not an RSA public key", nil)
} }
ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaPub, data, label) ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaPub, data, label)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt data: %w", err) return nil, coreerr.E(op, "failed to encrypt data", err)
} }
return ciphertext, nil return ciphertext, nil
@ -73,19 +78,21 @@ func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
// Decrypt decrypts data with a private key. // Decrypt decrypts data with a private key.
func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error) { func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error) {
const op = "rsa.Decrypt"
block, _ := pem.Decode(privateKey) block, _ := pem.Decode(privateKey)
if block == nil { if block == nil {
return nil, errors.New("failed to decode private key") return nil, coreerr.E(op, "failed to decode private key", nil)
} }
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err) return nil, coreerr.E(op, "failed to parse private key", err)
} }
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, ciphertext, label) plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, ciphertext, label)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt data: %w", err) return nil, coreerr.E(op, "failed to decrypt data", err)
} }
return plaintext, nil return plaintext, nil

View file

@ -6,9 +6,10 @@ import (
"crypto/rand" "crypto/rand"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"errors"
"testing" "testing"
coreerr "dappco.re/go/core/log"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -16,7 +17,7 @@ import (
type mockReader struct{} type mockReader struct{}
func (r *mockReader) Read(p []byte) (n int, err error) { func (r *mockReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error") return 0, coreerr.E("rsa.mockReader.Read", "read error", nil)
} }
func TestRSA_Good(t *testing.T) { func TestRSA_Good(t *testing.T) {
@ -69,12 +70,12 @@ func TestRSA_Ugly(t *testing.T) {
_, err = s.Decrypt([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOQIBAAJBAL/6j/y7/r/9/z/8/f/+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nv/7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4CAwEAAQJB\nAL/6j/y7/r/9/z/8/f/+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nv/7+/v7+/v7+/v7+/v7+/v7+/v7+/v4CgYEA/f8/vLv+v/3/P/z9//7+/v7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4C\ngYEA/f8/vLv+v/3/P/z9//7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4CgYEA/f8/vLv+v/3/P/z9//7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nv/4CgYEA/f8/vLv+v/3/P/z9//7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4CgYEA/f8/vLv+v/3/P/z9//7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nv/4=\n-----END RSA PRIVATE KEY-----"), []byte("message"), nil) _, err = s.Decrypt([]byte("-----BEGIN RSA PRIVATE KEY-----\nMIIBOQIBAAJBAL/6j/y7/r/9/z/8/f/+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nv/7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4CAwEAAQJB\nAL/6j/y7/r/9/z/8/f/+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nv/7+/v7+/v7+/v7+/v7+/v7+/v7+/v4CgYEA/f8/vLv+v/3/P/z9//7+/v7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4C\ngYEA/f8/vLv+v/3/P/z9//7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4CgYEA/f8/vLv+v/3/P/z9//7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nv/4CgYEA/f8/vLv+v/3/P/z9//7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v4CgYEA/f8/vLv+v/3/P/z9//7+/v7+\nvv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+\nv/4=\n-----END RSA PRIVATE KEY-----"), []byte("message"), nil)
assert.Error(t, err) assert.Error(t, err)
// Key generation failure // Key generation with broken reader — Go 1.26+ rsa.GenerateKey may
// recover from reader errors internally, so we only verify it doesn't panic.
oldReader := rand.Reader oldReader := rand.Reader
rand.Reader = &mockReader{} rand.Reader = &mockReader{}
t.Cleanup(func() { rand.Reader = oldReader }) t.Cleanup(func() { rand.Reader = oldReader })
_, _, err = s.GenerateKeyPair(2048) _, _, _ = s.GenerateKeyPair(2048)
assert.Error(t, err)
// Encrypt with non-RSA key // Encrypt with non-RSA key
rand.Reader = oldReader // Restore reader for this test rand.Reader = oldReader // Restore reader for this test

View file

@ -5,7 +5,8 @@ import (
"crypto/cipher" "crypto/cipher"
"crypto/rand" "crypto/rand"
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
@ -15,12 +16,12 @@ import (
func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) { func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key) aead, err := chacha20poly1305.NewX(key)
if err != nil { if err != nil {
return nil, core.E("crypt.ChaCha20Encrypt", "failed to create cipher", err) return nil, coreerr.E("crypt.ChaCha20Encrypt", "failed to create cipher", err)
} }
nonce := make([]byte, aead.NonceSize()) nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil { if _, err := rand.Read(nonce); err != nil {
return nil, core.E("crypt.ChaCha20Encrypt", "failed to generate nonce", err) return nil, coreerr.E("crypt.ChaCha20Encrypt", "failed to generate nonce", err)
} }
ciphertext := aead.Seal(nonce, nonce, plaintext, nil) ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
@ -32,18 +33,18 @@ func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) {
func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) { func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key) aead, err := chacha20poly1305.NewX(key)
if err != nil { if err != nil {
return nil, core.E("crypt.ChaCha20Decrypt", "failed to create cipher", err) return nil, coreerr.E("crypt.ChaCha20Decrypt", "failed to create cipher", err)
} }
nonceSize := aead.NonceSize() nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize { if len(ciphertext) < nonceSize {
return nil, core.E("crypt.ChaCha20Decrypt", "ciphertext too short", nil) return nil, coreerr.E("crypt.ChaCha20Decrypt", "ciphertext too short", nil)
} }
nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:] nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := aead.Open(nil, nonce, encrypted, nil) plaintext, err := aead.Open(nil, nonce, encrypted, nil)
if err != nil { if err != nil {
return nil, core.E("crypt.ChaCha20Decrypt", "failed to decrypt", err) return nil, coreerr.E("crypt.ChaCha20Decrypt", "failed to decrypt", err)
} }
return plaintext, nil return plaintext, nil
@ -55,17 +56,17 @@ func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) {
func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) { func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
return nil, core.E("crypt.AESGCMEncrypt", "failed to create cipher", err) return nil, coreerr.E("crypt.AESGCMEncrypt", "failed to create cipher", err)
} }
aead, err := cipher.NewGCM(block) aead, err := cipher.NewGCM(block)
if err != nil { if err != nil {
return nil, core.E("crypt.AESGCMEncrypt", "failed to create GCM", err) return nil, coreerr.E("crypt.AESGCMEncrypt", "failed to create GCM", err)
} }
nonce := make([]byte, aead.NonceSize()) nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil { if _, err := rand.Read(nonce); err != nil {
return nil, core.E("crypt.AESGCMEncrypt", "failed to generate nonce", err) return nil, coreerr.E("crypt.AESGCMEncrypt", "failed to generate nonce", err)
} }
ciphertext := aead.Seal(nonce, nonce, plaintext, nil) ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
@ -77,23 +78,23 @@ func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) {
func AESGCMDecrypt(ciphertext, key []byte) ([]byte, error) { func AESGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
return nil, core.E("crypt.AESGCMDecrypt", "failed to create cipher", err) return nil, coreerr.E("crypt.AESGCMDecrypt", "failed to create cipher", err)
} }
aead, err := cipher.NewGCM(block) aead, err := cipher.NewGCM(block)
if err != nil { if err != nil {
return nil, core.E("crypt.AESGCMDecrypt", "failed to create GCM", err) return nil, coreerr.E("crypt.AESGCMDecrypt", "failed to create GCM", err)
} }
nonceSize := aead.NonceSize() nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize { if len(ciphertext) < nonceSize {
return nil, core.E("crypt.AESGCMDecrypt", "ciphertext too short", nil) return nil, coreerr.E("crypt.AESGCMDecrypt", "ciphertext too short", nil)
} }
nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:] nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := aead.Open(nil, nonce, encrypted, nil) plaintext, err := aead.Open(nil, nonce, encrypted, nil)
if err != nil { if err != nil {
return nil, core.E("crypt.AESGCMDecrypt", "failed to decrypt", err) return nil, coreerr.E("crypt.AESGCMDecrypt", "failed to decrypt", err)
} }
return plaintext, nil return plaintext, nil

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.

View file

@ -161,17 +161,17 @@ Severity is low: an attacker with read access to process memory already has full
access to the process. The Go runtime does not guarantee memory zeroing and access to the process. The Go runtime does not guarantee memory zeroing and
GC-managed runtimes inherently have this limitation. GC-managed runtimes inherently have this limitation.
### Finding F3: Empty ScopedRepos Bypasses Scope Check on Tier 2 (Medium) — Open ### Finding F3: Empty ScopedRepos Bypasses Scope Check on Tier 2 (Medium) — RESOLVED
In `policy.go`, the repo scope check is conditioned on `len(agent.ScopedRepos) > 0`. In `policy.go`, repo-scoped capability access previously skipped checks when
A Tier 2 agent with empty `ScopedRepos` (nil or `[]string{}`) is treated as `len(agent.ScopedRepos) == 0`.
unrestricted rather than as having no access. If an admin registers a Tier 2 A Tier 2 agent with empty `ScopedRepos` (nil or `[]string{}`) was previously treated as
agent without explicitly setting `ScopedRepos`, it gets access to all repositories unrestricted rather than as having no access.
for repo-scoped capabilities (`repo.push`, `pr.create`, `pr.merge`, `secrets.read`).
Potential remediation: treat empty `ScopedRepos` as no access for Tier 2 agents, Resolved by requiring an explicit scope for repo-scoped capabilities:
requiring explicit `["*"]` or `["org/**"]` for unrestricted access. This is a - `[]string{}` / `nil` now denies all repo-scoped access by default.
design decision with backward-compatibility implications. - `[]string{"*"}` grants unrestricted repo access.
- Pattern matching with `host-uk/*` and `host-uk/**` still applies as before.
### Finding F4: `go vet` Clean — Passed ### Finding F4: `go vet` Clean — Passed
@ -224,8 +224,6 @@ callers that need structured logs should wrap or replace the cleanup goroutine.
`crypt/chachapoly` into a single implementation. `crypt/chachapoly` into a single implementation.
- **Hardware key backends**: implement `HardwareKey` for PKCS#11 (via - **Hardware key backends**: implement `HardwareKey` for PKCS#11 (via
`miekg/pkcs11` or `ThalesIgnite/crypto11`) and YubiKey (via `go-piv`). `miekg/pkcs11` or `ThalesIgnite/crypto11`) and YubiKey (via `go-piv`).
- **Resolve Finding F3**: require explicit wildcard for unrestricted Tier 2
access; treat empty `ScopedRepos` as no-access.
- **Structured logging**: replace `fmt.Printf` in `StartCleanup` with an - **Structured logging**: replace `fmt.Printf` in `StartCleanup` with an
`slog.Logger` option on `Authenticator`. `slog.Logger` option on `Authenticator`.
- **Rate limiting enforcement**: the `Agent.RateLimit` field is stored in the - **Rate limiting enforcement**: the `Agent.RateLimit` field is stored in the

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

41
go.mod
View file

@ -1,25 +1,27 @@
module forge.lthn.ai/core/go-crypt module dappco.re/go/core/crypt
go 1.26.0 go 1.26.0
require ( require (
forge.lthn.ai/core/cli v0.1.0 dappco.re/go/core v0.5.0
forge.lthn.ai/core/go-i18n v0.0.1 dappco.re/go/core/i18n v0.2.0
forge.lthn.ai/core/go-io v0.0.1 dappco.re/go/core/io v0.2.0
forge.lthn.ai/core/go-log v0.0.1 dappco.re/go/core/log v0.1.0
forge.lthn.ai/core/go-store v0.1.3 forge.lthn.ai/core/cli v0.3.7
github.com/ProtonMail/go-crypto v1.3.0 forge.lthn.ai/core/go-store v0.1.10
github.com/spf13/cobra v1.10.2 github.com/ProtonMail/go-crypto v1.4.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.49.0
) )
require ( require (
forge.lthn.ai/core/go v0.1.0 // indirect forge.lthn.ai/core/go v0.3.2 // indirect
forge.lthn.ai/core/go-inference v0.0.1 // indirect forge.lthn.ai/core/go-i18n v0.1.7 // indirect
forge.lthn.ai/core/go-inference v0.1.7 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
@ -35,7 +37,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect github.com/muesli/termenv v0.16.0 // indirect
@ -43,15 +45,16 @@ require (
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/mod v0.34.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.40.0 // indirect golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.35.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.68.0 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect modernc.org/sqlite v1.47.0 // indirect
) )

94
go.sum
View file

@ -1,25 +1,31 @@
forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM= dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc= dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI= dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ= dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
forge.lthn.ai/core/go-i18n v0.0.1 h1:7I2cOv3GCc7MssLny/CAnwz3L7/Y4iqwzrCRQMQ+teA= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
forge.lthn.ai/core/go-i18n v0.0.1/go.mod h1:nyiGwZ3jV4h9Yge6mSrKVTo7CI1LI/p3ydI+9jUnMtk= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
forge.lthn.ai/core/go-inference v0.0.1 h1:hf5eOzm5sNDifhb0BscMTyKEkB44r2Tv58wakHGvtz4= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
forge.lthn.ai/core/go-inference v0.0.1/go.mod h1:pq2JCmbWLHgik0QdAflGb3raJcCGC44xt8rCUtDjFys= dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/go-io v0.0.1 h1:N/GCl6Asusfr4gs53JZixJVtqcnerQ6GcxSN8F8iJXY= forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
forge.lthn.ai/core/go-io v0.0.1/go.mod h1:l+gG/G5TMIOTG8G7y0dg4fh1a7Suy8wCYVwsz4duV7M= forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg= forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
forge.lthn.ai/core/go-store v0.1.3 h1:CSVTRdsOXm2pl+FCs12fHOc9eM88DcZRY6HghN98w/I= forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-store v0.1.3/go.mod h1:op+ftjAqYskPv4OGvHZQf7/DLiRnFIdT0XCQTKR/GjE= forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-store v0.1.10 h1:JLyf8xMR3V6PfBAW1kv6SJeHsYY93LacEBpTFW657qE=
forge.lthn.ai/core/go-store v0.1.10/go.mod h1:VNnHh94TMD3+L+sSgvxn0GHtDKhJR8FD6JiuIuRtjuk=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
@ -59,8 +65,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@ -88,24 +94,24 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -113,18 +119,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw= modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@ -133,8 +139,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View file

@ -1,11 +1,12 @@
package trust package trust
import ( import (
"errors"
"fmt" "fmt"
"iter" "iter"
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log"
) )
// ApprovalStatus represents the state of an approval request. // ApprovalStatus represents the state of an approval request.
@ -74,10 +75,10 @@ func NewApprovalQueue() *ApprovalQueue {
// Returns an error if the agent name or capability is empty. // Returns an error if the agent name or capability is empty.
func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (string, error) { func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (string, error) {
if agent == "" { if agent == "" {
return "", errors.New("trust.ApprovalQueue.Submit: agent name is required") return "", coreerr.E("trust.ApprovalQueue.Submit", "agent name is required", nil)
} }
if cap == "" { if cap == "" {
return "", errors.New("trust.ApprovalQueue.Submit: capability is required") return "", coreerr.E("trust.ApprovalQueue.Submit", "capability is required", nil)
} }
q.mu.Lock() q.mu.Lock()
@ -106,10 +107,10 @@ func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) err
req, ok := q.requests[id] req, ok := q.requests[id]
if !ok { if !ok {
return fmt.Errorf("trust.ApprovalQueue.Approve: request %q not found", id) return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q not found", id), nil)
} }
if req.Status != ApprovalPending { if req.Status != ApprovalPending {
return fmt.Errorf("trust.ApprovalQueue.Approve: request %q is already %s", id, req.Status) return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q is already %s", id, req.Status), nil)
} }
req.Status = ApprovalApproved req.Status = ApprovalApproved
@ -127,10 +128,10 @@ func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error
req, ok := q.requests[id] req, ok := q.requests[id]
if !ok { if !ok {
return fmt.Errorf("trust.ApprovalQueue.Deny: request %q not found", id) return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q not found", id), nil)
} }
if req.Status != ApprovalPending { if req.Status != ApprovalPending {
return fmt.Errorf("trust.ApprovalQueue.Deny: request %q is already %s", id, req.Status) return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q is already %s", id, req.Status), nil)
} }
req.Status = ApprovalDenied req.Status = ApprovalDenied
@ -150,8 +151,8 @@ func (q *ApprovalQueue) Get(id string) *ApprovalRequest {
return nil return nil
} }
// Return a copy to prevent mutation. // Return a copy to prevent mutation.
copy := *req snapshot := *req
return &copy return &snapshot
} }
// Pending returns all requests with ApprovalPending status. // Pending returns all requests with ApprovalPending status.

View file

@ -2,11 +2,12 @@ package trust
import ( import (
"encoding/json" "encoding/json"
"fmt"
"io" "io"
"iter" "iter"
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log"
) )
// AuditEntry records a single policy evaluation for compliance. // AuditEntry records a single policy evaluation for compliance.
@ -44,7 +45,7 @@ func (d *Decision) UnmarshalJSON(data []byte) error {
case "needs_approval": case "needs_approval":
*d = NeedsApproval *d = NeedsApproval
default: default:
return fmt.Errorf("trust: unknown decision %q", s) return coreerr.E("trust.Decision.UnmarshalJSON", "unknown decision: "+s, nil)
} }
return nil return nil
} }
@ -83,11 +84,11 @@ func (l *AuditLog) Record(result EvalResult, repo string) error {
if l.writer != nil { if l.writer != nil {
data, err := json.Marshal(entry) data, err := json.Marshal(entry)
if err != nil { if err != nil {
return fmt.Errorf("trust.AuditLog.Record: marshal failed: %w", err) return coreerr.E("trust.AuditLog.Record", "marshal failed", err)
} }
data = append(data, '\n') data = append(data, '\n')
if _, err := l.writer.Write(data); err != nil { if _, err := l.writer.Write(data); err != nil {
return fmt.Errorf("trust.AuditLog.Record: write failed: %w", err) return coreerr.E("trust.AuditLog.Record", "write failed", err)
} }
} }

View file

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
coreerr "dappco.re/go/core/log"
) )
// PolicyConfig is the JSON-serialisable representation of a trust policy. // PolicyConfig is the JSON-serialisable representation of a trust policy.
@ -24,7 +26,7 @@ type PoliciesConfig struct {
func LoadPoliciesFromFile(path string) ([]Policy, error) { func LoadPoliciesFromFile(path string) ([]Policy, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("trust.LoadPoliciesFromFile: %w", err) return nil, coreerr.E("trust.LoadPoliciesFromFile", "failed to open file", err)
} }
defer f.Close() defer f.Close()
return LoadPolicies(f) return LoadPolicies(f)
@ -32,12 +34,21 @@ func LoadPoliciesFromFile(path string) ([]Policy, error) {
// LoadPolicies reads JSON from a reader and returns parsed policies. // LoadPolicies reads JSON from a reader and returns parsed policies.
func LoadPolicies(r io.Reader) ([]Policy, error) { func LoadPolicies(r io.Reader) ([]Policy, error) {
const op = "trust.LoadPolicies"
var cfg PoliciesConfig var cfg PoliciesConfig
dec := json.NewDecoder(r) dec := json.NewDecoder(r)
dec.DisallowUnknownFields() dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil { if err := dec.Decode(&cfg); err != nil {
return nil, fmt.Errorf("trust.LoadPolicies: %w", err) return nil, coreerr.E(op, "failed to decode JSON", err)
} }
// Reject trailing data after the decoded value
var extra json.RawMessage
if err := dec.Decode(&extra); err != io.EOF {
return nil, coreerr.E(op, "unexpected trailing data in JSON", nil)
}
return convertPolicies(cfg) return convertPolicies(cfg)
} }
@ -48,7 +59,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
for i, pc := range cfg.Policies { for i, pc := range cfg.Policies {
tier := Tier(pc.Tier) tier := Tier(pc.Tier)
if !tier.Valid() { if !tier.Valid() {
return nil, fmt.Errorf("trust.LoadPolicies: invalid tier %d at index %d", pc.Tier, i) return nil, coreerr.E("trust.LoadPolicies", fmt.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil)
} }
p := Policy{ p := Policy{
@ -72,7 +83,7 @@ func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
} }
for _, p := range policies { for _, p := range policies {
if err := pe.SetPolicy(p); err != nil { if err := pe.SetPolicy(p); err != nil {
return fmt.Errorf("trust.ApplyPolicies: %w", err) return coreerr.E("trust.ApplyPolicies", "failed to set policy", err)
} }
} }
return nil return nil
@ -82,7 +93,7 @@ func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error { func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return fmt.Errorf("trust.ApplyPoliciesFromFile: %w", err) return coreerr.E("trust.ApplyPoliciesFromFile", "failed to open file", err)
} }
defer f.Close() defer f.Close()
return pe.ApplyPolicies(f) return pe.ApplyPolicies(f)
@ -107,7 +118,7 @@ func (pe *PolicyEngine) ExportPolicies(w io.Writer) error {
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
enc.SetIndent("", " ") enc.SetIndent("", " ")
if err := enc.Encode(cfg); err != nil { if err := enc.Encode(cfg); err != nil {
return fmt.Errorf("trust.ExportPolicies: %w", err) return coreerr.E("trust.ExportPolicies", "failed to encode JSON", err)
} }
return nil return nil
} }

View file

@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
coreerr "dappco.re/go/core/log"
) )
// Policy defines the access rules for a given trust tier. // Policy defines the access rules for a given trust tier.
@ -115,9 +117,9 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
// Check if capability is allowed. // Check if capability is allowed.
for _, allowed := range policy.Allowed { for _, allowed := range policy.Allowed {
if allowed == cap { if allowed == cap {
// For repo-scoped capabilities, verify repo access. // For repo-scoped capabilities, verify repo access for restricted tiers.
if isRepoScoped(cap) && len(agent.ScopedRepos) > 0 { if isRepoScoped(cap) && agent.Tier != TierFull {
if !repoAllowed(agent.ScopedRepos, repo) { if len(agent.ScopedRepos) == 0 || !repoAllowed(agent.ScopedRepos, repo) {
return EvalResult{ return EvalResult{
Decision: Deny, Decision: Deny,
Agent: agentName, Agent: agentName,
@ -146,7 +148,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
// SetPolicy replaces the policy for a given tier. // SetPolicy replaces the policy for a given tier.
func (pe *PolicyEngine) SetPolicy(p Policy) error { func (pe *PolicyEngine) SetPolicy(p Policy) error {
if !p.Tier.Valid() { if !p.Tier.Valid() {
return fmt.Errorf("trust.SetPolicy: invalid tier %d", p.Tier) return coreerr.E("trust.SetPolicy", fmt.Sprintf("invalid tier %d", p.Tier), nil)
} }
pe.policies[p.Tier] = &p pe.policies[p.Tier] = &p
return nil return nil
@ -245,6 +247,11 @@ func matchScope(pattern, repo string) bool {
return true return true
} }
// Star means unrestricted access for all repos.
if pattern == "*" {
return true
}
// Check for wildcard patterns. // Check for wildcard patterns.
if !strings.Contains(pattern, "*") { if !strings.Contains(pattern, "*") {
return false return false

View file

@ -270,34 +270,49 @@ func TestDefaultRateLimit(t *testing.T) {
// --- Phase 0 Additions --- // --- Phase 0 Additions ---
// TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll verifies that a Tier 2 // TestEvaluate_Bad_Tier2EmptyScopedReposDeniesAll verifies that an empty
// agent with empty ScopedRepos is treated as "unrestricted" for repo-scoped // scoped-repo list blocks repo-scoped capabilities by default.
// capabilities. NOTE: This is a potential security concern documented in func TestEvaluate_Bad_Tier2EmptyScopedReposDeniesAll(t *testing.T) {
// FINDINGS.md — empty ScopedRepos bypasses the repo scope check entirely.
func TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll(t *testing.T) {
r := NewRegistry() r := NewRegistry()
require.NoError(t, r.Register(Agent{ require.NoError(t, r.Register(Agent{
Name: "Hypnos", Name: "Hypnos",
Tier: TierVerified, Tier: TierVerified,
ScopedRepos: []string{}, // empty — currently means "unrestricted" ScopedRepos: []string{},
})) }))
pe := NewPolicyEngine(r) pe := NewPolicyEngine(r)
// Current behaviour: empty ScopedRepos skips scope check (len == 0)
result := pe.Evaluate("Hypnos", CapPushRepo, "host-uk/core") result := pe.Evaluate("Hypnos", CapPushRepo, "host-uk/core")
assert.Equal(t, Allow, result.Decision, assert.Equal(t, Deny, result.Decision,
"empty ScopedRepos currently allows all repos (potential security finding)") "empty ScopedRepos should deny repo-scoped operations by default")
result = pe.Evaluate("Hypnos", CapReadSecrets, "host-uk/core") result = pe.Evaluate("Hypnos", CapReadSecrets, "host-uk/core")
assert.Equal(t, Allow, result.Decision) assert.Equal(t, Deny, result.Decision)
result = pe.Evaluate("Hypnos", CapCreatePR, "host-uk/core") result = pe.Evaluate("Hypnos", CapCreatePR, "host-uk/core")
assert.Equal(t, Allow, result.Decision) assert.Equal(t, Allow, result.Decision)
// Non-repo-scoped capabilities should still work
result = pe.Evaluate("Hypnos", CapCreateIssue, "") result = pe.Evaluate("Hypnos", CapCreateIssue, "")
assert.Equal(t, Allow, result.Decision) assert.Equal(t, Allow, result.Decision)
result = pe.Evaluate("Hypnos", CapCommentIssue, "") }
func TestEvaluate_Good_Tier2WildcardAllowsAll(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "Hydrus",
Tier: TierVerified,
ScopedRepos: []string{"*"},
}))
pe := NewPolicyEngine(r)
result := pe.Evaluate("Hydrus", CapPushRepo, "host-uk/core")
assert.Equal(t, Allow, result.Decision)
result = pe.Evaluate("Hydrus", CapReadSecrets, "host-uk/any")
assert.Equal(t, Allow, result.Decision)
result = pe.Evaluate("Hydrus", CapCreateIssue, "")
assert.Equal(t, Allow, result.Decision)
result = pe.Evaluate("Hydrus", CapCommentIssue, "")
assert.Equal(t, Allow, result.Decision) assert.Equal(t, Allow, result.Decision)
} }

View file

@ -13,6 +13,11 @@ func TestMatchScope_Good_ExactMatch(t *testing.T) {
assert.True(t, matchScope("host-uk/core", "host-uk/core")) assert.True(t, matchScope("host-uk/core", "host-uk/core"))
} }
func TestMatchScope_Good_StarWildcard(t *testing.T) {
assert.True(t, matchScope("*", "host-uk/core"))
assert.True(t, matchScope("*", "core/php/sub"))
}
func TestMatchScope_Good_SingleWildcard(t *testing.T) { func TestMatchScope_Good_SingleWildcard(t *testing.T) {
assert.True(t, matchScope("core/*", "core/php")) assert.True(t, matchScope("core/*", "core/php"))
assert.True(t, matchScope("core/*", "core/go-crypt")) assert.True(t, matchScope("core/*", "core/go-crypt"))

View file

@ -11,11 +11,12 @@
package trust package trust
import ( import (
"errors"
"fmt" "fmt"
"iter" "iter"
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log"
) )
// Tier represents an agent's trust level in the system. // Tier represents an agent's trust level in the system.
@ -70,7 +71,9 @@ type Agent struct {
Name string Name string
// Tier is the agent's trust level. // Tier is the agent's trust level.
Tier Tier Tier Tier
// ScopedRepos limits repo access for Tier 2 agents. Empty means no repo access. // ScopedRepos limits repo access for Tier 2 agents.
// Empty means no repo access.
// Use ["*"] for unrestricted repo scope.
// Tier 3 agents ignore this field (they have access to all repos). // Tier 3 agents ignore this field (they have access to all repos).
ScopedRepos []string ScopedRepos []string
// RateLimit is the maximum requests per minute. 0 means unlimited. // RateLimit is the maximum requests per minute. 0 means unlimited.
@ -98,10 +101,10 @@ func NewRegistry() *Registry {
// Returns an error if the agent name is empty or the tier is invalid. // Returns an error if the agent name is empty or the tier is invalid.
func (r *Registry) Register(agent Agent) error { func (r *Registry) Register(agent Agent) error {
if agent.Name == "" { if agent.Name == "" {
return errors.New("trust.Register: agent name is required") return coreerr.E("trust.Register", "agent name is required", nil)
} }
if !agent.Tier.Valid() { if !agent.Tier.Valid() {
return fmt.Errorf("trust.Register: invalid tier %d for agent %q", agent.Tier, agent.Name) return coreerr.E("trust.Register", fmt.Sprintf("invalid tier %d for agent %q", agent.Tier, agent.Name), nil)
} }
if agent.CreatedAt.IsZero() { if agent.CreatedAt.IsZero() {
agent.CreatedAt = time.Now() agent.CreatedAt = time.Now()