[agent/codex] A specs/ folder has been injected into this workspace with R... #13

Open
Virgil wants to merge 9 commits from agent/ax-review--banned-imports--test-naming into dev
73 changed files with 4561 additions and 712 deletions

View file

@ -48,7 +48,7 @@ workspace file instead.
| `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) |
| `dappco.re/go/core/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.

149
UPGRADE.md Normal file
View file

@ -0,0 +1,149 @@
# Upgrade Plan: v0.8.0 AX Compliance
Scope: planning only. This document identifies the repo changes required for AX v0.8.0 compliance without changing package code yet.
Breaking-change risk: medium. This module is consumed by `core`, `go-blockchain`, and `LEM`, so any public API edits made during compliance work should be reviewed as a consumer-facing change even if the primary AX items are mechanical.
## Current Audit Summary
- Banned imports: 52 hits across production and test code.
- Test naming violations: 292 `Test...` functions do not follow `TestFile_Function_{Good,Bad,Ugly}`.
- Exported API usage-example gaps: 162 exported declarations in non-test files do not include a usage/example-oriented doc comment.
## Execution Order
1. Fix production banned imports first to reduce AX failures without changing public behaviour.
2. Rename tests package-by-package, starting with the largest files, and rerun targeted `go test ./...`.
3. Add usage/example comments to exported API surfaces, starting with the packages most likely to be imported by consumers: `auth`, `crypt`, `trust`.
4. Run the full validation flow: `go build ./...`, `go vet ./...`, `go test ./... -count=1 -timeout 120s`, `go test -cover ./...`, `go mod tidy`.
5. Review downstream consumers for naming-only vs behavioural risk before releasing v0.8.0.
## 1. Remove Banned Imports
Production code should be prioritised before test helpers.
### Production files
- `auth/auth.go`: replace banned imports at `auth/auth.go:32`, `auth/auth.go:33`, `auth/auth.go:34`.
- `auth/session_store_sqlite.go`: replace banned imports at `auth/session_store_sqlite.go:4`, `auth/session_store_sqlite.go:5`.
- `cmd/crypt/cmd_checksum.go`: replace banned imports at `cmd/crypt/cmd_checksum.go:4`, `cmd/crypt/cmd_checksum.go:5`.
- `cmd/crypt/cmd_encrypt.go`: replace banned imports at `cmd/crypt/cmd_encrypt.go:4`, `cmd/crypt/cmd_encrypt.go:5`.
- `cmd/crypt/cmd_hash.go`: replace banned import at `cmd/crypt/cmd_hash.go:4`.
- `cmd/crypt/cmd_keygen.go`: replace banned import at `cmd/crypt/cmd_keygen.go:7`.
- `cmd/testcmd/cmd_output.go`: replace banned imports at `cmd/testcmd/cmd_output.go:6`, `cmd/testcmd/cmd_output.go:7`, `cmd/testcmd/cmd_output.go:11`.
- `cmd/testcmd/cmd_runner.go`: replace banned imports at `cmd/testcmd/cmd_runner.go:5`, `cmd/testcmd/cmd_runner.go:7`, `cmd/testcmd/cmd_runner.go:8`, `cmd/testcmd/cmd_runner.go:10`.
- `crypt/chachapoly/chachapoly.go`: replace banned import at `crypt/chachapoly/chachapoly.go:5`.
- `crypt/checksum.go`: replace banned import at `crypt/checksum.go:8`.
- `crypt/hash.go`: replace banned imports at `crypt/hash.go:6`, `crypt/hash.go:7`.
- `crypt/openpgp/service.go`: replace banned import at `crypt/openpgp/service.go:7`.
- `crypt/rsa/rsa.go`: replace banned import at `crypt/rsa/rsa.go:9`.
- `trust/approval.go`: replace banned import at `trust/approval.go:4`.
- `trust/audit.go`: replace banned import at `trust/audit.go:4`.
- `trust/config.go`: replace banned imports at `trust/config.go:4`, `trust/config.go:5`, `trust/config.go:7`.
- `trust/policy.go`: replace banned imports at `trust/policy.go:4`, `trust/policy.go:6`.
- `trust/trust.go`: replace banned import at `trust/trust.go:14`.
### Test files
- `auth/auth_test.go`: banned imports at `auth/auth_test.go:4`, `auth/auth_test.go:5`, `auth/auth_test.go:6`.
- `auth/session_store_test.go`: banned imports at `auth/session_store_test.go:5`, `auth/session_store_test.go:6`, `auth/session_store_test.go:7`.
- `crypt/chachapoly/chachapoly_test.go`: banned import at `crypt/chachapoly/chachapoly_test.go:5`.
- `crypt/checksum_test.go`: banned imports at `crypt/checksum_test.go:4`, `crypt/checksum_test.go:5`.
- `crypt/rsa/rsa_test.go`: banned import at `crypt/rsa/rsa_test.go:9`.
- `trust/approval_test.go`: banned import at `trust/approval_test.go:4`.
- `trust/audit_test.go`: banned imports at `trust/audit_test.go:5`, `trust/audit_test.go:6`, `trust/audit_test.go:8`.
- `trust/bench_test.go`: banned import at `trust/bench_test.go:4`.
- `trust/config_test.go`: banned imports at `trust/config_test.go:5`, `trust/config_test.go:6`, `trust/config_test.go:7`, `trust/config_test.go:8`.
- `trust/trust_test.go`: banned import at `trust/trust_test.go:4`.
Plan notes:
- Prefer package-local replacements already used elsewhere in the repo rather than introducing new compatibility shims.
- `cmd/testcmd/cmd_runner.go:8` is the only `os/exec` hit and may need the largest redesign if AX forbids subprocess execution outright.
- `encoding/json` use is concentrated in `auth` and `trust`; validate AX-approved serialisation alternatives before changing wire formats consumed by downstream modules.
## 2. Rename Tests To `TestFile_Function_{Good,Bad,Ugly}`
Every audited `Test...` function currently fails the file-prefix requirement. The work is mechanical but broad, so do it file-by-file to keep diffs reviewable.
### Highest-volume files first
- `auth/auth_test.go`: 55 renames, starting at `auth/auth_test.go:28`.
- `trust/policy_test.go`: 38 renames, starting at `trust/policy_test.go:32`.
- `trust/trust_test.go`: 26 renames, starting at `trust/trust_test.go:15`.
- `trust/approval_test.go`: 23 renames, starting at `trust/approval_test.go:14`.
- `trust/scope_test.go`: 23 renames, starting at `trust/scope_test.go:12`.
- `trust/config_test.go`: 20 renames, starting at `trust/config_test.go:37`.
- `auth/session_store_test.go`: 19 renames, starting at `auth/session_store_test.go:21`.
- `trust/audit_test.go`: 17 renames, starting at `trust/audit_test.go:18`.
### Remaining files
- `crypt/pgp/pgp_test.go`: 12 renames, starting at `crypt/pgp/pgp_test.go:10`.
- `crypt/chachapoly/chachapoly_test.go`: 9 renames, starting at `crypt/chachapoly/chachapoly_test.go:18`.
- `crypt/symmetric_test.go`: 8 renames, starting at `crypt/symmetric_test.go:10`.
- `crypt/checksum_test.go`: 7 renames, starting at `crypt/checksum_test.go:12`.
- `crypt/crypt_test.go`: 7 renames, starting at `crypt/crypt_test.go:11`.
- `crypt/lthn/lthn_test.go`: 7 renames, starting at `crypt/lthn/lthn_test.go:10`.
- `crypt/kdf_test.go`: 6 renames, starting at `crypt/kdf_test.go:9`.
- `cmd/testcmd/output_test.go`: 4 renames, starting at `cmd/testcmd/output_test.go:9`.
- `crypt/hash_test.go`: 3 renames, starting at `crypt/hash_test.go:10`.
- `crypt/hmac_test.go`: 3 renames, starting at `crypt/hmac_test.go:11`.
- `crypt/rsa/rsa_test.go`: 3 renames, starting at `crypt/rsa/rsa_test.go:22`.
- `crypt/openpgp/service_test.go`: 2 renames, starting at `crypt/openpgp/service_test.go:12`.
Plan notes:
- Apply the file stem as the prefix. Examples: `approval_test.go` -> `TestApproval_...`, `policy_test.go` -> `TestPolicy_...`, `session_store_test.go` -> `TestSessionStore_...`.
- Preserve existing subcase suffixes after `Good`, `Bad`, or `Ugly` where they add meaning.
- Renames should not change behaviour, but they can break editor tooling, test filters, and CI allowlists that depend on old names.
## 3. Add Usage-Example Comments To Exported API
The package has normal doc comments in many places, but the audit found no usage/example-oriented export comments matching AX expectations. This affects all major public surfaces.
### `auth` package
- `auth/auth.go`: 26 exported declarations at `auth/auth.go:47`, `auth/auth.go:48`, `auth/auth.go:60`, `auth/auth.go:70`, `auth/auth.go:77`, `auth/auth.go:85`, `auth/auth.go:92`, `auth/auth.go:95`, `auth/auth.go:102`, `auth/auth.go:110`, `auth/auth.go:125`, `auth/auth.go:138`, `auth/auth.go:164`, `auth/auth.go:241`, `auth/auth.go:283`, `auth/auth.go:317`, `auth/auth.go:334`, `auth/auth.go:355`, `auth/auth.go:367`, `auth/auth.go:407`, `auth/auth.go:459`, `auth/auth.go:540`, `auth/auth.go:576`, `auth/auth.go:600`, `auth/auth.go:623`, `auth/auth.go:696`.
- `auth/hardware.go`: 2 exported declarations at `auth/hardware.go:20`, `auth/hardware.go:47`.
- `auth/session_store.go`: 9 exported declarations at `auth/session_store.go:12`, `auth/session_store.go:15`, `auth/session_store.go:24`, `auth/session_store.go:30`, `auth/session_store.go:37`, `auth/session_store.go:52`, `auth/session_store.go:63`, `auth/session_store.go:76`, `auth/session_store.go:87`.
- `auth/session_store_sqlite.go`: 8 exported declarations at `auth/session_store_sqlite.go:16`, `auth/session_store_sqlite.go:23`, `auth/session_store_sqlite.go:32`, `auth/session_store_sqlite.go:52`, `auth/session_store_sqlite.go:64`, `auth/session_store_sqlite.go:80`, `auth/session_store_sqlite.go:104`, `auth/session_store_sqlite.go:131`.
### `crypt` package family
- `crypt/chachapoly/chachapoly.go`: 2 exported declarations at `crypt/chachapoly/chachapoly.go:14`, `crypt/chachapoly/chachapoly.go:29`.
- `crypt/checksum.go`: 4 exported declarations at `crypt/checksum.go:14`, `crypt/checksum.go:30`, `crypt/checksum.go:46`, `crypt/checksum.go:52`.
- `crypt/crypt.go`: 4 exported declarations at `crypt/crypt.go:10`, `crypt/crypt.go:32`, `crypt/crypt.go:53`, `crypt/crypt.go:74`.
- `crypt/hash.go`: 4 exported declarations at `crypt/hash.go:17`, `crypt/hash.go:37`, `crypt/hash.go:72`, `crypt/hash.go:81`.
- `crypt/hmac.go`: 3 exported declarations at `crypt/hmac.go:11`, `crypt/hmac.go:18`, `crypt/hmac.go:26`.
- `crypt/kdf.go`: 3 exported declarations at `crypt/kdf.go:28`, `crypt/kdf.go:34`, `crypt/kdf.go:45`.
- `crypt/lthn/lthn.go`: 4 exported declarations at `crypt/lthn/lthn.go:45`, `crypt/lthn/lthn.go:50`, `crypt/lthn/lthn.go:64`, `crypt/lthn/lthn.go:92`.
- `crypt/openpgp/service.go`: 6 exported declarations at `crypt/openpgp/service.go:18`, `crypt/openpgp/service.go:23`, `crypt/openpgp/service.go:29`, `crypt/openpgp/service.go:104`, `crypt/openpgp/service.go:139`, `crypt/openpgp/service.go:177`.
- `crypt/pgp/pgp.go`: 6 exported declarations at `crypt/pgp/pgp.go:19`, `crypt/pgp/pgp.go:27`, `crypt/pgp/pgp.go:119`, `crypt/pgp/pgp.go:152`, `crypt/pgp/pgp.go:196`, `crypt/pgp/pgp.go:227`.
- `crypt/rsa/rsa.go`: 5 exported declarations at `crypt/rsa/rsa.go:15`, `crypt/rsa/rsa.go:18`, `crypt/rsa/rsa.go:23`, `crypt/rsa/rsa.go:53`, `crypt/rsa/rsa.go:80`.
- `crypt/symmetric.go`: 4 exported declarations at `crypt/symmetric.go:16`, `crypt/symmetric.go:33`, `crypt/symmetric.go:56`, `crypt/symmetric.go:78`.
### `trust` package
- `trust/approval.go`: 15 exported declarations at `trust/approval.go:13`, `trust/approval.go:17`, `trust/approval.go:19`, `trust/approval.go:21`, `trust/approval.go:25`, `trust/approval.go:39`, `trust/approval.go:61`, `trust/approval.go:68`, `trust/approval.go:76`, `trust/approval.go:104`, `trust/approval.go:125`, `trust/approval.go:145`, `trust/approval.go:159`, `trust/approval.go:173`, `trust/approval.go:189`.
- `trust/audit.go`: 11 exported declarations at `trust/audit.go:14`, `trust/audit.go:30`, `trust/audit.go:35`, `trust/audit.go:54`, `trust/audit.go:62`, `trust/audit.go:69`, `trust/audit.go:99`, `trust/audit.go:109`, `trust/audit.go:123`, `trust/audit.go:130`, `trust/audit.go:144`.
- `trust/config.go`: 7 exported declarations at `trust/config.go:13`, `trust/config.go:21`, `trust/config.go:26`, `trust/config.go:36`, `trust/config.go:70`, `trust/config.go:84`, `trust/config.go:94`.
- `trust/policy.go`: 12 exported declarations at `trust/policy.go:12`, `trust/policy.go:24`, `trust/policy.go:30`, `trust/policy.go:34`, `trust/policy.go:36`, `trust/policy.go:38`, `trust/policy.go:42`, `trust/policy.go:56`, `trust/policy.go:64`, `trust/policy.go:76`, `trust/policy.go:149`, `trust/policy.go:158`.
- `trust/trust.go`: 25 exported declarations at `trust/trust.go:23`, `trust/trust.go:27`, `trust/trust.go:29`, `trust/trust.go:31`, `trust/trust.go:35`, `trust/trust.go:49`, `trust/trust.go:54`, `trust/trust.go:57`, `trust/trust.go:58`, `trust/trust.go:59`, `trust/trust.go:60`, `trust/trust.go:61`, `trust/trust.go:62`, `trust/trust.go:63`, `trust/trust.go:64`, `trust/trust.go:65`, `trust/trust.go:69`, `trust/trust.go:86`, `trust/trust.go:92`, `trust/trust.go:100`, `trust/trust.go:121`, `trust/trust.go:128`, `trust/trust.go:139`, `trust/trust.go:150`, `trust/trust.go:163`.
### Command entrypoints
- `cmd/crypt/cmd.go`: exported declaration at `cmd/crypt/cmd.go:10`.
- `cmd/testcmd/cmd_main.go`: exported declaration at `cmd/testcmd/cmd_main.go:59`.
Plan notes:
- Keep comments example-oriented and consumer-safe. Public comments should explain the happy-path call pattern without embedding secrets or unstable implementation details.
- Prioritise constructor and entrypoint comments first: `auth.New`, `trust.NewRegistry`, `trust.NewPolicyEngine`, `trust.NewApprovalQueue`, `trust.NewAuditLog`, `crypt/openpgp.New`, `crypt/rsa.NewService`, and the command registration functions.
- If AX requires literal `Usage:` blocks, standardise that wording repo-wide before editing all 162 declarations.
## 4. Validation And Release Checks
- Run the required flow after each migration stage, not only at the end, so failures stay attributable to one category of change.
- Add a final review for consumer impact before tagging `v0.8.0`, especially if banned-import replacements force API or behaviour adjustments in `auth`, `trust/config`, or CLI codepaths.
- Commit the compliance work in small conventional-commit slices where practical. If the migration is done in one batch, use a non-breaking conventional commit scope that matches the touched package set.

View file

@ -29,12 +29,10 @@ import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
core "dappco.re/go/core"
"dappco.re/go/core/crypt/crypt"
"dappco.re/go/core/crypt/crypt/lthn"
"dappco.re/go/core/crypt/crypt/pgp"
@ -42,11 +40,14 @@ import (
coreerr "dappco.re/go/core/log"
)
// Default durations for challenge and session lifetimes.
const (
// DefaultChallengeTTL is the default lifetime for a generated challenge.
// Usage: pass DefaultChallengeTTL into WithChallengeTTL(...) to keep the package default.
DefaultChallengeTTL = 5 * time.Minute
DefaultSessionTTL = 24 * time.Hour
nonceBytes = 32
// DefaultSessionTTL is the default lifetime for an authenticated session.
// Usage: pass DefaultSessionTTL into WithSessionTTL(...) to keep the package default.
DefaultSessionTTL = 24 * time.Hour
nonceBytes = 32
)
// protectedUsers lists usernames that cannot be deleted.
@ -57,6 +58,7 @@ var protectedUsers = map[string]bool{
}
// User represents a registered user with PGP credentials.
// Usage: use User with the other exported helpers in this package.
type User struct {
PublicKey string `json:"public_key"`
KeyID string `json:"key_id"`
@ -67,6 +69,7 @@ type User struct {
}
// Challenge is a PGP-encrypted nonce sent to a client during authentication.
// Usage: use Challenge with the other exported helpers in this package.
type Challenge struct {
Nonce []byte `json:"nonce"`
Encrypted string `json:"encrypted"` // PGP-encrypted nonce (armored)
@ -74,6 +77,7 @@ type Challenge struct {
}
// Session represents an authenticated session.
// Usage: use Session with the other exported helpers in this package.
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
@ -82,6 +86,7 @@ type Session struct {
// Revocation records the details of a revoked user key.
// Stored as JSON in the user's .rev file, replacing the legacy placeholder.
// Usage: use Revocation with the other exported helpers in this package.
type Revocation struct {
UserID string `json:"user_id"`
Reason string `json:"reason"`
@ -89,9 +94,11 @@ type Revocation struct {
}
// Option configures an Authenticator.
// Usage: use Option with the other exported helpers in this package.
type Option func(*Authenticator)
// WithChallengeTTL sets the lifetime of a challenge before it expires.
// Usage: pass WithChallengeTTL(...) into the related constructor to adjust the default behaviour.
func WithChallengeTTL(d time.Duration) Option {
return func(a *Authenticator) {
a.challengeTTL = d
@ -99,6 +106,7 @@ func WithChallengeTTL(d time.Duration) Option {
}
// WithSessionTTL sets the lifetime of a session before it expires.
// Usage: pass WithSessionTTL(...) into the related constructor to adjust the default behaviour.
func WithSessionTTL(d time.Duration) Option {
return func(a *Authenticator) {
a.sessionTTL = d
@ -107,6 +115,7 @@ func WithSessionTTL(d time.Duration) Option {
// WithSessionStore sets the SessionStore implementation.
// If not provided, an in-memory store is used (sessions lost on restart).
// Usage: pass WithSessionStore(...) into the related constructor to adjust the default behaviour.
func WithSessionStore(s SessionStore) Option {
return func(a *Authenticator) {
a.store = s
@ -122,6 +131,7 @@ func WithSessionStore(s SessionStore) Option {
// An optional HardwareKey can be provided via WithHardwareKey for
// hardware-backed cryptographic operations (PKCS#11, YubiKey, etc.).
// See auth/hardware.go for the interface definition and integration points.
// Usage: create an Authenticator with New(...) and then call Register, Login, or CreateChallenge.
type Authenticator struct {
medium io.Medium
store SessionStore
@ -135,6 +145,7 @@ type Authenticator struct {
// New creates an Authenticator that persists user data via the given Medium.
// By default, sessions are stored in memory. Use WithSessionStore to provide
// a persistent implementation (e.g. SQLiteSessionStore).
// Usage: call New(...) to create a ready-to-use value.
func New(m io.Medium, opts ...Option) *Authenticator {
a := &Authenticator{
medium: m,
@ -161,6 +172,7 @@ func userPath(userID, ext string) string {
// produce a userID, generates a PGP keypair (protected by the given password),
// and persists the public key, private key, revocation placeholder, password
// hash (Argon2id), and encrypted metadata via the Medium.
// Usage: call Register(...) during the package's normal workflow.
func (a *Authenticator) Register(username, password string) (*User, error) {
const op = "auth.Register"
@ -218,12 +230,13 @@ func (a *Authenticator) Register(username, password string) (*User, error) {
}
// Encrypt metadata with the user's public key and store
metaJSON, err := json.Marshal(user)
if err != nil {
metaJSONResult := core.JSONMarshal(user)
if !metaJSONResult.OK {
err, _ := metaJSONResult.Value.(error)
return nil, coreerr.E(op, "failed to marshal user metadata", err)
}
encMeta, err := pgp.Encrypt(metaJSON, kp.PublicKey)
encMeta, err := pgp.Encrypt(metaJSONResult.Value.([]byte), kp.PublicKey)
if err != nil {
return nil, coreerr.E(op, "failed to encrypt user metadata", err)
}
@ -238,6 +251,7 @@ func (a *Authenticator) Register(username, password string) (*User, error) {
// CreateChallenge generates a cryptographic challenge for the given user.
// A random nonce is created and encrypted with the user's PGP public key.
// The client must decrypt the nonce and sign it to prove key ownership.
// Usage: call CreateChallenge(...) during the package's normal workflow.
func (a *Authenticator) CreateChallenge(userID string) (*Challenge, error) {
const op = "auth.CreateChallenge"
@ -280,6 +294,7 @@ func (a *Authenticator) CreateChallenge(userID string) (*Challenge, error) {
// ValidateResponse verifies a signed nonce from the client. The client must
// have decrypted the challenge nonce and signed it with their private key.
// On success, a new session is created and returned.
// Usage: call ValidateResponse(...) during the package's normal workflow.
func (a *Authenticator) ValidateResponse(userID string, signedNonce []byte) (*Session, error) {
const op = "auth.ValidateResponse"
@ -314,6 +329,7 @@ func (a *Authenticator) ValidateResponse(userID string, signedNonce []byte) (*Se
}
// ValidateSession checks whether a token maps to a valid, non-expired session.
// Usage: call ValidateSession(...) during the package's normal workflow.
func (a *Authenticator) ValidateSession(token string) (*Session, error) {
const op = "auth.ValidateSession"
@ -331,6 +347,7 @@ func (a *Authenticator) ValidateSession(token string) (*Session, error) {
}
// RefreshSession extends the expiry of an existing valid session.
// Usage: call RefreshSession(...) during the package's normal workflow.
func (a *Authenticator) RefreshSession(token string) (*Session, error) {
const op = "auth.RefreshSession"
@ -352,6 +369,7 @@ func (a *Authenticator) RefreshSession(token string) (*Session, error) {
}
// RevokeSession removes a session, invalidating the token immediately.
// Usage: call RevokeSession(...) during the package's normal workflow.
func (a *Authenticator) RevokeSession(token string) error {
const op = "auth.RevokeSession"
@ -364,6 +382,7 @@ func (a *Authenticator) RevokeSession(token string) error {
// DeleteUser removes a user and all associated keys from storage.
// The "server" user is protected and cannot be deleted (mirroring the
// original TypeScript implementation's safeguard).
// Usage: call DeleteUser(...) during the package's normal workflow.
func (a *Authenticator) DeleteUser(userID string) error {
const op = "auth.DeleteUser"
@ -404,6 +423,8 @@ func (a *Authenticator) DeleteUser(userID string) error {
// - Otherwise, falls back to legacy .lthn file with LTHN hash verification.
// On successful legacy login, the password is re-hashed with Argon2id and
// a .hash file is written (transparent migration).
//
// Usage: call Login(...) for password-based flows when challenge-response is not required.
func (a *Authenticator) Login(userID, password string) (*Session, error) {
const op = "auth.Login"
@ -419,7 +440,7 @@ func (a *Authenticator) Login(userID, password string) (*Session, error) {
return nil, coreerr.E(op, "failed to read password hash", err)
}
if strings.HasPrefix(storedHash, "$argon2id$") {
if core.HasPrefix(storedHash, "$argon2id$") {
valid, err := crypt.VerifyPassword(password, storedHash)
if err != nil {
return nil, coreerr.E(op, "failed to verify password", err)
@ -456,6 +477,7 @@ func (a *Authenticator) Login(userID, password string) (*Session, error) {
// all existing sessions. The caller must provide the current password
// (oldPassword) to decrypt existing metadata and the new password (newPassword)
// to protect the new keypair.
// Usage: call RotateKeyPair(...) during the package's normal workflow.
func (a *Authenticator) RotateKeyPair(userID, oldPassword, newPassword string) (*User, error) {
const op = "auth.RotateKeyPair"
@ -482,7 +504,9 @@ func (a *Authenticator) RotateKeyPair(userID, oldPassword, newPassword string) (
}
var user User
if err := json.Unmarshal(metaJSON, &user); err != nil {
metaResult := core.JSONUnmarshal(metaJSON, &user)
if !metaResult.OK {
err, _ := metaResult.Value.(error)
return nil, coreerr.E(op, "failed to unmarshal user metadata", err)
}
@ -504,12 +528,13 @@ func (a *Authenticator) RotateKeyPair(userID, oldPassword, newPassword string) (
user.PasswordHash = newHash
// Re-encrypt metadata with new public key
updatedMeta, err := json.Marshal(&user)
if err != nil {
updatedMetaResult := core.JSONMarshal(&user)
if !updatedMetaResult.OK {
err, _ := updatedMetaResult.Value.(error)
return nil, coreerr.E(op, "failed to marshal updated metadata", err)
}
encUpdatedMeta, err := pgp.Encrypt(updatedMeta, newKP.PublicKey)
encUpdatedMeta, err := pgp.Encrypt(updatedMetaResult.Value.([]byte), newKP.PublicKey)
if err != nil {
return nil, coreerr.E(op, "failed to encrypt metadata with new key", err)
}
@ -537,6 +562,7 @@ func (a *Authenticator) RotateKeyPair(userID, oldPassword, newPassword string) (
// RevokeKey marks a user's key as revoked. It verifies the password first,
// writes a JSON revocation record to the .rev file (replacing the placeholder),
// and invalidates all sessions for the user.
// Usage: call RevokeKey(...) during the package's normal workflow.
func (a *Authenticator) RevokeKey(userID, password, reason string) error {
const op = "auth.RevokeKey"
@ -556,11 +582,12 @@ func (a *Authenticator) RevokeKey(userID, password, reason string) error {
Reason: reason,
RevokedAt: time.Now(),
}
revJSON, err := json.Marshal(&rev)
if err != nil {
revJSONResult := core.JSONMarshal(&rev)
if !revJSONResult.OK {
err, _ := revJSONResult.Value.(error)
return coreerr.E(op, "failed to marshal revocation record", err)
}
if err := a.medium.Write(userPath(userID, ".rev"), string(revJSON)); err != nil {
if err := a.medium.Write(userPath(userID, ".rev"), string(revJSONResult.Value.([]byte))); err != nil {
return coreerr.E(op, "failed to write revocation record", err)
}
@ -573,6 +600,7 @@ func (a *Authenticator) RevokeKey(userID, password, reason string) error {
// IsRevoked checks whether a user's key has been revoked by inspecting the
// .rev file. Returns true only if the file contains valid revocation JSON
// (not the legacy "REVOCATION_PLACEHOLDER" string).
// Usage: call IsRevoked(...) during the package's normal workflow.
func (a *Authenticator) IsRevoked(userID string) bool {
content, err := a.medium.Read(userPath(userID, ".rev"))
if err != nil {
@ -586,7 +614,8 @@ func (a *Authenticator) IsRevoked(userID string) bool {
// Attempt to parse as JSON revocation record
var rev Revocation
if err := json.Unmarshal([]byte(content), &rev); err != nil {
revResult := core.JSONUnmarshal([]byte(content), &rev)
if !revResult.OK {
return false
}
@ -597,6 +626,7 @@ func (a *Authenticator) IsRevoked(userID string) bool {
// WriteChallengeFile writes an encrypted challenge to a file for air-gapped
// (courier) transport. The challenge is created and then its encrypted nonce
// is written to the specified path on the Medium.
// Usage: call WriteChallengeFile(...) during the package's normal workflow.
func (a *Authenticator) WriteChallengeFile(userID, path string) error {
const op = "auth.WriteChallengeFile"
@ -605,12 +635,13 @@ func (a *Authenticator) WriteChallengeFile(userID, path string) error {
return coreerr.E(op, "failed to create challenge", err)
}
data, err := json.Marshal(challenge)
if err != nil {
challengeResult := core.JSONMarshal(challenge)
if !challengeResult.OK {
err, _ := challengeResult.Value.(error)
return coreerr.E(op, "failed to marshal challenge", err)
}
if err := a.medium.Write(path, string(data)); err != nil {
if err := a.medium.Write(path, string(challengeResult.Value.([]byte))); err != nil {
return coreerr.E(op, "failed to write challenge file", err)
}
@ -620,6 +651,7 @@ func (a *Authenticator) WriteChallengeFile(userID, path string) error {
// ReadResponseFile reads a signed response from a file and validates it,
// completing the air-gapped authentication flow. The file must contain the
// raw PGP signature bytes (armored).
// Usage: call ReadResponseFile(...) during the package's normal workflow.
func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error) {
const op = "auth.ReadResponseFile"
@ -645,7 +677,7 @@ func (a *Authenticator) verifyPassword(userID, password string) error {
// Try Argon2id hash first (.hash file)
if a.medium.IsFile(userPath(userID, ".hash")) {
storedHash, err := a.medium.Read(userPath(userID, ".hash"))
if err == nil && strings.HasPrefix(storedHash, "$argon2id$") {
if err == nil && core.HasPrefix(storedHash, "$argon2id$") {
valid, verr := crypt.VerifyPassword(password, storedHash)
if verr != nil {
return coreerr.E(op, "failed to verify password", nil)
@ -693,6 +725,7 @@ func (a *Authenticator) createSession(userID string) (*Session, error) {
// StartCleanup runs a background goroutine that periodically removes expired
// sessions from the store. It stops when the context is cancelled.
// Usage: call StartCleanup(...) during the package's normal workflow.
func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
@ -705,11 +738,11 @@ func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration
case <-ticker.C:
count, err := a.store.Cleanup()
if err != nil {
fmt.Printf("auth: session cleanup error: %v\n", err)
core.Print(nil, "auth: session cleanup error: %v", err)
continue
}
if count > 0 {
fmt.Printf("auth: cleaned up %d expired session(s)\n", count)
core.Print(nil, "auth: cleaned up %d expired session(s)", count)
}
}
}

View file

@ -1,13 +1,11 @@
package auth
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -25,7 +23,7 @@ func newTestAuth(opts ...Option) (*Authenticator, *io.MockMedium) {
// --- Register ---
func TestRegister_Good(t *testing.T) {
func TestAuth_Register_Good(t *testing.T) {
a, m := newTestAuth()
user, err := a.Register("alice", "hunter2")
@ -46,11 +44,11 @@ func TestRegister_Good(t *testing.T) {
assert.NotEmpty(t, user.PublicKey)
assert.Equal(t, userID, user.KeyID)
assert.NotEmpty(t, user.Fingerprint)
assert.True(t, strings.HasPrefix(user.PasswordHash, "$argon2id$"), "password hash should be Argon2id format")
assert.True(t, core.HasPrefix(user.PasswordHash, "$argon2id$"), "password hash should be Argon2id format")
assert.False(t, user.Created.IsZero())
}
func TestRegister_Bad(t *testing.T) {
func TestAuth_Register_Bad(t *testing.T) {
a, _ := newTestAuth()
// Register first time succeeds
@ -63,7 +61,7 @@ func TestRegister_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "user already exists")
}
func TestRegister_Ugly(t *testing.T) {
func TestAuth_Register_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Empty username/password should still work (PGP allows it)
@ -74,7 +72,7 @@ func TestRegister_Ugly(t *testing.T) {
// --- CreateChallenge ---
func TestCreateChallenge_Good(t *testing.T) {
func TestAuth_CreateChallenge_Good(t *testing.T) {
a, _ := newTestAuth()
user, err := a.Register("charlie", "pass")
@ -89,7 +87,7 @@ func TestCreateChallenge_Good(t *testing.T) {
assert.True(t, challenge.ExpiresAt.After(time.Now()))
}
func TestCreateChallenge_Bad(t *testing.T) {
func TestAuth_CreateChallenge_Bad(t *testing.T) {
a, _ := newTestAuth()
// Challenge for non-existent user
@ -98,7 +96,7 @@ func TestCreateChallenge_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "user not found")
}
func TestCreateChallenge_Ugly(t *testing.T) {
func TestAuth_CreateChallenge_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Empty userID
@ -108,7 +106,7 @@ func TestCreateChallenge_Ugly(t *testing.T) {
// --- ValidateResponse (full challenge-response flow) ---
func TestValidateResponse_Good(t *testing.T) {
func TestAuth_ValidateResponse_Good(t *testing.T) {
a, m := newTestAuth()
// Register user
@ -142,7 +140,7 @@ func TestValidateResponse_Good(t *testing.T) {
assert.True(t, session.ExpiresAt.After(time.Now()))
}
func TestValidateResponse_Bad(t *testing.T) {
func TestAuth_ValidateResponse_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("eve", "pass")
@ -155,7 +153,7 @@ func TestValidateResponse_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "no pending challenge")
}
func TestValidateResponse_Ugly(t *testing.T) {
func TestAuth_ValidateResponse_Ugly(t *testing.T) {
a, m := newTestAuth(WithChallengeTTL(1 * time.Millisecond))
_, err := a.Register("frank", "pass")
@ -182,7 +180,7 @@ func TestValidateResponse_Ugly(t *testing.T) {
// --- ValidateSession ---
func TestValidateSession_Good(t *testing.T) {
func TestAuth_ValidateSession_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("grace", "pass")
@ -198,7 +196,7 @@ func TestValidateSession_Good(t *testing.T) {
assert.Equal(t, userID, validated.UserID)
}
func TestValidateSession_Bad(t *testing.T) {
func TestAuth_ValidateSession_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.ValidateSession("nonexistent-token")
@ -206,7 +204,7 @@ func TestValidateSession_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "session not found")
}
func TestValidateSession_Ugly(t *testing.T) {
func TestAuth_ValidateSession_Ugly(t *testing.T) {
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
_, err := a.Register("heidi", "pass")
@ -225,7 +223,7 @@ func TestValidateSession_Ugly(t *testing.T) {
// --- RefreshSession ---
func TestRefreshSession_Good(t *testing.T) {
func TestAuth_RefreshSession_Good(t *testing.T) {
a, _ := newTestAuth(WithSessionTTL(1 * time.Hour))
_, err := a.Register("ivan", "pass")
@ -245,7 +243,7 @@ func TestRefreshSession_Good(t *testing.T) {
assert.True(t, refreshed.ExpiresAt.After(originalExpiry))
}
func TestRefreshSession_Bad(t *testing.T) {
func TestAuth_RefreshSession_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.RefreshSession("nonexistent-token")
@ -253,7 +251,7 @@ func TestRefreshSession_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "session not found")
}
func TestRefreshSession_Ugly(t *testing.T) {
func TestAuth_RefreshSession_Ugly(t *testing.T) {
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
_, err := a.Register("judy", "pass")
@ -272,7 +270,7 @@ func TestRefreshSession_Ugly(t *testing.T) {
// --- RevokeSession ---
func TestRevokeSession_Good(t *testing.T) {
func TestAuth_RevokeSession_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("karl", "pass")
@ -290,7 +288,7 @@ func TestRevokeSession_Good(t *testing.T) {
assert.Error(t, err)
}
func TestRevokeSession_Bad(t *testing.T) {
func TestAuth_RevokeSession_Bad(t *testing.T) {
a, _ := newTestAuth()
err := a.RevokeSession("nonexistent-token")
@ -298,7 +296,7 @@ func TestRevokeSession_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "session not found")
}
func TestRevokeSession_Ugly(t *testing.T) {
func TestAuth_RevokeSession_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Revoke empty token
@ -308,7 +306,7 @@ func TestRevokeSession_Ugly(t *testing.T) {
// --- DeleteUser ---
func TestDeleteUser_Good(t *testing.T) {
func TestAuth_DeleteUser_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("larry", "pass")
@ -336,7 +334,7 @@ func TestDeleteUser_Good(t *testing.T) {
assert.Contains(t, err.Error(), "session not found")
}
func TestDeleteUser_Bad(t *testing.T) {
func TestAuth_DeleteUser_Bad(t *testing.T) {
a, _ := newTestAuth()
// Protected user "server" cannot be deleted
@ -345,7 +343,7 @@ func TestDeleteUser_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "cannot delete protected user")
}
func TestDeleteUser_Ugly(t *testing.T) {
func TestAuth_DeleteUser_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Non-existent user
@ -356,7 +354,7 @@ func TestDeleteUser_Ugly(t *testing.T) {
// --- Login ---
func TestLogin_Good(t *testing.T) {
func TestAuth_Login_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("mallory", "secret")
@ -372,7 +370,7 @@ func TestLogin_Good(t *testing.T) {
assert.True(t, session.ExpiresAt.After(time.Now()))
}
func TestLogin_Bad(t *testing.T) {
func TestAuth_Login_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("nancy", "correct-password")
@ -385,7 +383,7 @@ func TestLogin_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "invalid password")
}
func TestLogin_Ugly(t *testing.T) {
func TestAuth_Login_Ugly(t *testing.T) {
a, _ := newTestAuth()
// Login for non-existent user
@ -396,7 +394,7 @@ func TestLogin_Ugly(t *testing.T) {
// --- WriteChallengeFile / ReadResponseFile (Air-Gapped) ---
func TestAirGappedFlow_Good(t *testing.T) {
func TestAuth_AirGappedFlow_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("oscar", "airgap-pass")
@ -414,8 +412,8 @@ func TestAirGappedFlow_Good(t *testing.T) {
require.NoError(t, err)
var challenge Challenge
err = json.Unmarshal([]byte(challengeData), &challenge)
require.NoError(t, err)
result := core.JSONUnmarshal([]byte(challengeData), &challenge)
require.Truef(t, result.OK, "failed to unmarshal challenge: %v", result.Value)
// Client-side: decrypt nonce and sign it
privKey, err := m.Read(userPath(userID, ".key"))
@ -441,7 +439,7 @@ func TestAirGappedFlow_Good(t *testing.T) {
assert.Equal(t, userID, session.UserID)
}
func TestWriteChallengeFile_Bad(t *testing.T) {
func TestAuth_WriteChallengeFile_Bad(t *testing.T) {
a, _ := newTestAuth()
// Challenge for non-existent user
@ -449,7 +447,7 @@ func TestWriteChallengeFile_Bad(t *testing.T) {
assert.Error(t, err)
}
func TestReadResponseFile_Bad(t *testing.T) {
func TestAuth_ReadResponseFile_Bad(t *testing.T) {
a, _ := newTestAuth()
// Response file does not exist
@ -457,7 +455,7 @@ func TestReadResponseFile_Bad(t *testing.T) {
assert.Error(t, err)
}
func TestReadResponseFile_Ugly(t *testing.T) {
func TestAuth_ReadResponseFile_Ugly(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("peggy", "pass")
@ -479,13 +477,13 @@ func TestReadResponseFile_Ugly(t *testing.T) {
// --- Options ---
func TestWithChallengeTTL_Good(t *testing.T) {
func TestAuth_WithChallengeTTL_Good(t *testing.T) {
ttl := 30 * time.Second
a, _ := newTestAuth(WithChallengeTTL(ttl))
assert.Equal(t, ttl, a.challengeTTL)
}
func TestWithSessionTTL_Good(t *testing.T) {
func TestAuth_WithSessionTTL_Good(t *testing.T) {
ttl := 2 * time.Hour
a, _ := newTestAuth(WithSessionTTL(ttl))
assert.Equal(t, ttl, a.sessionTTL)
@ -493,7 +491,7 @@ func TestWithSessionTTL_Good(t *testing.T) {
// --- Full Round-Trip (Online Flow) ---
func TestFullRoundTrip_Good(t *testing.T) {
func TestAuth_FullRoundTrip_Good(t *testing.T) {
a, m := newTestAuth()
// 1. Register
@ -543,7 +541,7 @@ func TestFullRoundTrip_Good(t *testing.T) {
// --- Concurrent Access ---
func TestConcurrentSessions_Good(t *testing.T) {
func TestAuth_ConcurrentSessions_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("ruth", "pass")
@ -581,16 +579,16 @@ func TestConcurrentSessions_Good(t *testing.T) {
// --- Phase 0 Additions ---
// TestConcurrentSessionCreation_Good verifies that 10 goroutines creating
// TestAuth_ConcurrentSessionCreation_Good verifies that 10 goroutines creating
// sessions simultaneously do not produce data races or errors.
func TestConcurrentSessionCreation_Good(t *testing.T) {
func TestAuth_ConcurrentSessionCreation_Good(t *testing.T) {
a, _ := newTestAuth()
// Register 10 distinct users to avoid contention on a single user record
const n = 10
userIDs := make([]string, n)
for i := range n {
username := fmt.Sprintf("concurrent-user-%d", i)
username := core.Sprintf("concurrent-user-%d", i)
_, err := a.Register(username, "pass")
require.NoError(t, err)
userIDs[i] = lthn.Hash(username)
@ -621,8 +619,9 @@ func TestConcurrentSessionCreation_Good(t *testing.T) {
}
}
// TestSessionTokenUniqueness_Good generates 1000 tokens and verifies no collisions.
func TestSessionTokenUniqueness_Good(t *testing.T) {
// TestAuth_SessionTokenUniqueness_Good generates 1000 session tokens and verifies
// no collisions without paying the full login hash-verification cost each time.
func TestAuth_SessionTokenUniqueness_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("uniqueness-test", "pass")
@ -633,7 +632,7 @@ func TestSessionTokenUniqueness_Good(t *testing.T) {
tokens := make(map[string]bool, n)
for i := range n {
session, err := a.Login(userID, "pass")
session, err := a.createSession(userID)
require.NoError(t, err)
require.NotNil(t, session)
@ -646,9 +645,9 @@ func TestSessionTokenUniqueness_Good(t *testing.T) {
assert.Len(t, tokens, n, "all 1000 tokens should be unique")
}
// TestChallengeExpiryBoundary_Ugly tests validation right at the 5-minute boundary.
// TestAuth_ChallengeExpiryBoundary_Ugly tests validation right at the 5-minute boundary.
// The challenge should still be valid just before expiry and rejected after.
func TestChallengeExpiryBoundary_Ugly(t *testing.T) {
func TestAuth_ChallengeExpiryBoundary_Ugly(t *testing.T) {
// Use a very short TTL to test the boundary without sleeping 5 minutes
ttl := 50 * time.Millisecond
a, m := newTestAuth(WithChallengeTTL(ttl))
@ -692,9 +691,9 @@ func TestChallengeExpiryBoundary_Ugly(t *testing.T) {
assert.Contains(t, err.Error(), "challenge expired")
}
// TestEmptyPasswordRegistration_Good verifies that empty password registration works.
// TestAuth_EmptyPasswordRegistration_Good verifies that empty password registration works.
// PGP key is generated unencrypted in this case.
func TestEmptyPasswordRegistration_Good(t *testing.T) {
func TestAuth_EmptyPasswordRegistration_Good(t *testing.T) {
a, m := newTestAuth()
user, err := a.Register("no-password-user", "")
@ -731,11 +730,15 @@ func TestEmptyPasswordRegistration_Good(t *testing.T) {
assert.NotNil(t, crSession)
}
// TestVeryLongUsername_Ugly verifies behaviour with a 10K character username.
func TestVeryLongUsername_Ugly(t *testing.T) {
// TestAuth_VeryLongUsername_Ugly verifies behaviour with a 10K character username.
func TestAuth_VeryLongUsername_Ugly(t *testing.T) {
a, _ := newTestAuth()
longUsername := strings.Repeat("a", 10000)
longName := core.NewBuilder()
for range 10000 {
longName.WriteString("a")
}
longUsername := longName.String()
user, err := a.Register(longUsername, "pass")
require.NoError(t, err)
require.NotNil(t, user)
@ -750,8 +753,8 @@ func TestVeryLongUsername_Ugly(t *testing.T) {
assert.NotNil(t, session)
}
// TestUnicodeUsernamePassword_Good verifies registration and login with Unicode characters.
func TestUnicodeUsernamePassword_Good(t *testing.T) {
// TestAuth_UnicodeUsernamePassword_Good verifies registration and login with Unicode characters.
func TestAuth_UnicodeUsernamePassword_Good(t *testing.T) {
a, _ := newTestAuth()
// Japanese + emoji + Chinese + Arabic
@ -774,9 +777,9 @@ func TestUnicodeUsernamePassword_Good(t *testing.T) {
assert.Error(t, err)
}
// TestAirGappedRoundTrip_Good tests the full air-gapped flow:
// TestAuth_AirGappedRoundTrip_Good tests the full air-gapped flow:
// WriteChallengeFile -> client signs offline -> ReadResponseFile
func TestAirGappedRoundTrip_Good(t *testing.T) {
func TestAuth_AirGappedRoundTrip_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("airgap-roundtrip", "courier-pass")
@ -794,8 +797,8 @@ func TestAirGappedRoundTrip_Good(t *testing.T) {
require.NoError(t, err)
var challenge Challenge
err = json.Unmarshal([]byte(challengeData), &challenge)
require.NoError(t, err)
result := core.JSONUnmarshal([]byte(challengeData), &challenge)
require.Truef(t, result.OK, "failed to unmarshal challenge: %v", result.Value)
assert.NotEmpty(t, challenge.Encrypted)
assert.True(t, challenge.ExpiresAt.After(time.Now()))
@ -829,8 +832,8 @@ func TestAirGappedRoundTrip_Good(t *testing.T) {
assert.Equal(t, session.Token, validated.Token)
}
// TestRefreshExpiredSession_Bad verifies that refreshing an already-expired session fails.
func TestRefreshExpiredSession_Bad(t *testing.T) {
// TestAuth_RefreshExpiredSession_Bad verifies that refreshing an already-expired session fails.
func TestAuth_RefreshExpiredSession_Bad(t *testing.T) {
a, _ := newTestAuth(WithSessionTTL(1 * time.Millisecond))
_, err := a.Register("expired-refresh", "pass")
@ -856,8 +859,8 @@ func TestRefreshExpiredSession_Bad(t *testing.T) {
// --- Phase 2: Password Hash Migration ---
// TestRegisterArgon2id_Good verifies that new registrations use Argon2id format.
func TestRegisterArgon2id_Good(t *testing.T) {
// TestAuth_RegisterArgon2id_Good verifies that new registrations use Argon2id format.
func TestAuth_RegisterArgon2id_Good(t *testing.T) {
a, m := newTestAuth()
user, err := a.Register("argon2-user", "strong-pass")
@ -869,17 +872,17 @@ func TestRegisterArgon2id_Good(t *testing.T) {
assert.True(t, m.IsFile(userPath(userID, ".hash")))
hashContent, err := m.Read(userPath(userID, ".hash"))
require.NoError(t, err)
assert.True(t, strings.HasPrefix(hashContent, "$argon2id$"), "stored hash should be Argon2id")
assert.True(t, core.HasPrefix(hashContent, "$argon2id$"), "stored hash should be Argon2id")
// .lthn file should NOT exist for new registrations
assert.False(t, m.IsFile(userPath(userID, ".lthn")))
// User struct should have Argon2id hash
assert.True(t, strings.HasPrefix(user.PasswordHash, "$argon2id$"))
assert.True(t, core.HasPrefix(user.PasswordHash, "$argon2id$"))
}
// TestLoginArgon2id_Good verifies login works with Argon2id hashed password.
func TestLoginArgon2id_Good(t *testing.T) {
// TestAuth_LoginArgon2id_Good verifies login works with Argon2id hashed password.
func TestAuth_LoginArgon2id_Good(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("login-argon2", "my-password")
@ -892,8 +895,8 @@ func TestLoginArgon2id_Good(t *testing.T) {
assert.NotEmpty(t, session.Token)
}
// TestLoginArgon2id_Bad verifies wrong password fails with Argon2id hash.
func TestLoginArgon2id_Bad(t *testing.T) {
// TestAuth_LoginArgon2id_Bad verifies wrong password fails with Argon2id hash.
func TestAuth_LoginArgon2id_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("login-argon2-bad", "correct")
@ -905,9 +908,9 @@ func TestLoginArgon2id_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "invalid password")
}
// TestLegacyLTHNMigration_Good verifies that a user registered with the legacy
// TestAuth_LegacyLTHNMigration_Good verifies that a user registered with the legacy
// LTHN hash format is transparently migrated to Argon2id on successful login.
func TestLegacyLTHNMigration_Good(t *testing.T) {
func TestAuth_LegacyLTHNMigration_Good(t *testing.T) {
m := io.NewMockMedium()
a := New(m)
@ -939,7 +942,7 @@ func TestLegacyLTHNMigration_Good(t *testing.T) {
assert.True(t, m.IsFile(userPath(userID, ".hash")), "migration should create .hash file")
newHash, err := m.Read(userPath(userID, ".hash"))
require.NoError(t, err)
assert.True(t, strings.HasPrefix(newHash, "$argon2id$"), "migrated hash should be Argon2id")
assert.True(t, core.HasPrefix(newHash, "$argon2id$"), "migrated hash should be Argon2id")
// Subsequent login should use the new Argon2id hash (not LTHN)
session2, err := a.Login(userID, "legacy-pass")
@ -947,8 +950,8 @@ func TestLegacyLTHNMigration_Good(t *testing.T) {
assert.NotEmpty(t, session2.Token)
}
// TestLegacyLTHNLogin_Bad verifies wrong password fails for legacy LTHN users.
func TestLegacyLTHNLogin_Bad(t *testing.T) {
// TestAuth_LegacyLTHNLogin_Bad verifies wrong password fails for legacy LTHN users.
func TestAuth_LegacyLTHNLogin_Bad(t *testing.T) {
m := io.NewMockMedium()
a := New(m)
@ -973,9 +976,9 @@ func TestLegacyLTHNLogin_Bad(t *testing.T) {
// --- Phase 2: Key Rotation ---
// TestRotateKeyPair_Good verifies the full key rotation flow:
// TestAuth_RotateKeyPair_Good verifies the full key rotation flow:
// register -> login -> rotate -> verify old key can't decrypt -> verify new key works -> sessions invalidated.
func TestRotateKeyPair_Good(t *testing.T) {
func TestAuth_RotateKeyPair_Good(t *testing.T) {
a, m := newTestAuth()
// Register and login
@ -1023,14 +1026,14 @@ func TestRotateKeyPair_Good(t *testing.T) {
require.NoError(t, err)
var meta User
err = json.Unmarshal(decrypted, &meta)
require.NoError(t, err)
result := core.JSONUnmarshal(decrypted, &meta)
require.Truef(t, result.OK, "failed to unmarshal metadata: %v", result.Value)
assert.Equal(t, userID, meta.KeyID)
assert.True(t, strings.HasPrefix(meta.PasswordHash, "$argon2id$"))
assert.True(t, core.HasPrefix(meta.PasswordHash, "$argon2id$"))
}
// TestRotateKeyPair_Bad verifies that rotation fails with wrong old password.
func TestRotateKeyPair_Bad(t *testing.T) {
// TestAuth_RotateKeyPair_Bad verifies that rotation fails with wrong old password.
func TestAuth_RotateKeyPair_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("rotate-bad", "correct-pass")
@ -1043,8 +1046,8 @@ func TestRotateKeyPair_Bad(t *testing.T) {
assert.Contains(t, err.Error(), "failed to decrypt metadata")
}
// TestRotateKeyPair_Ugly verifies rotation for non-existent user.
func TestRotateKeyPair_Ugly(t *testing.T) {
// TestAuth_RotateKeyPair_Ugly verifies rotation for non-existent user.
func TestAuth_RotateKeyPair_Ugly(t *testing.T) {
a, _ := newTestAuth()
_, err := a.RotateKeyPair("nonexistent-user-id", "old", "new")
@ -1052,9 +1055,9 @@ func TestRotateKeyPair_Ugly(t *testing.T) {
assert.Contains(t, err.Error(), "user not found")
}
// TestRotateKeyPair_OldKeyCannotDecrypt_Good verifies old private key
// TestAuth_RotateKeyPairOldKeyCannotDecrypt_Good verifies old private key
// cannot decrypt metadata after rotation.
func TestRotateKeyPair_OldKeyCannotDecrypt_Good(t *testing.T) {
func TestAuth_RotateKeyPairOldKeyCannotDecrypt_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("rotate-crypto", "pass-a")
@ -1078,9 +1081,9 @@ func TestRotateKeyPair_OldKeyCannotDecrypt_Good(t *testing.T) {
// --- Phase 2: Key Revocation ---
// TestRevokeKey_Good verifies the full revocation flow:
// TestAuth_RevokeKey_Good verifies the full revocation flow:
// register -> login -> revoke -> login fails -> challenge fails -> sessions invalidated.
func TestRevokeKey_Good(t *testing.T) {
func TestAuth_RevokeKey_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("revoke-user", "pass")
@ -1107,8 +1110,8 @@ func TestRevokeKey_Good(t *testing.T) {
assert.NotEqual(t, "REVOCATION_PLACEHOLDER", revContent)
var rev Revocation
err = json.Unmarshal([]byte(revContent), &rev)
require.NoError(t, err)
result := core.JSONUnmarshal([]byte(revContent), &rev)
require.Truef(t, result.OK, "failed to unmarshal revocation: %v", result.Value)
assert.Equal(t, userID, rev.UserID)
assert.Equal(t, "compromised key material", rev.Reason)
assert.False(t, rev.RevokedAt.IsZero())
@ -1128,8 +1131,8 @@ func TestRevokeKey_Good(t *testing.T) {
assert.Error(t, err)
}
// TestRevokeKey_Bad verifies revocation fails with wrong password.
func TestRevokeKey_Bad(t *testing.T) {
// TestAuth_RevokeKey_Bad verifies revocation fails with wrong password.
func TestAuth_RevokeKey_Bad(t *testing.T) {
a, _ := newTestAuth()
_, err := a.Register("revoke-bad", "correct")
@ -1144,8 +1147,8 @@ func TestRevokeKey_Bad(t *testing.T) {
assert.False(t, a.IsRevoked(userID))
}
// TestRevokeKey_Ugly verifies revocation for non-existent user.
func TestRevokeKey_Ugly(t *testing.T) {
// TestAuth_RevokeKey_Ugly verifies revocation for non-existent user.
func TestAuth_RevokeKey_Ugly(t *testing.T) {
a, _ := newTestAuth()
err := a.RevokeKey("nonexistent-user-id", "pass", "reason")
@ -1153,9 +1156,9 @@ func TestRevokeKey_Ugly(t *testing.T) {
assert.Contains(t, err.Error(), "user not found")
}
// TestIsRevoked_Placeholder_Good verifies that the legacy placeholder is not
// TestAuth_IsRevokedPlaceholder_Good verifies that the legacy placeholder is not
// treated as a valid revocation.
func TestIsRevoked_Placeholder_Good(t *testing.T) {
func TestAuth_IsRevokedPlaceholder_Good(t *testing.T) {
a, m := newTestAuth()
_, err := a.Register("placeholder-user", "pass")
@ -1171,16 +1174,16 @@ func TestIsRevoked_Placeholder_Good(t *testing.T) {
assert.False(t, a.IsRevoked(userID))
}
// TestIsRevoked_NoRevFile_Good verifies that a missing .rev file returns false.
func TestIsRevoked_NoRevFile_Good(t *testing.T) {
// TestAuth_IsRevokedNoRevFile_Good verifies that a missing .rev file returns false.
func TestAuth_IsRevokedNoRevFile_Good(t *testing.T) {
a, _ := newTestAuth()
assert.False(t, a.IsRevoked("completely-nonexistent"))
}
// TestRevokeKey_LegacyUser_Good verifies revocation works for a legacy user
// TestAuth_RevokeKeyLegacyUser_Good verifies revocation works for a legacy user
// with only a .lthn hash file (no .hash file).
func TestRevokeKey_LegacyUser_Good(t *testing.T) {
func TestAuth_RevokeKeyLegacyUser_Good(t *testing.T) {
m := io.NewMockMedium()
a := New(m)

View file

@ -17,6 +17,7 @@ package auth
// similar tamper-resistant devices.
//
// All methods must be safe for concurrent use.
// Usage: implement HardwareKey and pass it to WithHardwareKey(...) to wire hardware-backed auth into New(...).
type HardwareKey interface {
// Sign produces a cryptographic signature over the given data using the
// hardware-stored private key. The signature format depends on the
@ -44,6 +45,7 @@ type HardwareKey interface {
//
// This is a forward-looking option — integration points are documented in
// auth.go but not yet wired up.
// Usage: pass WithHardwareKey(...) into New(...) to enable a HardwareKey implementation.
func WithHardwareKey(hk HardwareKey) Option {
return func(a *Authenticator) {
a.hardwareKey = hk

View file

@ -9,9 +9,11 @@ import (
)
// ErrSessionNotFound is returned when a session token is not found.
// Usage: compare returned errors against ErrSessionNotFound when branching on failures.
var ErrSessionNotFound = coreerr.E("auth", "session not found", nil)
// SessionStore abstracts session persistence.
// Usage: use SessionStore with the other exported helpers in this package.
type SessionStore interface {
Get(token string) (*Session, error)
Set(session *Session) error
@ -21,12 +23,14 @@ type SessionStore interface {
}
// MemorySessionStore is an in-memory SessionStore backed by a map.
// Usage: use MemorySessionStore with the other exported helpers in this package.
type MemorySessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
}
// NewMemorySessionStore creates a new in-memory session store.
// Usage: call NewMemorySessionStore(...) to create a ready-to-use value.
func NewMemorySessionStore() *MemorySessionStore {
return &MemorySessionStore{
sessions: make(map[string]*Session),
@ -34,6 +38,7 @@ func NewMemorySessionStore() *MemorySessionStore {
}
// Get retrieves a session by token.
// Usage: call Get(...) during the package's normal workflow.
func (m *MemorySessionStore) Get(token string) (*Session, error) {
m.mu.RLock()
defer m.mu.RUnlock()
@ -49,6 +54,7 @@ func (m *MemorySessionStore) Get(token string) (*Session, error) {
}
// Set stores a session, keyed by its token.
// Usage: call Set(...) during the package's normal workflow.
func (m *MemorySessionStore) Set(session *Session) error {
m.mu.Lock()
defer m.mu.Unlock()
@ -60,6 +66,7 @@ func (m *MemorySessionStore) Set(session *Session) error {
}
// Delete removes a session by token.
// Usage: call Delete(...) during the package's normal workflow.
func (m *MemorySessionStore) Delete(token string) error {
m.mu.Lock()
defer m.mu.Unlock()
@ -73,6 +80,7 @@ func (m *MemorySessionStore) Delete(token string) error {
}
// DeleteByUser removes all sessions belonging to the given user.
// Usage: call DeleteByUser(...) during the package's normal workflow.
func (m *MemorySessionStore) DeleteByUser(userID string) error {
m.mu.Lock()
defer m.mu.Unlock()
@ -84,6 +92,7 @@ func (m *MemorySessionStore) DeleteByUser(userID string) error {
}
// Cleanup removes all expired sessions and returns the count removed.
// Usage: call Cleanup(...) during the package's normal workflow.
func (m *MemorySessionStore) Cleanup() (int, error) {
m.mu.Lock()
defer m.mu.Unlock()

View file

@ -1,18 +1,18 @@
package auth
import (
"encoding/json"
"errors"
"sync"
"time"
"forge.lthn.ai/core/go-store"
core "dappco.re/go/core"
"dappco.re/go/core/store"
)
const sessionGroup = "sessions"
// SQLiteSessionStore is a SessionStore backed by go-store (SQLite KV).
// SQLiteSessionStore is a SessionStore backed by core/store (SQLite KV).
// A mutex serialises all operations because SQLite is single-writer.
// Usage: use SQLiteSessionStore with the other exported helpers in this package.
type SQLiteSessionStore struct {
mu sync.Mutex
store *store.Store
@ -20,6 +20,7 @@ type SQLiteSessionStore struct {
// NewSQLiteSessionStore creates a new SQLite-backed session store.
// Use ":memory:" for testing or a file path for persistent storage.
// Usage: call NewSQLiteSessionStore(...) to create a ready-to-use value.
func NewSQLiteSessionStore(dbPath string) (*SQLiteSessionStore, error) {
s, err := store.New(dbPath)
if err != nil {
@ -29,38 +30,44 @@ func NewSQLiteSessionStore(dbPath string) (*SQLiteSessionStore, error) {
}
// Get retrieves a session by token from SQLite.
// Usage: call Get(...) during the package's normal workflow.
func (s *SQLiteSessionStore) Get(token string) (*Session, error) {
s.mu.Lock()
defer s.mu.Unlock()
val, err := s.store.Get(sessionGroup, token)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
if core.Is(err, store.ErrNotFound) {
return nil, ErrSessionNotFound
}
return nil, err
}
var session Session
if err := json.Unmarshal([]byte(val), &session); err != nil {
result := core.JSONUnmarshal([]byte(val), &session)
if !result.OK {
err, _ := result.Value.(error)
return nil, err
}
return &session, nil
}
// Set stores a session in SQLite, keyed by its token.
// Usage: call Set(...) during the package's normal workflow.
func (s *SQLiteSessionStore) Set(session *Session) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := json.Marshal(session)
if err != nil {
result := core.JSONMarshal(session)
if !result.OK {
err, _ := result.Value.(error)
return err
}
return s.store.Set(sessionGroup, session.Token, string(data))
return s.store.Set(sessionGroup, session.Token, string(result.Value.([]byte)))
}
// Delete removes a session by token from SQLite.
// Usage: call Delete(...) during the package's normal workflow.
func (s *SQLiteSessionStore) Delete(token string) error {
s.mu.Lock()
defer s.mu.Unlock()
@ -68,7 +75,7 @@ func (s *SQLiteSessionStore) Delete(token string) error {
// Check existence first to return ErrSessionNotFound
_, err := s.store.Get(sessionGroup, token)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
if core.Is(err, store.ErrNotFound) {
return ErrSessionNotFound
}
return err
@ -77,6 +84,7 @@ func (s *SQLiteSessionStore) Delete(token string) error {
}
// DeleteByUser removes all sessions belonging to the given user.
// Usage: call DeleteByUser(...) during the package's normal workflow.
func (s *SQLiteSessionStore) DeleteByUser(userID string) error {
s.mu.Lock()
defer s.mu.Unlock()
@ -88,7 +96,8 @@ func (s *SQLiteSessionStore) DeleteByUser(userID string) error {
for token, val := range all {
var session Session
if err := json.Unmarshal([]byte(val), &session); err != nil {
result := core.JSONUnmarshal([]byte(val), &session)
if !result.OK {
continue // Skip malformed entries
}
if session.UserID == userID {
@ -101,6 +110,7 @@ func (s *SQLiteSessionStore) DeleteByUser(userID string) error {
}
// Cleanup removes all expired sessions and returns the count removed.
// Usage: call Cleanup(...) during the package's normal workflow.
func (s *SQLiteSessionStore) Cleanup() (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
@ -114,7 +124,8 @@ func (s *SQLiteSessionStore) Cleanup() (int, error) {
count := 0
for token, val := range all {
var session Session
if err := json.Unmarshal([]byte(val), &session); err != nil {
result := core.JSONUnmarshal([]byte(val), &session)
if !result.OK {
continue // Skip malformed entries
}
if now.After(session.ExpiresAt) {
@ -128,6 +139,7 @@ func (s *SQLiteSessionStore) Cleanup() (int, error) {
}
// Close closes the underlying SQLite store.
// Usage: call Close(...) during the package's normal workflow.
func (s *SQLiteSessionStore) Close() error {
s.mu.Lock()
defer s.mu.Unlock()

View file

@ -2,13 +2,11 @@ package auth
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -18,7 +16,7 @@ import (
// --- MemorySessionStore ---
func TestMemorySessionStore_GetSetDelete_Good(t *testing.T) {
func TestSessionStore_MemorySessionStoreGetSetDelete_Good(t *testing.T) {
store := NewMemorySessionStore()
session := &Session{
@ -46,27 +44,27 @@ func TestMemorySessionStore_GetSetDelete_Good(t *testing.T) {
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestMemorySessionStore_GetNotFound_Bad(t *testing.T) {
func TestSessionStore_MemorySessionStoreGetNotFound_Bad(t *testing.T) {
store := NewMemorySessionStore()
_, err := store.Get("nonexistent-token")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestMemorySessionStore_DeleteNotFound_Bad(t *testing.T) {
func TestSessionStore_MemorySessionStoreDeleteNotFound_Bad(t *testing.T) {
store := NewMemorySessionStore()
err := store.Delete("nonexistent-token")
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestMemorySessionStore_DeleteByUser_Good(t *testing.T) {
func TestSessionStore_MemorySessionStoreDeleteByUser_Good(t *testing.T) {
store := NewMemorySessionStore()
// Create sessions for two users
for i := range 3 {
err := store.Set(&Session{
Token: fmt.Sprintf("user-a-token-%d", i),
Token: core.Sprintf("user-a-token-%d", i),
UserID: "user-a",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
@ -86,7 +84,7 @@ func TestMemorySessionStore_DeleteByUser_Good(t *testing.T) {
// user-a sessions should be gone
for i := range 3 {
_, err := store.Get(fmt.Sprintf("user-a-token-%d", i))
_, err := store.Get(core.Sprintf("user-a-token-%d", i))
assert.ErrorIs(t, err, ErrSessionNotFound)
}
@ -96,7 +94,7 @@ func TestMemorySessionStore_DeleteByUser_Good(t *testing.T) {
assert.Equal(t, "user-b", got.UserID)
}
func TestMemorySessionStore_Cleanup_Good(t *testing.T) {
func TestSessionStore_MemorySessionStoreCleanup_Good(t *testing.T) {
store := NewMemorySessionStore()
// Create expired and valid sessions
@ -136,7 +134,7 @@ func TestMemorySessionStore_Cleanup_Good(t *testing.T) {
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestMemorySessionStore_Concurrent_Good(t *testing.T) {
func TestSessionStore_MemorySessionStoreConcurrent_Good(t *testing.T) {
store := NewMemorySessionStore()
const n = 20
@ -146,11 +144,11 @@ func TestMemorySessionStore_Concurrent_Good(t *testing.T) {
for i := range n {
go func(idx int) {
defer wg.Done()
token := fmt.Sprintf("concurrent-token-%d", idx)
token := core.Sprintf("concurrent-token-%d", idx)
err := store.Set(&Session{
Token: token,
UserID: fmt.Sprintf("user-%d", idx%5),
UserID: core.Sprintf("user-%d", idx%5),
ExpiresAt: time.Now().Add(1 * time.Hour),
})
assert.NoError(t, err)
@ -166,7 +164,7 @@ func TestMemorySessionStore_Concurrent_Good(t *testing.T) {
// --- SQLiteSessionStore ---
func TestSQLiteSessionStore_GetSetDelete_Good(t *testing.T) {
func TestSessionStore_SQLiteSessionStoreGetSetDelete_Good(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
@ -196,7 +194,7 @@ func TestSQLiteSessionStore_GetSetDelete_Good(t *testing.T) {
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestSQLiteSessionStore_GetNotFound_Bad(t *testing.T) {
func TestSessionStore_SQLiteSessionStoreGetNotFound_Bad(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
@ -205,7 +203,7 @@ func TestSQLiteSessionStore_GetNotFound_Bad(t *testing.T) {
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestSQLiteSessionStore_DeleteNotFound_Bad(t *testing.T) {
func TestSessionStore_SQLiteSessionStoreDeleteNotFound_Bad(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
@ -214,7 +212,7 @@ func TestSQLiteSessionStore_DeleteNotFound_Bad(t *testing.T) {
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestSQLiteSessionStore_DeleteByUser_Good(t *testing.T) {
func TestSessionStore_SQLiteSessionStoreDeleteByUser_Good(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
@ -222,7 +220,7 @@ func TestSQLiteSessionStore_DeleteByUser_Good(t *testing.T) {
// Create sessions for two users
for i := range 3 {
err := store.Set(&Session{
Token: fmt.Sprintf("sqlite-user-a-%d", i),
Token: core.Sprintf("sqlite-user-a-%d", i),
UserID: "user-a",
ExpiresAt: time.Now().Add(1 * time.Hour),
})
@ -242,7 +240,7 @@ func TestSQLiteSessionStore_DeleteByUser_Good(t *testing.T) {
// user-a sessions should be gone
for i := range 3 {
_, err := store.Get(fmt.Sprintf("sqlite-user-a-%d", i))
_, err := store.Get(core.Sprintf("sqlite-user-a-%d", i))
assert.ErrorIs(t, err, ErrSessionNotFound)
}
@ -252,7 +250,7 @@ func TestSQLiteSessionStore_DeleteByUser_Good(t *testing.T) {
assert.Equal(t, "user-b", got.UserID)
}
func TestSQLiteSessionStore_Cleanup_Good(t *testing.T) {
func TestSessionStore_SQLiteSessionStoreCleanup_Good(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
@ -294,9 +292,9 @@ func TestSQLiteSessionStore_Cleanup_Good(t *testing.T) {
assert.ErrorIs(t, err, ErrSessionNotFound)
}
func TestSQLiteSessionStore_Persistence_Good(t *testing.T) {
func TestSessionStore_SQLiteSessionStorePersistence_Good(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "sessions.db")
dbPath := core.Path(dir, "sessions.db")
// Write a session
store1, err := NewSQLiteSessionStore(dbPath)
@ -325,9 +323,9 @@ func TestSQLiteSessionStore_Persistence_Good(t *testing.T) {
assert.Equal(t, "persist-token", got.Token)
}
func TestSQLiteSessionStore_Concurrent_Good(t *testing.T) {
func TestSessionStore_SQLiteSessionStoreConcurrent_Good(t *testing.T) {
// Use a temp file — :memory: SQLite has concurrency limitations
dbPath := filepath.Join(t.TempDir(), "concurrent.db")
dbPath := core.Path(t.TempDir(), "concurrent.db")
store, err := NewSQLiteSessionStore(dbPath)
require.NoError(t, err)
defer store.Close()
@ -339,11 +337,11 @@ func TestSQLiteSessionStore_Concurrent_Good(t *testing.T) {
for i := range n {
go func(idx int) {
defer wg.Done()
token := fmt.Sprintf("sqlite-concurrent-%d", idx)
token := core.Sprintf("sqlite-concurrent-%d", idx)
err := store.Set(&Session{
Token: token,
UserID: fmt.Sprintf("user-%d", idx%5),
UserID: core.Sprintf("user-%d", idx%5),
ExpiresAt: time.Now().Add(1 * time.Hour),
})
assert.NoError(t, err)
@ -361,7 +359,7 @@ func TestSQLiteSessionStore_Concurrent_Good(t *testing.T) {
// --- Authenticator with SessionStore ---
func TestAuthenticator_WithSessionStore_Good(t *testing.T) {
func TestSessionStore_AuthenticatorWithSessionStore_Good(t *testing.T) {
sqliteStore, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer sqliteStore.Close()
@ -400,7 +398,7 @@ func TestAuthenticator_WithSessionStore_Good(t *testing.T) {
assert.Contains(t, err.Error(), "session not found")
}
func TestAuthenticator_DefaultStore_Good(t *testing.T) {
func TestSessionStore_AuthenticatorDefaultStore_Good(t *testing.T) {
m := io.NewMockMedium()
a := New(m)
@ -409,7 +407,7 @@ func TestAuthenticator_DefaultStore_Good(t *testing.T) {
assert.True(t, ok, "default store should be MemorySessionStore")
}
func TestAuthenticator_StartCleanup_Good(t *testing.T) {
func TestSessionStore_AuthenticatorStartCleanup_Good(t *testing.T) {
m := io.NewMockMedium()
a := New(m, WithSessionTTL(1*time.Millisecond))
@ -438,7 +436,7 @@ func TestAuthenticator_StartCleanup_Good(t *testing.T) {
assert.Contains(t, err.Error(), "session not found")
}
func TestAuthenticator_StartCleanup_CancelStops_Good(t *testing.T) {
func TestSessionStore_AuthenticatorStartCleanupCancelStops_Good(t *testing.T) {
m := io.NewMockMedium()
a := New(m)
@ -450,7 +448,7 @@ func TestAuthenticator_StartCleanup_CancelStops_Good(t *testing.T) {
time.Sleep(50 * time.Millisecond)
}
func TestSQLiteSessionStore_UpdateExisting_Good(t *testing.T) {
func TestSessionStore_SQLiteSessionStoreUpdateExisting_Good(t *testing.T) {
store, err := NewSQLiteSessionStore(":memory:")
require.NoError(t, err)
defer store.Close()
@ -478,10 +476,9 @@ func TestSQLiteSessionStore_UpdateExisting_Good(t *testing.T) {
"updated session should have later expiry")
}
func TestSQLiteSessionStore_TempFile_Good(t *testing.T) {
func TestSessionStore_SQLiteSessionStoreTempFile_Good(t *testing.T) {
// Verify we can use a real temp file (not :memory:)
tmpFile := filepath.Join(os.TempDir(), "go-crypt-test-session-store.db")
defer os.Remove(tmpFile)
tmpFile := core.Path(t.TempDir(), "go-crypt-test-session-store.db")
store, err := NewSQLiteSessionStore(tmpFile)
require.NoError(t, err)

View file

@ -7,6 +7,7 @@ func init() {
}
// AddCryptCommands registers the 'crypt' command group and all subcommands.
// Usage: call AddCryptCommands(...) during the package's normal workflow.
func AddCryptCommands(root *cli.Command) {
cryptCmd := &cli.Command{
Use: "crypt",

View file

@ -1,9 +1,7 @@
package crypt
import (
"fmt"
"path/filepath"
core "dappco.re/go/core"
"dappco.re/go/core/crypt/crypt"
"forge.lthn.ai/core/cli/pkg/cli"
)
@ -42,12 +40,12 @@ func runChecksum(path string) error {
if checksumVerify != "" {
if hash == checksumVerify {
cli.Success(fmt.Sprintf("Checksum matches: %s", filepath.Base(path)))
cli.Success(core.Sprintf("Checksum matches: %s", core.PathBase(path)))
return nil
}
cli.Error(fmt.Sprintf("Checksum mismatch: %s", filepath.Base(path)))
cli.Dim(fmt.Sprintf(" expected: %s", checksumVerify))
cli.Dim(fmt.Sprintf(" got: %s", hash))
cli.Error(core.Sprintf("Checksum mismatch: %s", core.PathBase(path)))
cli.Dim(core.Sprintf(" expected: %s", checksumVerify))
cli.Dim(core.Sprintf(" got: %s", hash))
return cli.Err("checksum verification failed")
}
@ -56,6 +54,6 @@ func runChecksum(path string) error {
algo = "SHA-512"
}
fmt.Printf("%s %s (%s)\n", hash, path, algo)
core.Print(nil, "%s %s (%s)", hash, path, algo)
return nil
}

View file

@ -1,9 +1,7 @@
package crypt
import (
"fmt"
"strings"
core "dappco.re/go/core"
"dappco.re/go/core/crypt/crypt"
coreio "dappco.re/go/core/io"
"forge.lthn.ai/core/cli/pkg/cli"
@ -74,7 +72,7 @@ func runEncrypt(path string) error {
return cli.Wrap(err, "failed to write encrypted file")
}
cli.Success(fmt.Sprintf("Encrypted %s -> %s", path, outPath))
cli.Success(core.Sprintf("Encrypted %s -> %s", path, outPath))
return nil
}
@ -103,7 +101,7 @@ func runDecrypt(path string) error {
return cli.Wrap(err, "failed to decrypt")
}
outPath := strings.TrimSuffix(path, ".enc")
outPath := core.TrimSuffix(path, ".enc")
if outPath == path {
outPath = path + ".dec"
}
@ -112,6 +110,6 @@ func runDecrypt(path string) error {
return cli.Wrap(err, "failed to write decrypted file")
}
cli.Success(fmt.Sprintf("Decrypted %s -> %s", path, outPath))
cli.Success(core.Sprintf("Decrypted %s -> %s", path, outPath))
return nil
}

View file

@ -1,8 +1,7 @@
package crypt
import (
"fmt"
core "dappco.re/go/core"
"dappco.re/go/core/crypt/crypt"
"forge.lthn.ai/core/cli/pkg/cli"
@ -39,7 +38,7 @@ func runHash(input string) error {
if err != nil {
return cli.Wrap(err, "failed to hash password")
}
fmt.Println(hash)
core.Println(hash)
return nil
}
@ -47,7 +46,7 @@ func runHash(input string) error {
if err != nil {
return cli.Wrap(err, "failed to hash password")
}
fmt.Println(hash)
core.Println(hash)
return nil
}

View file

@ -4,8 +4,8 @@ import (
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
core "dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
)
@ -43,12 +43,12 @@ func runKeygen() error {
switch {
case keygenHex:
fmt.Println(hex.EncodeToString(key))
core.Println(hex.EncodeToString(key))
case keygenBase64:
fmt.Println(base64.StdEncoding.EncodeToString(key))
core.Println(base64.StdEncoding.EncodeToString(key))
default:
// Default to hex output
fmt.Println(hex.EncodeToString(key))
core.Println(hex.EncodeToString(key))
}
return nil

View file

@ -20,7 +20,7 @@ var (
testCovLowStyle = cli.NewStyle().Foreground(cli.ColourRed500)
)
// Flag variables for test command
// Flag variables for test command.
var (
testVerbose bool
testCoverage bool
@ -31,10 +31,15 @@ var (
testJSON bool
)
// testCmd wraps `go test`, defaulting to `./...` and keeping coverage enabled
// so both human-readable and JSON summaries can report package coverage.
var testCmd = &cli.Command{
Use: "test",
Short: i18n.T("cmd.test.short"),
Long: i18n.T("cmd.test.long"),
Example: ` core test
core test --pkg ./auth --run TestLogin_Good
core test --race --json`,
RunE: func(cmd *cli.Command, args []string) error {
return runTest(testVerbose, testCoverage, testShort, testPkg, testRun, testRace, testJSON)
},
@ -51,6 +56,7 @@ func initTestFlags() {
}
// AddTestCommands registers the 'test' command and all subcommands.
// Usage: call AddTestCommands(...) during the package's normal workflow.
func AddTestCommands(root *cli.Command) {
initTestFlags()
root.AddCommand(testCmd)

View file

@ -3,13 +3,11 @@ package testcmd
import (
"bufio"
"cmp"
"fmt"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
core "dappco.re/go/core"
"dappco.re/go/core/i18n"
)
@ -40,7 +38,7 @@ func parseTestOutput(output string) testResults {
skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`)
coverPattern := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
scanner := bufio.NewScanner(strings.NewReader(output))
scanner := bufio.NewScanner(core.NewReader(output))
for scanner.Scan() {
line := scanner.Text()
@ -85,21 +83,32 @@ func printTestSummary(results testResults, showCoverage bool) {
// Print pass/fail summary
total := results.passed + results.failed
if total > 0 {
fmt.Printf(" %s %s", testPassStyle.Render("✓"), i18n.T("i18n.count.passed", results.passed))
line := core.NewBuilder()
line.WriteString(" ")
line.WriteString(testPassStyle.Render("✓"))
line.WriteString(" ")
line.WriteString(i18n.T("i18n.count.passed", results.passed))
if results.failed > 0 {
fmt.Printf(" %s %s", testFailStyle.Render("✗"), i18n.T("i18n.count.failed", results.failed))
line.WriteString(" ")
line.WriteString(testFailStyle.Render("✗"))
line.WriteString(" ")
line.WriteString(i18n.T("i18n.count.failed", results.failed))
}
if results.skipped > 0 {
fmt.Printf(" %s %s", testSkipStyle.Render("○"), i18n.T("i18n.count.skipped", results.skipped))
line.WriteString(" ")
line.WriteString(testSkipStyle.Render("○"))
line.WriteString(" ")
line.WriteString(i18n.T("i18n.count.skipped", results.skipped))
}
fmt.Println()
core.Println(line.String())
}
// Print failed packages
if len(results.failedPkgs) > 0 {
fmt.Printf("\n %s\n", i18n.T("cmd.test.failed_packages"))
core.Println()
core.Println(" " + i18n.T("cmd.test.failed_packages"))
for _, pkg := range results.failedPkgs {
fmt.Printf(" %s %s\n", testFailStyle.Render("✗"), pkg)
core.Println(core.Sprintf(" %s %s", testFailStyle.Render("✗"), pkg))
}
}
@ -108,7 +117,8 @@ func printTestSummary(results testResults, showCoverage bool) {
printCoverageSummary(results)
} else if results.covCount > 0 {
avgCov := results.totalCov / float64(results.covCount)
fmt.Printf("\n %s %s\n", i18n.Label("coverage"), formatCoverage(avgCov))
core.Println()
core.Println(core.Sprintf(" %s %s", i18n.Label("coverage"), formatCoverage(avgCov)))
}
}
@ -117,7 +127,8 @@ func printCoverageSummary(results testResults) {
return
}
fmt.Printf("\n %s\n", testHeaderStyle.Render(i18n.T("cmd.test.coverage_by_package")))
core.Println()
core.Println(" " + testHeaderStyle.Render(i18n.T("cmd.test.coverage_by_package")))
// Sort packages by name
slices.SortFunc(results.packages, func(a, b packageCoverage) int {
@ -143,8 +154,8 @@ func printCoverageSummary(results testResults) {
if padLen < 0 {
padLen = 2
}
padding := strings.Repeat(" ", padLen)
fmt.Printf(" %s%s%s\n", name, padding, formatCoverage(pkg.coverage))
padding := repeatString(" ", padLen)
core.Println(core.Sprintf(" %s%s%s", name, padding, formatCoverage(pkg.coverage)))
}
// Print average
@ -155,13 +166,14 @@ func printCoverageSummary(results testResults) {
if padLen < 0 {
padLen = 2
}
padding := strings.Repeat(" ", padLen)
fmt.Printf("\n %s%s%s\n", testHeaderStyle.Render(avgLabel), padding, formatCoverage(avgCov))
padding := repeatString(" ", padLen)
core.Println()
core.Println(core.Sprintf(" %s%s%s", testHeaderStyle.Render(avgLabel), padding, formatCoverage(avgCov)))
}
}
func formatCoverage(cov float64) string {
s := fmt.Sprintf("%.1f%%", cov)
s := core.Sprintf("%.1f%%", cov)
if cov >= 80 {
return testCovHighStyle.Render(s)
} else if cov >= 50 {
@ -172,41 +184,47 @@ func formatCoverage(cov float64) string {
func shortenPackageName(name string) string {
const modulePrefix = "dappco.re/go/"
if strings.HasPrefix(name, modulePrefix) {
remainder := strings.TrimPrefix(name, modulePrefix)
// If there's a sub-path (e.g. "go/pkg/foo"), strip the module name
if idx := strings.Index(remainder, "/"); idx >= 0 {
return remainder[idx+1:]
if core.HasPrefix(name, modulePrefix) {
remainder := core.TrimPrefix(name, modulePrefix)
parts := core.SplitN(remainder, "/", 2)
if len(parts) == 2 {
return parts[1]
}
// Module root (e.g. "cli-php") — return as-is
return remainder
}
return filepath.Base(name)
return core.PathBase(name)
}
func printJSONResults(results testResults, exitCode int) {
// Simple JSON output for agents
fmt.Printf("{\n")
fmt.Printf(" \"passed\": %d,\n", results.passed)
fmt.Printf(" \"failed\": %d,\n", results.failed)
fmt.Printf(" \"skipped\": %d,\n", results.skipped)
payload := struct {
Passed int `json:"passed"`
Failed int `json:"failed"`
Skipped int `json:"skipped"`
Coverage float64 `json:"coverage,omitempty"`
ExitCode int `json:"exit_code"`
FailedPackages []string `json:"failed_packages"`
}{
Passed: results.passed,
Failed: results.failed,
Skipped: results.skipped,
ExitCode: exitCode,
FailedPackages: results.failedPkgs,
}
if results.covCount > 0 {
avgCov := results.totalCov / float64(results.covCount)
fmt.Printf(" \"coverage\": %.1f,\n", avgCov)
payload.Coverage = results.totalCov / float64(results.covCount)
}
fmt.Printf(" \"exit_code\": %d,\n", exitCode)
if len(results.failedPkgs) > 0 {
fmt.Printf(" \"failed_packages\": [\n")
for i, pkg := range results.failedPkgs {
comma := ","
if i == len(results.failedPkgs)-1 {
comma = ""
}
fmt.Printf(" %q%s\n", pkg, comma)
}
fmt.Printf(" ]\n")
} else {
fmt.Printf(" \"failed_packages\": []\n")
}
fmt.Printf("}\n")
core.Println(core.JSONMarshalString(payload))
}
func repeatString(part string, count int) string {
if count <= 0 {
return ""
}
builder := core.NewBuilder()
for range count {
builder.WriteString(part)
}
return builder.String()
}

View file

@ -2,20 +2,31 @@ package testcmd
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"context"
"runtime"
"strings"
"sync"
core "dappco.re/go/core"
"dappco.re/go/core/i18n"
coreerr "dappco.re/go/core/log"
"dappco.re/go/core/process"
)
var (
processInitOnce sync.Once
processInitErr error
)
func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error {
processInitOnce.Do(func() {
processInitErr = process.Init(core.New())
})
if processInitErr != nil {
return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), processInitErr)
}
// Detect if we're in a Go project
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
if !(&core.Fs{}).New("/").Exists("go.mod") {
return coreerr.E("cmd.test", i18n.T("cmd.test.error.no_go_mod"), nil)
}
@ -47,45 +58,32 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
// Add package pattern
args = append(args, pkg)
// Create command
cmd := exec.Command("go", args...)
cmd.Dir, _ = os.Getwd()
// Set environment to suppress macOS linker warnings
cmd.Env = append(os.Environ(), getMacOSDeploymentTarget())
if !jsonOutput {
fmt.Printf("%s %s\n", testHeaderStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests"))
fmt.Printf(" %s %s\n", i18n.Label("package"), testDimStyle.Render(pkg))
core.Println(core.Sprintf("%s %s", testHeaderStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests")))
core.Println(core.Sprintf(" %s %s", i18n.Label("package"), testDimStyle.Render(pkg)))
if run != "" {
fmt.Printf(" %s %s\n", i18n.Label("filter"), testDimStyle.Render(run))
core.Println(core.Sprintf(" %s %s", i18n.Label("filter"), testDimStyle.Render(run)))
}
fmt.Println()
core.Println()
}
// Capture output for parsing
var stdout, stderr strings.Builder
if verbose && !jsonOutput {
// Stream output in verbose mode, but also capture for parsing
cmd.Stdout = io.MultiWriter(os.Stdout, &stdout)
cmd.Stderr = io.MultiWriter(os.Stderr, &stderr)
} else {
// Capture output for parsing
cmd.Stdout = &stdout
cmd.Stderr = &stderr
options := process.RunOptions{
Command: "go",
Args: args,
Dir: core.Env("DIR_CWD"),
}
if target := getMacOSDeploymentTarget(); target != "" {
options.Env = []string{target}
}
err := cmd.Run()
exitCode := 0
proc, err := process.StartWithOptions(context.Background(), options)
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
exitCode = exitErr.ExitCode()
}
return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), err)
}
// Combine stdout and stderr for parsing, filtering linker warnings
combined := filterLinkerWarnings(stdout.String() + "\n" + stderr.String())
waitErr := proc.Wait()
exitCode := proc.ExitCode
combined := filterLinkerWarnings(proc.Output())
// Parse results
results := parseTestOutput(combined)
@ -104,16 +102,23 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
printTestSummary(results, coverage)
} else if coverage {
// In verbose mode, still show coverage summary at end
fmt.Println()
if combined != "" {
core.Println(combined)
}
core.Println()
printCoverageSummary(results)
} else if combined != "" {
core.Println(combined)
}
if exitCode != 0 {
fmt.Printf("\n%s %s\n", testFailStyle.Render(i18n.T("cli.fail")), i18n.T("cmd.test.tests_failed"))
return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), nil)
core.Println()
core.Println(core.Sprintf("%s %s", testFailStyle.Render(i18n.T("cli.fail")), i18n.T("cmd.test.tests_failed")))
return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), waitErr)
}
fmt.Printf("\n%s %s\n", testPassStyle.Render(i18n.T("cli.pass")), i18n.T("common.result.all_passed"))
core.Println()
core.Println(core.Sprintf("%s %s", testPassStyle.Render(i18n.T("cli.pass")), i18n.T("common.result.all_passed")))
return nil
}
@ -128,18 +133,18 @@ func getMacOSDeploymentTarget() string {
func filterLinkerWarnings(output string) string {
// Filter out ld: warning lines that pollute the output
var filtered []string
scanner := bufio.NewScanner(strings.NewReader(output))
scanner := bufio.NewScanner(core.NewReader(output))
for scanner.Scan() {
line := scanner.Text()
// Skip linker warnings
if strings.HasPrefix(line, "ld: warning:") {
if core.HasPrefix(line, "ld: warning:") {
continue
}
// Skip test binary build comments
if strings.HasPrefix(line, "# ") && strings.HasSuffix(line, ".test") {
if core.HasPrefix(line, "# ") && core.HasSuffix(line, ".test") {
continue
}
filtered = append(filtered, line)
}
return strings.Join(filtered, "\n")
return core.Join("\n", filtered...)
}

View file

@ -6,19 +6,19 @@ import (
"github.com/stretchr/testify/assert"
)
func TestShortenPackageName(t *testing.T) {
func TestOutput_ShortenPackageName_Good(t *testing.T) {
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("example.com/org/cli-php"))
assert.Equal(t, "bar", shortenPackageName("github.com/other/bar"))
}
func TestFormatCoverageTest(t *testing.T) {
func TestOutput_FormatCoverage_Good(t *testing.T) {
assert.Contains(t, formatCoverage(85.0), "85.0%")
assert.Contains(t, formatCoverage(65.0), "65.0%")
assert.Contains(t, formatCoverage(25.0), "25.0%")
}
func TestParseTestOutput(t *testing.T) {
func TestOutput_ParseTestOutput_Good(t *testing.T) {
output := `ok dappco.re/go/core/pkg/foo 0.100s coverage: 50.0% of statements
FAIL dappco.re/go/core/pkg/bar
? dappco.re/go/core/pkg/baz [no test files]
@ -33,7 +33,7 @@ FAIL dappco.re/go/core/pkg/bar
assert.Equal(t, 50.0, results.packages[0].coverage)
}
func TestPrintCoverageSummarySafe(t *testing.T) {
func TestOutput_PrintCoverageSummaryLongPackageNames_Good(t *testing.T) {
// This tests the bug fix for long package names causing negative Repeat count
results := testResults{
packages: []packageCoverage{

View file

@ -2,15 +2,16 @@ package chachapoly
import (
"crypto/rand"
"fmt"
"io"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/chacha20poly1305"
)
// Encrypt encrypts data using ChaCha20-Poly1305.
// Usage: call Encrypt(...) during the package's normal workflow.
func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
@ -26,6 +27,7 @@ func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
}
// Decrypt decrypts data using ChaCha20-Poly1305.
// Usage: call Decrypt(...) during the package's normal workflow.
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
@ -34,7 +36,7 @@ func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
minLen := aead.NonceSize() + aead.Overhead()
if len(ciphertext) < minLen {
return nil, coreerr.E("chachapoly.Decrypt", fmt.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil)
return nil, coreerr.E("chachapoly.Decrypt", core.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil)
}
nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():]

View file

@ -2,9 +2,9 @@ package chachapoly
import (
"crypto/rand"
"errors"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
@ -12,10 +12,10 @@ import (
type mockReader struct{}
func (r *mockReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
return 0, core.NewError("read error")
}
func TestEncryptDecrypt(t *testing.T) {
func TestChachapoly_EncryptDecrypt_Good(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = 1
@ -31,14 +31,14 @@ func TestEncryptDecrypt(t *testing.T) {
assert.Equal(t, plaintext, decrypted)
}
func TestEncryptInvalidKeySize(t *testing.T) {
func TestChachapoly_EncryptInvalidKeySize_Bad(t *testing.T) {
key := make([]byte, 16) // Wrong size
plaintext := []byte("test")
_, err := Encrypt(plaintext, key)
assert.Error(t, err)
}
func TestDecryptWithWrongKey(t *testing.T) {
func TestChachapoly_DecryptWrongKey_Bad(t *testing.T) {
key1 := make([]byte, 32)
key2 := make([]byte, 32)
key2[0] = 1 // Different key
@ -51,7 +51,7 @@ func TestDecryptWithWrongKey(t *testing.T) {
assert.Error(t, err) // Should fail authentication
}
func TestDecryptTamperedCiphertext(t *testing.T) {
func TestChachapoly_DecryptTamperedCiphertext_Bad(t *testing.T) {
key := make([]byte, 32)
plaintext := []byte("secret")
ciphertext, err := Encrypt(plaintext, key)
@ -64,7 +64,7 @@ func TestDecryptTamperedCiphertext(t *testing.T) {
assert.Error(t, err)
}
func TestEncryptEmptyPlaintext(t *testing.T) {
func TestChachapoly_EncryptEmptyPlaintext_Good(t *testing.T) {
key := make([]byte, 32)
plaintext := []byte("")
ciphertext, err := Encrypt(plaintext, key)
@ -76,7 +76,7 @@ func TestEncryptEmptyPlaintext(t *testing.T) {
assert.Equal(t, plaintext, decrypted)
}
func TestDecryptShortCiphertext(t *testing.T) {
func TestChachapoly_DecryptShortCiphertext_Bad(t *testing.T) {
key := make([]byte, 32)
shortCiphertext := []byte("short")
@ -85,7 +85,7 @@ func TestDecryptShortCiphertext(t *testing.T) {
assert.Contains(t, err.Error(), "too short")
}
func TestCiphertextDiffersFromPlaintext(t *testing.T) {
func TestChachapoly_CiphertextDiffersFromPlaintext_Good(t *testing.T) {
key := make([]byte, 32)
plaintext := []byte("Hello, world!")
ciphertext, err := Encrypt(plaintext, key)
@ -93,7 +93,7 @@ func TestCiphertextDiffersFromPlaintext(t *testing.T) {
assert.NotEqual(t, plaintext, ciphertext)
}
func TestEncryptNonceError(t *testing.T) {
func TestChachapoly_EncryptNonceError_Bad(t *testing.T) {
key := make([]byte, 32)
plaintext := []byte("test")
@ -106,7 +106,7 @@ func TestEncryptNonceError(t *testing.T) {
assert.Error(t, err)
}
func TestDecryptInvalidKeySize(t *testing.T) {
func TestChachapoly_DecryptInvalidKeySize_Bad(t *testing.T) {
key := make([]byte, 16) // Wrong size
ciphertext := []byte("test")
_, err := Decrypt(ciphertext, key)

View file

@ -5,17 +5,20 @@ import (
"crypto/sha512"
"encoding/hex"
"io"
"os"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// SHA256File computes the SHA-256 checksum of a file and returns it as a hex string.
// Usage: call SHA256File(...) during the package's normal workflow.
func SHA256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
openResult := (&core.Fs{}).New("/").Open(path)
if !openResult.OK {
err, _ := openResult.Value.(error)
return "", coreerr.E("crypt.SHA256File", "failed to open file", err)
}
f := openResult.Value.(io.ReadCloser)
defer func() { _ = f.Close() }()
h := sha256.New()
@ -27,11 +30,14 @@ func SHA256File(path string) (string, error) {
}
// SHA512File computes the SHA-512 checksum of a file and returns it as a hex string.
// Usage: call SHA512File(...) during the package's normal workflow.
func SHA512File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
openResult := (&core.Fs{}).New("/").Open(path)
if !openResult.OK {
err, _ := openResult.Value.(error)
return "", coreerr.E("crypt.SHA512File", "failed to open file", err)
}
f := openResult.Value.(io.ReadCloser)
defer func() { _ = f.Close() }()
h := sha512.New()
@ -43,12 +49,14 @@ func SHA512File(path string) (string, error) {
}
// SHA256Sum computes the SHA-256 checksum of data and returns it as a hex string.
// Usage: call SHA256Sum(...) during the package's normal workflow.
func SHA256Sum(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// SHA512Sum computes the SHA-512 checksum of data and returns it as a hex string.
// Usage: call SHA512Sum(...) during the package's normal workflow.
func SHA512Sum(data []byte) string {
h := sha512.Sum512(data)
return hex.EncodeToString(h[:])

View file

@ -1,15 +1,14 @@
package crypt
import (
"os"
"path/filepath"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSHA256Sum_Good(t *testing.T) {
func TestChecksum_SHA256Sum_Good(t *testing.T) {
data := []byte("hello")
expected := "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
@ -17,7 +16,7 @@ func TestSHA256Sum_Good(t *testing.T) {
assert.Equal(t, expected, result)
}
func TestSHA512Sum_Good(t *testing.T) {
func TestChecksum_SHA512Sum_Good(t *testing.T) {
data := []byte("hello")
expected := "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043"
@ -27,12 +26,12 @@ func TestSHA512Sum_Good(t *testing.T) {
// --- Phase 0 Additions ---
// TestSHA256FileEmpty_Good verifies checksum of an empty file.
func TestSHA256FileEmpty_Good(t *testing.T) {
// TestChecksum_SHA256FileEmpty_Good verifies checksum of an empty file.
func TestChecksum_SHA256FileEmpty_Good(t *testing.T) {
tmpDir := t.TempDir()
emptyFile := filepath.Join(tmpDir, "empty.bin")
err := os.WriteFile(emptyFile, []byte{}, 0o644)
require.NoError(t, err)
emptyFile := core.Path(tmpDir, "empty.bin")
writeResult := (&core.Fs{}).New("/").WriteMode(emptyFile, "", 0o644)
require.Truef(t, writeResult.OK, "failed to write empty test file: %v", writeResult.Value)
hash, err := SHA256File(emptyFile)
require.NoError(t, err)
@ -40,38 +39,38 @@ func TestSHA256FileEmpty_Good(t *testing.T) {
assert.Equal(t, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", hash)
}
// TestSHA512FileEmpty_Good verifies SHA-512 checksum of an empty file.
func TestSHA512FileEmpty_Good(t *testing.T) {
// TestChecksum_SHA512FileEmpty_Good verifies SHA-512 checksum of an empty file.
func TestChecksum_SHA512FileEmpty_Good(t *testing.T) {
tmpDir := t.TempDir()
emptyFile := filepath.Join(tmpDir, "empty.bin")
err := os.WriteFile(emptyFile, []byte{}, 0o644)
require.NoError(t, err)
emptyFile := core.Path(tmpDir, "empty.bin")
writeResult := (&core.Fs{}).New("/").WriteMode(emptyFile, "", 0o644)
require.Truef(t, writeResult.OK, "failed to write empty test file: %v", writeResult.Value)
hash, err := SHA512File(emptyFile)
require.NoError(t, err)
assert.Equal(t, "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e", hash)
}
// TestSHA256FileNonExistent_Bad verifies error on non-existent file.
func TestSHA256FileNonExistent_Bad(t *testing.T) {
// TestChecksum_SHA256FileNonExistent_Bad verifies error on non-existent file.
func TestChecksum_SHA256FileNonExistent_Bad(t *testing.T) {
_, err := SHA256File("/nonexistent/path/to/file.bin")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to open file")
}
// TestSHA512FileNonExistent_Bad verifies error on non-existent file.
func TestSHA512FileNonExistent_Bad(t *testing.T) {
// TestChecksum_SHA512FileNonExistent_Bad verifies error on non-existent file.
func TestChecksum_SHA512FileNonExistent_Bad(t *testing.T) {
_, err := SHA512File("/nonexistent/path/to/file.bin")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to open file")
}
// TestSHA256FileWithContent_Good verifies checksum of a file with known content.
func TestSHA256FileWithContent_Good(t *testing.T) {
// TestChecksum_SHA256FileWithContent_Good verifies checksum of a file with known content.
func TestChecksum_SHA256FileWithContent_Good(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("hello"), 0o644)
require.NoError(t, err)
testFile := core.Path(tmpDir, "test.txt")
writeResult := (&core.Fs{}).New("/").WriteMode(testFile, "hello", 0o644)
require.Truef(t, writeResult.OK, "failed to write checksum fixture: %v", writeResult.Value)
hash, err := SHA256File(testFile)
require.NoError(t, err)

View file

@ -7,6 +7,7 @@ import (
// Encrypt encrypts data with a passphrase using ChaCha20-Poly1305.
// A random salt is generated and prepended to the output.
// Format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
// Usage: call Encrypt(...) during the package's normal workflow.
func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
salt, err := generateSalt(argon2SaltLen)
if err != nil {
@ -29,6 +30,7 @@ func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
// Decrypt decrypts data encrypted with Encrypt.
// Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
// Usage: call Decrypt(...) during the package's normal workflow.
func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
if len(ciphertext) < argon2SaltLen {
return nil, coreerr.E("crypt.Decrypt", "ciphertext too short", nil)
@ -50,6 +52,7 @@ func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
// EncryptAES encrypts data using AES-256-GCM with a passphrase.
// A random salt is generated and prepended to the output.
// Format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
// Usage: call EncryptAES(...) during the package's normal workflow.
func EncryptAES(plaintext, passphrase []byte) ([]byte, error) {
salt, err := generateSalt(argon2SaltLen)
if err != nil {
@ -71,6 +74,7 @@ func EncryptAES(plaintext, passphrase []byte) ([]byte, error) {
// DecryptAES decrypts data encrypted with EncryptAES.
// Expects format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
// Usage: call DecryptAES(...) during the package's normal workflow.
func DecryptAES(ciphertext, passphrase []byte) ([]byte, error) {
if len(ciphertext) < argon2SaltLen {
return nil, coreerr.E("crypt.DecryptAES", "ciphertext too short", nil)

View file

@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestEncryptDecrypt_Good(t *testing.T) {
func TestCrypt_EncryptDecrypt_Good(t *testing.T) {
plaintext := []byte("hello, world!")
passphrase := []byte("correct-horse-battery-staple")
@ -21,7 +21,7 @@ func TestEncryptDecrypt_Good(t *testing.T) {
assert.Equal(t, plaintext, decrypted)
}
func TestEncryptDecrypt_Bad(t *testing.T) {
func TestCrypt_EncryptDecrypt_Bad(t *testing.T) {
plaintext := []byte("secret data")
passphrase := []byte("correct-passphrase")
wrongPassphrase := []byte("wrong-passphrase")
@ -33,7 +33,7 @@ func TestEncryptDecrypt_Bad(t *testing.T) {
assert.Error(t, err)
}
func TestEncryptDecryptAES_Good(t *testing.T) {
func TestCrypt_EncryptDecryptAES_Good(t *testing.T) {
plaintext := []byte("hello, AES world!")
passphrase := []byte("my-secure-passphrase")
@ -48,8 +48,8 @@ func TestEncryptDecryptAES_Good(t *testing.T) {
// --- Phase 0 Additions ---
// TestWrongPassphraseDecrypt_Bad verifies wrong passphrase returns error, not corrupt data.
func TestWrongPassphraseDecrypt_Bad(t *testing.T) {
// TestCrypt_WrongPassphraseDecrypt_Bad verifies wrong passphrase returns error, not corrupt data.
func TestCrypt_WrongPassphraseDecrypt_Bad(t *testing.T) {
plaintext := []byte("sensitive payload")
passphrase := []byte("correct-passphrase")
wrongPassphrase := []byte("wrong-passphrase")
@ -70,8 +70,8 @@ func TestWrongPassphraseDecrypt_Bad(t *testing.T) {
assert.Nil(t, decryptedAES, "wrong passphrase must not return partial data (AES)")
}
// TestEmptyPlaintextRoundTrip_Good verifies encrypt/decrypt of empty plaintext.
func TestEmptyPlaintextRoundTrip_Good(t *testing.T) {
// TestCrypt_EmptyPlaintextRoundTrip_Good verifies encrypt/decrypt of empty plaintext.
func TestCrypt_EmptyPlaintextRoundTrip_Good(t *testing.T) {
passphrase := []byte("test-passphrase")
// ChaCha20
@ -93,8 +93,8 @@ func TestEmptyPlaintextRoundTrip_Good(t *testing.T) {
assert.Empty(t, decryptedAES)
}
// TestLargePlaintextRoundTrip_Good verifies encrypt/decrypt of a 1MB payload.
func TestLargePlaintextRoundTrip_Good(t *testing.T) {
// TestCrypt_LargePlaintextRoundTrip_Good verifies encrypt/decrypt of a 1MB payload.
func TestCrypt_LargePlaintextRoundTrip_Good(t *testing.T) {
passphrase := []byte("large-payload-passphrase")
plaintext := bytes.Repeat([]byte("X"), 1024*1024) // 1MB
@ -116,8 +116,8 @@ func TestLargePlaintextRoundTrip_Good(t *testing.T) {
assert.Equal(t, plaintext, decryptedAES)
}
// TestDecryptCiphertextTooShort_Ugly verifies short ciphertext is rejected.
func TestDecryptCiphertextTooShort_Ugly(t *testing.T) {
// TestCrypt_DecryptCiphertextTooShort_Ugly verifies short ciphertext is rejected.
func TestCrypt_DecryptCiphertextTooShort_Ugly(t *testing.T) {
_, err := Decrypt([]byte("short"), []byte("pass"))
assert.Error(t, err)
assert.Contains(t, err.Error(), "too short")

View file

@ -3,9 +3,9 @@ package crypt
import (
"crypto/subtle"
"encoding/base64"
"fmt"
"strings"
"strconv"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/argon2"
@ -14,6 +14,7 @@ import (
// HashPassword hashes a password using Argon2id with default parameters.
// Returns a string in the format: $argon2id$v=19$m=65536,t=3,p=4$<base64salt>$<base64hash>
// Usage: call HashPassword(...) during the package's normal workflow.
func HashPassword(password string) (string, error) {
salt, err := generateSalt(argon2SaltLen)
if err != nil {
@ -25,7 +26,7 @@ func HashPassword(password string) (string, error) {
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
encoded := core.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argon2Memory, argon2Time, argon2Parallelism,
b64Salt, b64Hash)
@ -34,21 +35,23 @@ func HashPassword(password string) (string, error) {
// VerifyPassword verifies a password against an Argon2id hash string.
// The hash must be in the format produced by HashPassword.
// Usage: call VerifyPassword(...) during the package's normal workflow.
func VerifyPassword(password, hash string) (bool, error) {
parts := strings.Split(hash, "$")
parts := core.Split(hash, "$")
if len(parts) != 6 {
return false, coreerr.E("crypt.VerifyPassword", "invalid hash format", nil)
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
version, err := parsePrefixedInt(parts[2], "v=")
if err != nil {
return false, coreerr.E("crypt.VerifyPassword", "failed to parse version", err)
}
if version != argon2.Version {
return false, coreerr.E("crypt.VerifyPassword", core.Sprintf("unsupported argon2 version %d", version), nil)
}
var memory uint32
var time uint32
var parallelism uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &parallelism); err != nil {
memory, time, parallelism, err := parseArgonParams(parts[3])
if err != nil {
return false, coreerr.E("crypt.VerifyPassword", "failed to parse parameters", err)
}
@ -67,8 +70,55 @@ func VerifyPassword(password, hash string) (bool, error) {
return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1, nil
}
func parseArgonParams(input string) (uint32, uint32, uint8, error) {
fields := core.Split(input, ",")
if len(fields) != 3 {
return 0, 0, 0, core.NewError("invalid argon2 parameters")
}
memory, err := parsePrefixedUint32(fields[0], "m=")
if err != nil {
return 0, 0, 0, err
}
time, err := parsePrefixedUint32(fields[1], "t=")
if err != nil {
return 0, 0, 0, err
}
parallelismValue, err := parsePrefixedUint32(fields[2], "p=")
if err != nil {
return 0, 0, 0, err
}
return memory, time, uint8(parallelismValue), nil
}
func parsePrefixedInt(input, prefix string) (int, error) {
if !core.HasPrefix(input, prefix) {
return 0, core.NewError(core.Sprintf("missing %q prefix", prefix))
}
value, err := strconv.Atoi(core.TrimPrefix(input, prefix))
if err != nil {
return 0, err
}
return value, nil
}
func parsePrefixedUint32(input, prefix string) (uint32, error) {
if !core.HasPrefix(input, prefix) {
return 0, core.NewError(core.Sprintf("missing %q prefix", prefix))
}
value, err := strconv.ParseUint(core.TrimPrefix(input, prefix), 10, 32)
if err != nil {
return 0, err
}
return uint32(value), nil
}
// HashBcrypt hashes a password using bcrypt with the given cost.
// Cost must be between bcrypt.MinCost and bcrypt.MaxCost.
// Usage: call HashBcrypt(...) during the package's normal workflow.
func HashBcrypt(password string, cost int) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
if err != nil {
@ -78,6 +128,7 @@ func HashBcrypt(password string, cost int) (string, error) {
}
// VerifyBcrypt verifies a password against a bcrypt hash.
// Usage: call VerifyBcrypt(...) during the package's normal workflow.
func VerifyBcrypt(password, hash string) (bool, error) {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
if err == bcrypt.ErrMismatchedHashAndPassword {

View file

@ -7,7 +7,7 @@ import (
"golang.org/x/crypto/bcrypt"
)
func TestHashPassword_Good(t *testing.T) {
func TestHash_HashPassword_Good(t *testing.T) {
password := "my-secure-password"
hash, err := HashPassword(password)
@ -20,7 +20,7 @@ func TestHashPassword_Good(t *testing.T) {
assert.True(t, match)
}
func TestVerifyPassword_Bad(t *testing.T) {
func TestHash_VerifyPassword_Bad(t *testing.T) {
password := "my-secure-password"
wrongPassword := "wrong-password"
@ -32,7 +32,7 @@ func TestVerifyPassword_Bad(t *testing.T) {
assert.False(t, match)
}
func TestHashBcrypt_Good(t *testing.T) {
func TestHash_HashBcrypt_Good(t *testing.T) {
password := "bcrypt-test-password"
hash, err := HashBcrypt(password, bcrypt.DefaultCost)

View file

@ -8,6 +8,7 @@ import (
)
// HMACSHA256 computes the HMAC-SHA256 of a message using the given key.
// Usage: call HMACSHA256(...) during the package's normal workflow.
func HMACSHA256(message, key []byte) []byte {
mac := hmac.New(sha256.New, key)
mac.Write(message)
@ -15,6 +16,7 @@ func HMACSHA256(message, key []byte) []byte {
}
// HMACSHA512 computes the HMAC-SHA512 of a message using the given key.
// Usage: call HMACSHA512(...) during the package's normal workflow.
func HMACSHA512(message, key []byte) []byte {
mac := hmac.New(sha512.New, key)
mac.Write(message)
@ -23,6 +25,7 @@ func HMACSHA512(message, key []byte) []byte {
// VerifyHMAC verifies an HMAC using constant-time comparison.
// hashFunc should be sha256.New, sha512.New, etc.
// Usage: call VerifyHMAC(...) during the package's normal workflow.
func VerifyHMAC(message, key, mac []byte, hashFunc func() hash.Hash) bool {
expected := hmac.New(hashFunc, key)
expected.Write(message)

View file

@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestHMACSHA256_Good(t *testing.T) {
func TestHMAC_HMACSHA256_Good(t *testing.T) {
// RFC 4231 Test Case 2
key := []byte("Jefe")
message := []byte("what do ya want for nothing?")
@ -18,7 +18,7 @@ func TestHMACSHA256_Good(t *testing.T) {
assert.Equal(t, expected, hex.EncodeToString(mac))
}
func TestVerifyHMAC_Good(t *testing.T) {
func TestHMAC_VerifyHMAC_Good(t *testing.T) {
key := []byte("secret-key")
message := []byte("test message")
@ -28,7 +28,7 @@ func TestVerifyHMAC_Good(t *testing.T) {
assert.True(t, valid)
}
func TestVerifyHMAC_Bad(t *testing.T) {
func TestHMAC_VerifyHMAC_Bad(t *testing.T) {
key := []byte("secret-key")
message := []byte("test message")
tampered := []byte("tampered message")

View file

@ -25,12 +25,14 @@ const (
// DeriveKey derives a key from a passphrase using Argon2id with default parameters.
// The salt must be argon2SaltLen bytes. keyLen specifies the desired key length.
// Usage: call DeriveKey(...) during the package's normal workflow.
func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte {
return argon2.IDKey(passphrase, salt, argon2Time, argon2Memory, argon2Parallelism, keyLen)
}
// DeriveKeyScrypt derives a key from a passphrase using scrypt.
// Uses recommended parameters: N=32768, r=8, p=1.
// Usage: call DeriveKeyScrypt(...) during the package's normal workflow.
func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error) {
key, err := scrypt.Key(passphrase, salt, 32768, 8, 1, keyLen)
if err != nil {
@ -42,6 +44,7 @@ func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error) {
// HKDF derives a key using HKDF-SHA256.
// secret is the input keying material, salt is optional (can be nil),
// info is optional context, and keyLen is the desired output length.
// Usage: call HKDF(...) during the package's normal workflow.
func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error) {
reader := hkdf.New(sha256.New, secret, salt, info)
key := make([]byte, keyLen)

View file

@ -6,7 +6,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestDeriveKey_Good(t *testing.T) {
func TestKDF_DeriveKey_Good(t *testing.T) {
passphrase := []byte("test-passphrase")
salt := []byte("1234567890123456") // 16 bytes
@ -21,7 +21,7 @@ func TestDeriveKey_Good(t *testing.T) {
assert.NotEqual(t, key1, key3)
}
func TestDeriveKeyScrypt_Good(t *testing.T) {
func TestKDF_DeriveKeyScrypt_Good(t *testing.T) {
passphrase := []byte("test-passphrase")
salt := []byte("1234567890123456")
@ -35,7 +35,7 @@ func TestDeriveKeyScrypt_Good(t *testing.T) {
assert.Equal(t, key, key2)
}
func TestHKDF_Good(t *testing.T) {
func TestKDF_HKDF_Good(t *testing.T) {
secret := []byte("input-keying-material")
salt := []byte("optional-salt")
info := []byte("context-info")
@ -57,8 +57,8 @@ func TestHKDF_Good(t *testing.T) {
// --- Phase 0 Additions ---
// TestKeyDerivationDeterminism_Good verifies same passphrase + salt always yields same key.
func TestKeyDerivationDeterminism_Good(t *testing.T) {
// TestKDF_KeyDerivationDeterminism_Good verifies same passphrase + salt always yields same key.
func TestKDF_KeyDerivationDeterminism_Good(t *testing.T) {
passphrase := []byte("determinism-test-passphrase")
salt := []byte("1234567890123456") // 16 bytes
@ -82,8 +82,8 @@ func TestKeyDerivationDeterminism_Good(t *testing.T) {
assert.Equal(t, scryptKey1, scryptKey2, "scrypt must also be deterministic")
}
// TestHKDFDifferentInfoStrings_Good verifies different info strings produce different keys.
func TestHKDFDifferentInfoStrings_Good(t *testing.T) {
// TestKDF_HKDFDifferentInfoStrings_Good verifies different info strings produce different keys.
func TestKDF_HKDFDifferentInfoStrings_Good(t *testing.T) {
secret := []byte("shared-secret-material")
salt := []byte("common-salt")
@ -114,8 +114,8 @@ func TestHKDFDifferentInfoStrings_Good(t *testing.T) {
}
}
// TestHKDFNilSalt_Good verifies HKDF works with nil salt.
func TestHKDFNilSalt_Good(t *testing.T) {
// TestKDF_HKDFNilSalt_Good verifies HKDF works with nil salt.
func TestKDF_HKDFNilSalt_Good(t *testing.T) {
secret := []byte("input-keying-material")
info := []byte("context")

View file

@ -42,11 +42,13 @@ var keyMap = map[rune]rune{
// SetKeyMap replaces the default character substitution map.
// Use this to customize the quasi-salt derivation for specific applications.
// Changes affect all subsequent Hash and Verify calls.
// Usage: call SetKeyMap(...) during the package's normal workflow.
func SetKeyMap(newKeyMap map[rune]rune) {
keyMap = newKeyMap
}
// GetKeyMap returns the current character substitution map.
// Usage: call GetKeyMap(...) during the package's normal workflow.
func GetKeyMap() map[rune]rune {
return keyMap
}
@ -61,6 +63,7 @@ func GetKeyMap() map[rune]rune {
//
// The same input always produces the same hash, enabling verification
// without storing a separate salt value.
// Usage: call Hash(...) when you need a deterministic content-style digest rather than a password hash.
func Hash(input string) string {
salt := createSalt(input)
hash := sha256.Sum256([]byte(input + salt))
@ -89,6 +92,7 @@ func createSalt(input string) string {
// Verify checks if an input string produces the given hash.
// Returns true if Hash(input) equals the provided hash value.
// Uses constant-time comparison to prevent timing attacks.
// Usage: call Verify(...) during the package's normal workflow.
func Verify(input string, hash string) bool {
computed := Hash(input)
return subtle.ConstantTimeCompare([]byte(computed), []byte(hash)) == 1

View file

@ -7,32 +7,36 @@ import (
"github.com/stretchr/testify/assert"
)
func TestHash(t *testing.T) {
func TestLTHN_Hash_Good(t *testing.T) {
hash := Hash("hello")
assert.NotEmpty(t, hash)
}
func TestVerify(t *testing.T) {
func TestLTHN_Verify_Good(t *testing.T) {
hash := Hash("hello")
assert.True(t, Verify("hello", hash))
}
func TestLTHN_Verify_Bad(t *testing.T) {
hash := Hash("hello")
assert.False(t, Verify("world", hash))
}
func TestCreateSalt_Good(t *testing.T) {
func TestLTHN_CreateSalt_Good(t *testing.T) {
// "hello" reversed: "olleh" -> "0113h"
expected := "0113h"
actual := createSalt("hello")
assert.Equal(t, expected, actual, "Salt should be correctly created for 'hello'")
}
func TestCreateSalt_Bad(t *testing.T) {
func TestLTHN_CreateSalt_Bad(t *testing.T) {
// Test with an empty string
expected := ""
actual := createSalt("")
assert.Equal(t, expected, actual, "Salt for an empty string should be empty")
}
func TestCreateSalt_Ugly(t *testing.T) {
func TestLTHN_CreateSalt_Ugly(t *testing.T) {
// Test with characters not in the keyMap
input := "world123"
// "world123" reversed: "321dlrow" -> "e2ld1r0w"
@ -50,7 +54,7 @@ func TestCreateSalt_Ugly(t *testing.T) {
var testKeyMapMu sync.Mutex
func TestSetKeyMap(t *testing.T) {
func TestLTHN_SetKeyMap_Good(t *testing.T) {
testKeyMapMu.Lock()
originalKeyMap := GetKeyMap()
t.Cleanup(func() {

View file

@ -4,7 +4,6 @@ import (
"bytes"
"crypto"
goio "io"
"strings"
framework "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
@ -15,17 +14,20 @@ import (
)
// Service provides OpenPGP cryptographic operations.
// Usage: use Service with the other exported helpers in this package.
type Service struct {
core *framework.Core
}
// New creates a new OpenPGP service instance.
// Usage: call New(...) to create a ready-to-use value.
func New(c *framework.Core) (any, error) {
return &Service{core: c}, nil
}
// CreateKeyPair generates a new RSA-4096 PGP keypair.
// Returns the armored private key string.
// Usage: call CreateKeyPair(...) during the package's normal workflow.
func (s *Service) CreateKeyPair(name, passphrase string) (string, error) {
config := &packet.Config{
Algorithm: packet.PubKeyAlgoRSA,
@ -101,8 +103,9 @@ func serializeEntity(w goio.Writer, e *openpgp.Entity) error {
// EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath).
// The encrypted data is written to the provided writer and also returned as an armored string.
// Usage: call EncryptPGP(...) during the package's normal workflow.
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(framework.NewReader(recipientPath))
if err != nil {
return "", coreerr.E("openpgp.EncryptPGP", "failed to read recipient key", err)
}
@ -136,8 +139,9 @@ func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opt
}
// DecryptPGP decrypts a PGP message using the provided armored private key and passphrase.
// Usage: call DecryptPGP(...) during the package's normal workflow.
func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) {
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey))
entityList, err := openpgp.ReadArmoredKeyRing(framework.NewReader(privateKey))
if err != nil {
return "", coreerr.E("openpgp.DecryptPGP", "failed to read private key", err)
}
@ -154,7 +158,7 @@ func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any
}
// Decrypt armored message
block, err := armor.Decode(strings.NewReader(message))
block, err := armor.Decode(framework.NewReader(message))
if err != nil {
return "", coreerr.E("openpgp.DecryptPGP", "failed to decode armored message", err)
}
@ -174,6 +178,7 @@ func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any
}
// HandleIPCEvents handles PGP-related IPC messages.
// Usage: call HandleIPCEvents(...) during the package's normal workflow.
func (s *Service) HandleIPCEvents(c *framework.Core, msg framework.Message) error {
switch m := msg.(type) {
case map[string]any:

View file

@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestCreateKeyPair(t *testing.T) {
func TestService_CreateKeyPair_Good(t *testing.T) {
c := framework.New()
s := &Service{core: c}
@ -19,7 +19,7 @@ func TestCreateKeyPair(t *testing.T) {
assert.Contains(t, privKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
}
func TestEncryptDecrypt(t *testing.T) {
func TestService_EncryptDecrypt_Good(t *testing.T) {
c := framework.New()
s := &Service{core: c}

View file

@ -16,6 +16,7 @@ import (
)
// KeyPair holds armored PGP public and private keys.
// Usage: use KeyPair with the other exported helpers in this package.
type KeyPair struct {
PublicKey string
PrivateKey string
@ -24,6 +25,7 @@ type KeyPair struct {
// CreateKeyPair generates a new PGP key pair for the given identity.
// If password is non-empty, the private key is encrypted with it.
// Returns a KeyPair with armored public and private keys.
// Usage: call CreateKeyPair(...) during the package's normal workflow.
func CreateKeyPair(name, email, password string) (*KeyPair, error) {
const op = "pgp.CreateKeyPair"
@ -116,6 +118,7 @@ func serializeEncryptedEntity(w io.Writer, e *openpgp.Entity) error {
// Encrypt encrypts data for the recipient identified by their armored public key.
// Returns the encrypted data as armored PGP output.
// Usage: call Encrypt(...) during the package's normal workflow.
func Encrypt(data []byte, publicKeyArmor string) ([]byte, error) {
const op = "pgp.Encrypt"
@ -149,6 +152,7 @@ func Encrypt(data []byte, publicKeyArmor string) ([]byte, error) {
// 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.
// Usage: call Decrypt(...) during the package's normal workflow.
func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
const op = "pgp.Decrypt"
@ -193,6 +197,7 @@ func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
// Sign creates an armored detached signature for the given data using
// the armored private key. If the key is encrypted, the password is used
// to decrypt it first.
// Usage: call Sign(...) during the package's normal workflow.
func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) {
const op = "pgp.Sign"
@ -224,6 +229,7 @@ func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) {
// Verify verifies an armored detached signature against the given data
// and armored public key. Returns nil if the signature is valid.
// Usage: call Verify(...) during the package's normal workflow.
func Verify(data, signature []byte, publicKeyArmor string) error {
const op = "pgp.Verify"

View file

@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/require"
)
func TestCreateKeyPair_Good(t *testing.T) {
func TestPGP_CreateKeyPair_Good(t *testing.T) {
kp, err := CreateKeyPair("Test User", "test@example.com", "")
require.NoError(t, err)
require.NotNil(t, kp)
@ -15,7 +15,7 @@ func TestCreateKeyPair_Good(t *testing.T) {
assert.Contains(t, kp.PrivateKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
}
func TestCreateKeyPair_Bad(t *testing.T) {
func TestPGP_CreateKeyPair_Bad(t *testing.T) {
// Empty name still works (openpgp allows it), but test with password
kp, err := CreateKeyPair("Secure User", "secure@example.com", "strong-password")
require.NoError(t, err)
@ -24,14 +24,14 @@ func TestCreateKeyPair_Bad(t *testing.T) {
assert.Contains(t, kp.PrivateKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
}
func TestCreateKeyPair_Ugly(t *testing.T) {
func TestPGP_CreateKeyPair_Ugly(t *testing.T) {
// Minimal identity
kp, err := CreateKeyPair("", "", "")
require.NoError(t, err)
require.NotNil(t, kp)
}
func TestEncryptDecrypt_Good(t *testing.T) {
func TestPGP_EncryptDecrypt_Good(t *testing.T) {
kp, err := CreateKeyPair("Test User", "test@example.com", "")
require.NoError(t, err)
@ -46,7 +46,7 @@ func TestEncryptDecrypt_Good(t *testing.T) {
assert.Equal(t, plaintext, decrypted)
}
func TestEncryptDecrypt_Bad(t *testing.T) {
func TestPGP_EncryptDecrypt_Bad(t *testing.T) {
kp1, err := CreateKeyPair("User One", "one@example.com", "")
require.NoError(t, err)
kp2, err := CreateKeyPair("User Two", "two@example.com", "")
@ -61,7 +61,7 @@ func TestEncryptDecrypt_Bad(t *testing.T) {
assert.Error(t, err)
}
func TestEncryptDecrypt_Ugly(t *testing.T) {
func TestPGP_EncryptDecrypt_Ugly(t *testing.T) {
// Invalid public key for encryption
_, err := Encrypt([]byte("data"), "not-a-pgp-key")
assert.Error(t, err)
@ -71,7 +71,7 @@ func TestEncryptDecrypt_Ugly(t *testing.T) {
assert.Error(t, err)
}
func TestEncryptDecryptWithPassword_Good(t *testing.T) {
func TestPGP_EncryptDecryptWithPassword_Good(t *testing.T) {
password := "my-secret-passphrase"
kp, err := CreateKeyPair("Secure User", "secure@example.com", password)
require.NoError(t, err)
@ -85,7 +85,7 @@ func TestEncryptDecryptWithPassword_Good(t *testing.T) {
assert.Equal(t, plaintext, decrypted)
}
func TestSignVerify_Good(t *testing.T) {
func TestPGP_SignVerify_Good(t *testing.T) {
kp, err := CreateKeyPair("Signer", "signer@example.com", "")
require.NoError(t, err)
@ -99,7 +99,7 @@ func TestSignVerify_Good(t *testing.T) {
assert.NoError(t, err)
}
func TestSignVerify_Bad(t *testing.T) {
func TestPGP_SignVerify_Bad(t *testing.T) {
kp, err := CreateKeyPair("Signer", "signer@example.com", "")
require.NoError(t, err)
@ -112,7 +112,7 @@ func TestSignVerify_Bad(t *testing.T) {
assert.Error(t, err)
}
func TestSignVerify_Ugly(t *testing.T) {
func TestPGP_SignVerify_Ugly(t *testing.T) {
// Invalid key for signing
_, err := Sign([]byte("data"), "not-a-key", "")
assert.Error(t, err)
@ -129,7 +129,7 @@ func TestSignVerify_Ugly(t *testing.T) {
assert.Error(t, err)
}
func TestSignVerifyWithPassword_Good(t *testing.T) {
func TestPGP_SignVerifyWithPassword_Good(t *testing.T) {
password := "signing-password"
kp, err := CreateKeyPair("Signer", "signer@example.com", password)
require.NoError(t, err)
@ -142,7 +142,7 @@ func TestSignVerifyWithPassword_Good(t *testing.T) {
assert.NoError(t, err)
}
func TestFullRoundTrip_Good(t *testing.T) {
func TestPGP_FullRoundTrip_Good(t *testing.T) {
// Generate keys, encrypt, decrypt, sign, and verify - full round trip
kp, err := CreateKeyPair("Full Test", "full@example.com", "")
require.NoError(t, err)

View file

@ -6,25 +6,28 @@ import (
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// Service provides RSA functionality.
// Usage: use Service with the other exported helpers in this package.
type Service struct{}
// NewService creates and returns a new Service instance for performing RSA-related operations.
// Usage: call NewService(...) to create a ready-to-use value.
func NewService() *Service {
return &Service{}
}
// GenerateKeyPair creates a new RSA key pair.
// Usage: call GenerateKeyPair(...) during the package's normal workflow.
func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err error) {
const op = "rsa.GenerateKeyPair"
if bits < 2048 {
return nil, nil, coreerr.E(op, fmt.Sprintf("key size too small: %d (minimum 2048)", bits), nil)
return nil, nil, coreerr.E(op, core.Sprintf("key size too small: %d (minimum 2048)", bits), nil)
}
privKey, err := rsa.GenerateKey(rand.Reader, bits)
if err != nil {
@ -50,6 +53,7 @@ func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err e
}
// Encrypt encrypts data with a public key.
// Usage: call Encrypt(...) during the package's normal workflow.
func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
const op = "rsa.Encrypt"
@ -77,6 +81,7 @@ func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
}
// Decrypt decrypts data with a private key.
// Usage: call Decrypt(...) during the package's normal workflow.
func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error) {
const op = "rsa.Decrypt"

View file

@ -6,9 +6,9 @@ import (
"crypto/rand"
"crypto/x509"
"encoding/pem"
"errors"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
@ -16,10 +16,10 @@ import (
type mockReader struct{}
func (r *mockReader) Read(p []byte) (n int, err error) {
return 0, errors.New("read error")
return 0, core.NewError("read error")
}
func TestRSA_Good(t *testing.T) {
func TestRSA_RSA_Good(t *testing.T) {
s := NewService()
// Generate a new key pair
@ -37,7 +37,7 @@ func TestRSA_Good(t *testing.T) {
assert.Equal(t, message, plaintext)
}
func TestRSA_Bad(t *testing.T) {
func TestRSA_RSA_Bad(t *testing.T) {
s := NewService()
// Decrypt with wrong key
@ -56,7 +56,7 @@ func TestRSA_Bad(t *testing.T) {
assert.Error(t, err)
}
func TestRSA_Ugly(t *testing.T) {
func TestRSA_RSA_Ugly(t *testing.T) {
s := NewService()
// Malformed keys and messages

View file

@ -13,6 +13,7 @@ import (
// ChaCha20Encrypt encrypts plaintext using ChaCha20-Poly1305.
// The key must be 32 bytes. The nonce is randomly generated and prepended
// to the ciphertext.
// Usage: call ChaCha20Encrypt(...) during the package's normal workflow.
func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
@ -30,6 +31,7 @@ func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) {
// ChaCha20Decrypt decrypts ciphertext encrypted with ChaCha20Encrypt.
// The key must be 32 bytes. Expects the nonce prepended to the ciphertext.
// Usage: call ChaCha20Decrypt(...) during the package's normal workflow.
func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key)
if err != nil {
@ -53,6 +55,7 @@ func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) {
// AESGCMEncrypt encrypts plaintext using AES-256-GCM.
// The key must be 32 bytes. The nonce is randomly generated and prepended
// to the ciphertext.
// Usage: call AESGCMEncrypt(...) during the package's normal workflow.
func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
@ -75,6 +78,7 @@ func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) {
// AESGCMDecrypt decrypts ciphertext encrypted with AESGCMEncrypt.
// The key must be 32 bytes. Expects the nonce prepended to the ciphertext.
// Usage: call AESGCMDecrypt(...) during the package's normal workflow.
func AESGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {

View file

@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestChaCha20_Good(t *testing.T) {
func TestSymmetric_ChaCha20_Good(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
assert.NoError(t, err)
@ -23,7 +23,7 @@ func TestChaCha20_Good(t *testing.T) {
assert.Equal(t, plaintext, decrypted)
}
func TestChaCha20_Bad(t *testing.T) {
func TestSymmetric_ChaCha20_Bad(t *testing.T) {
key := make([]byte, 32)
wrongKey := make([]byte, 32)
_, _ = rand.Read(key)
@ -38,7 +38,7 @@ func TestChaCha20_Bad(t *testing.T) {
assert.Error(t, err)
}
func TestAESGCM_Good(t *testing.T) {
func TestSymmetric_AESGCM_Good(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
assert.NoError(t, err)
@ -56,8 +56,8 @@ func TestAESGCM_Good(t *testing.T) {
// --- Phase 0 Additions ---
// TestAESGCM_Bad_WrongKey verifies wrong key returns error, not corrupt data.
func TestAESGCM_Bad_WrongKey(t *testing.T) {
// TestSymmetric_AESGCMWrongKey_Bad verifies wrong key returns error, not corrupt data.
func TestSymmetric_AESGCMWrongKey_Bad(t *testing.T) {
key := make([]byte, 32)
wrongKey := make([]byte, 32)
_, _ = rand.Read(key)
@ -72,8 +72,8 @@ func TestAESGCM_Bad_WrongKey(t *testing.T) {
assert.Nil(t, decrypted, "wrong key must not return partial data")
}
// TestChaCha20EmptyPlaintext_Good verifies empty plaintext round-trip at low level.
func TestChaCha20EmptyPlaintext_Good(t *testing.T) {
// TestSymmetric_ChaCha20EmptyPlaintext_Good verifies empty plaintext round-trip at low level.
func TestSymmetric_ChaCha20EmptyPlaintext_Good(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
assert.NoError(t, err)
@ -87,8 +87,8 @@ func TestChaCha20EmptyPlaintext_Good(t *testing.T) {
assert.Empty(t, decrypted)
}
// TestAESGCMEmptyPlaintext_Good verifies empty plaintext round-trip at low level.
func TestAESGCMEmptyPlaintext_Good(t *testing.T) {
// TestSymmetric_AESGCMEmptyPlaintext_Good verifies empty plaintext round-trip at low level.
func TestSymmetric_AESGCMEmptyPlaintext_Good(t *testing.T) {
key := make([]byte, 32)
_, err := rand.Read(key)
assert.NoError(t, err)
@ -102,8 +102,8 @@ func TestAESGCMEmptyPlaintext_Good(t *testing.T) {
assert.Empty(t, decrypted)
}
// TestChaCha20LargePayload_Good verifies 1MB encrypt/decrypt round-trip.
func TestChaCha20LargePayload_Good(t *testing.T) {
// TestSymmetric_ChaCha20LargePayload_Good verifies 1MB encrypt/decrypt round-trip.
func TestSymmetric_ChaCha20LargePayload_Good(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)
@ -120,8 +120,8 @@ func TestChaCha20LargePayload_Good(t *testing.T) {
assert.Equal(t, plaintext, decrypted)
}
// TestAESGCMLargePayload_Good verifies 1MB encrypt/decrypt round-trip.
func TestAESGCMLargePayload_Good(t *testing.T) {
// TestSymmetric_AESGCMLargePayload_Good verifies 1MB encrypt/decrypt round-trip.
func TestSymmetric_AESGCMLargePayload_Good(t *testing.T) {
key := make([]byte, 32)
_, _ = rand.Read(key)

View file

@ -9,8 +9,8 @@ description: How to build, test, and contribute to go-crypt.
- **Go 1.26** or later (the module declares `go 1.26.0`).
- A Go workspace (`go.work`) that resolves the local dependencies:
`forge.lthn.ai/core/go`, `forge.lthn.ai/core/go-store`,
`forge.lthn.ai/core/go-io`, `forge.lthn.ai/core/go-log`, and
`dappco.re/go/core`, `dappco.re/go/core/store`,
`dappco.re/go/core/io`, `dappco.re/go/core/log`, and
`forge.lthn.ai/core/cli`. If you are working outside the full monorepo,
create a `go.work` at the parent directory pointing to your local
checkouts.
@ -211,16 +211,17 @@ HTTPS authentication is not configured for this repository.
## Local Dependencies
The `go.mod` depends on several `forge.lthn.ai/core/*` modules. These are
The `go.mod` depends on several `dappco.re/go/core/*` modules plus the
remaining `forge.lthn.ai/core/cli` dependency. These are
resolved through the Go workspace (`~/Code/go.work`). Do not modify the
replace directives in `go.mod` directly -- use the workspace file instead.
| Module | Local Path | Purpose |
|--------|-----------|---------|
| `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 |
| `dappco.re/go/core` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
| `dappco.re/go/core/store` | `../go-store` | SQLite KV store for session persistence |
| `dappco.re/go/core/io` | `../go-io` | `io.Medium` storage abstraction |
| `dappco.re/go/core/log` | `../go-log` | `core.E()` contextual error wrapping |
| `forge.lthn.ai/core/cli` | `../cli` | CLI framework for `cmd/crypt` commands |
## Known Limitations

View file

@ -144,10 +144,10 @@ core crypt checksum myfile.txt --verify "abc123..."
| 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()` |
| `dappco.re/go/core` | Framework: `core.E` error helper, `core.Crypt` interface, `io.Medium` storage abstraction |
| `dappco.re/go/core/store` | SQLite KV store for persistent session storage |
| `dappco.re/go/core/io` | `io.Medium` interface used by the auth package |
| `dappco.re/go/core/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 |

6
go.mod
View file

@ -3,12 +3,13 @@ module dappco.re/go/core/crypt
go 1.26.0
require (
dappco.re/go/core v0.5.0
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/i18n v0.2.0
dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0
dappco.re/go/core/process v0.3.0
dappco.re/go/core/store v0.2.0
forge.lthn.ai/core/cli v0.3.7
forge.lthn.ai/core/go-store v0.1.10
github.com/ProtonMail/go-crypto v1.4.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.49.0
@ -48,7 +49,6 @@ require (
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect

10
go.sum
View file

@ -1,11 +1,15 @@
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
dappco.re/go/core/process v0.3.0 h1:BPF9R79+8ZWe34qCIy/sZy+P4HwbaO95js2oPJL7IqM=
dappco.re/go/core/process v0.3.0/go.mod h1:qwx8kt6x+J9gn7fu8lavuess72Ye9jPBODqDZQ9K0as=
dappco.re/go/core/store v0.2.0 h1:MH3R9m3mdr5T3lMWi37ryvTrXzF4xLBTYBGyNZF0p3I=
dappco.re/go/core/store v0.2.0/go.mod h1:QQGJiruayjna3nywbf0N2gcO502q/oEkPoSpBpSKbLM=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
@ -16,8 +20,6 @@ forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWv
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=

521
specs/auth/README.md Normal file
View file

@ -0,0 +1,521 @@
# auth
**Import:** `dappco.re/go/core/crypt/auth`
**Files:** 4
## Types
### `Authenticator`
```go
type Authenticator struct {
medium io.Medium
store SessionStore
hardwareKey HardwareKey // optional hardware key (nil = software only)
challenges map[string]*Challenge // userID -> pending challenge
mu sync.RWMutex // protects challenges map only
challengeTTL time.Duration
sessionTTL time.Duration
}
```
Authenticator manages PGP-based challenge-response authentication.
All user data and keys are persisted through an io.Medium, which may
be backed by disk, memory (MockMedium), or any other storage backend.
Sessions are persisted via a SessionStore (in-memory by default,
optionally SQLite-backed for crash recovery).
An optional HardwareKey can be provided via WithHardwareKey for
hardware-backed cryptographic operations (PKCS#11, YubiKey, etc.).
See auth/hardware.go for the interface definition and integration points.
Usage: create an Authenticator with New(...) and then call Register, Login, or CreateChallenge.
#### Methods
##### `CreateChallenge`
```go
func (a *Authenticator) CreateChallenge(userID string) (*Challenge, error)
```
CreateChallenge generates a cryptographic challenge for the given user.
A random nonce is created and encrypted with the user's PGP public key.
The client must decrypt the nonce and sign it to prove key ownership.
Usage: call CreateChallenge(...) during the package's normal workflow.
##### `DeleteUser`
```go
func (a *Authenticator) DeleteUser(userID string) error
```
DeleteUser removes a user and all associated keys from storage.
The "server" user is protected and cannot be deleted (mirroring the
original TypeScript implementation's safeguard).
Usage: call DeleteUser(...) during the package's normal workflow.
##### `IsRevoked`
```go
func (a *Authenticator) IsRevoked(userID string) bool
```
IsRevoked checks whether a user's key has been revoked by inspecting the
.rev file. Returns true only if the file contains valid revocation JSON
(not the legacy "REVOCATION_PLACEHOLDER" string).
Usage: call IsRevoked(...) during the package's normal workflow.
##### `Login`
```go
func (a *Authenticator) Login(userID, password string) (*Session, error)
```
Login performs password-based authentication as a convenience method.
It verifies the password against the stored hash and, on success,
creates a new session. This bypasses the PGP challenge-response flow.
Hash format detection:
- If a .hash file exists, its content starts with "$argon2id$" and is verified
using constant-time Argon2id comparison.
- Otherwise, falls back to legacy .lthn file with LTHN hash verification.
On successful legacy login, the password is re-hashed with Argon2id and
a .hash file is written (transparent migration).
Usage: call Login(...) for password-based flows when challenge-response is not required.
##### `ReadResponseFile`
```go
func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error)
```
ReadResponseFile reads a signed response from a file and validates it,
completing the air-gapped authentication flow. The file must contain the
raw PGP signature bytes (armored).
Usage: call ReadResponseFile(...) during the package's normal workflow.
##### `RefreshSession`
```go
func (a *Authenticator) RefreshSession(token string) (*Session, error)
```
RefreshSession extends the expiry of an existing valid session.
Usage: call RefreshSession(...) during the package's normal workflow.
##### `Register`
```go
func (a *Authenticator) Register(username, password string) (*User, error)
```
Register creates a new user account. It hashes the username with LTHN to
produce a userID, generates a PGP keypair (protected by the given password),
and persists the public key, private key, revocation placeholder, password
hash (Argon2id), and encrypted metadata via the Medium.
Usage: call Register(...) during the package's normal workflow.
##### `RevokeKey`
```go
func (a *Authenticator) RevokeKey(userID, password, reason string) error
```
RevokeKey marks a user's key as revoked. It verifies the password first,
writes a JSON revocation record to the .rev file (replacing the placeholder),
and invalidates all sessions for the user.
Usage: call RevokeKey(...) during the package's normal workflow.
##### `RevokeSession`
```go
func (a *Authenticator) RevokeSession(token string) error
```
RevokeSession removes a session, invalidating the token immediately.
Usage: call RevokeSession(...) during the package's normal workflow.
##### `RotateKeyPair`
```go
func (a *Authenticator) RotateKeyPair(userID, oldPassword, newPassword string) (*User, error)
```
RotateKeyPair generates a new PGP keypair for the given user, re-encrypts
their metadata with the new key, updates the password hash, and invalidates
all existing sessions. The caller must provide the current password
(oldPassword) to decrypt existing metadata and the new password (newPassword)
to protect the new keypair.
Usage: call RotateKeyPair(...) during the package's normal workflow.
##### `StartCleanup`
```go
func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration)
```
StartCleanup runs a background goroutine that periodically removes expired
sessions from the store. It stops when the context is cancelled.
Usage: call StartCleanup(...) during the package's normal workflow.
##### `ValidateResponse`
```go
func (a *Authenticator) ValidateResponse(userID string, signedNonce []byte) (*Session, error)
```
ValidateResponse verifies a signed nonce from the client. The client must
have decrypted the challenge nonce and signed it with their private key.
On success, a new session is created and returned.
Usage: call ValidateResponse(...) during the package's normal workflow.
##### `ValidateSession`
```go
func (a *Authenticator) ValidateSession(token string) (*Session, error)
```
ValidateSession checks whether a token maps to a valid, non-expired session.
Usage: call ValidateSession(...) during the package's normal workflow.
##### `WriteChallengeFile`
```go
func (a *Authenticator) WriteChallengeFile(userID, path string) error
```
WriteChallengeFile writes an encrypted challenge to a file for air-gapped
(courier) transport. The challenge is created and then its encrypted nonce
is written to the specified path on the Medium.
Usage: call WriteChallengeFile(...) during the package's normal workflow.
### `Challenge`
```go
type Challenge struct {
Nonce []byte `json:"nonce"`
Encrypted string `json:"encrypted"` // PGP-encrypted nonce (armored)
ExpiresAt time.Time `json:"expires_at"`
}
```
Challenge is a PGP-encrypted nonce sent to a client during authentication.
Usage: use Challenge with the other exported helpers in this package.
### `HardwareKey`
```go
type HardwareKey interface {
// Sign produces a cryptographic signature over the given data using the
// hardware-stored private key. The signature format depends on the
// underlying device (e.g. ECDSA, RSA-PSS, EdDSA).
Sign(data []byte) ([]byte, error)
// Decrypt decrypts ciphertext using the hardware-stored private key.
// The ciphertext format must match what the device expects (e.g. RSA-OAEP).
Decrypt(ciphertext []byte) ([]byte, error)
// GetPublicKey returns the PEM or armored public key corresponding to the
// hardware-stored private key.
GetPublicKey() (string, error)
// IsAvailable reports whether the hardware key device is currently
// connected and operational. Callers should check this before attempting
// Sign or Decrypt to provide graceful fallback behaviour.
IsAvailable() bool
}
```
HardwareKey defines the contract for hardware-backed cryptographic operations.
Implementations should wrap PKCS#11 tokens, YubiKeys, TPM modules, or
similar tamper-resistant devices.
All methods must be safe for concurrent use.
Usage: implement HardwareKey and pass it to WithHardwareKey(...) to wire hardware-backed auth into New(...).
### `MemorySessionStore`
```go
type MemorySessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
}
```
MemorySessionStore is an in-memory SessionStore backed by a map.
Usage: use MemorySessionStore with the other exported helpers in this package.
#### Methods
##### `Cleanup`
```go
func (m *MemorySessionStore) Cleanup() (int, error)
```
Cleanup removes all expired sessions and returns the count removed.
Usage: call Cleanup(...) during the package's normal workflow.
##### `Delete`
```go
func (m *MemorySessionStore) Delete(token string) error
```
Delete removes a session by token.
Usage: call Delete(...) during the package's normal workflow.
##### `DeleteByUser`
```go
func (m *MemorySessionStore) DeleteByUser(userID string) error
```
DeleteByUser removes all sessions belonging to the given user.
Usage: call DeleteByUser(...) during the package's normal workflow.
##### `Get`
```go
func (m *MemorySessionStore) Get(token string) (*Session, error)
```
Get retrieves a session by token.
Usage: call Get(...) during the package's normal workflow.
##### `Set`
```go
func (m *MemorySessionStore) Set(session *Session) error
```
Set stores a session, keyed by its token.
Usage: call Set(...) during the package's normal workflow.
### `Option`
```go
type Option func(*Authenticator)
```
Option configures an Authenticator.
Usage: use Option with the other exported helpers in this package.
### `Revocation`
```go
type Revocation struct {
UserID string `json:"user_id"`
Reason string `json:"reason"`
RevokedAt time.Time `json:"revoked_at"`
}
```
Revocation records the details of a revoked user key.
Stored as JSON in the user's .rev file, replacing the legacy placeholder.
Usage: use Revocation with the other exported helpers in this package.
### `SQLiteSessionStore`
```go
type SQLiteSessionStore struct {
mu sync.Mutex
store *store.Store
}
```
SQLiteSessionStore is a SessionStore backed by core/store (SQLite KV).
A mutex serialises all operations because SQLite is single-writer.
Usage: use SQLiteSessionStore with the other exported helpers in this package.
#### Methods
##### `Cleanup`
```go
func (s *SQLiteSessionStore) Cleanup() (int, error)
```
Cleanup removes all expired sessions and returns the count removed.
Usage: call Cleanup(...) during the package's normal workflow.
##### `Close`
```go
func (s *SQLiteSessionStore) Close() error
```
Close closes the underlying SQLite store.
Usage: call Close(...) during the package's normal workflow.
##### `Delete`
```go
func (s *SQLiteSessionStore) Delete(token string) error
```
Delete removes a session by token from SQLite.
Usage: call Delete(...) during the package's normal workflow.
##### `DeleteByUser`
```go
func (s *SQLiteSessionStore) DeleteByUser(userID string) error
```
DeleteByUser removes all sessions belonging to the given user.
Usage: call DeleteByUser(...) during the package's normal workflow.
##### `Get`
```go
func (s *SQLiteSessionStore) Get(token string) (*Session, error)
```
Get retrieves a session by token from SQLite.
Usage: call Get(...) during the package's normal workflow.
##### `Set`
```go
func (s *SQLiteSessionStore) Set(session *Session) error
```
Set stores a session in SQLite, keyed by its token.
Usage: call Set(...) during the package's normal workflow.
### `Session`
```go
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
}
```
Session represents an authenticated session.
Usage: use Session with the other exported helpers in this package.
### `SessionStore`
```go
type SessionStore interface {
Get(token string) (*Session, error)
Set(session *Session) error
Delete(token string) error
DeleteByUser(userID string) error
Cleanup() (int, error) // Remove expired sessions, return count removed
}
```
SessionStore abstracts session persistence.
Usage: use SessionStore with the other exported helpers in this package.
### `User`
```go
type User struct {
PublicKey string `json:"public_key"`
KeyID string `json:"key_id"`
Fingerprint string `json:"fingerprint"`
PasswordHash string `json:"password_hash"` // Argon2id (new) or LTHN (legacy)
Created time.Time `json:"created"`
LastLogin time.Time `json:"last_login"`
}
```
User represents a registered user with PGP credentials.
Usage: use User with the other exported helpers in this package.
## Functions
### `New`
```go
func New(m io.Medium, opts ...Option) *Authenticator
```
New creates an Authenticator that persists user data via the given Medium.
By default, sessions are stored in memory. Use WithSessionStore to provide
a persistent implementation (e.g. SQLiteSessionStore).
Usage: call New(...) to create a ready-to-use value.
### `NewMemorySessionStore`
```go
func NewMemorySessionStore() *MemorySessionStore
```
NewMemorySessionStore creates a new in-memory session store.
Usage: call NewMemorySessionStore(...) to create a ready-to-use value.
### `NewSQLiteSessionStore`
```go
func NewSQLiteSessionStore(dbPath string) (*SQLiteSessionStore, error)
```
NewSQLiteSessionStore creates a new SQLite-backed session store.
Use ":memory:" for testing or a file path for persistent storage.
Usage: call NewSQLiteSessionStore(...) to create a ready-to-use value.
### `WithChallengeTTL`
```go
func WithChallengeTTL(d time.Duration) Option
```
WithChallengeTTL sets the lifetime of a challenge before it expires.
Usage: pass WithChallengeTTL(...) into the related constructor to adjust the default behaviour.
### `WithHardwareKey`
```go
func WithHardwareKey(hk HardwareKey) Option
```
WithHardwareKey configures the Authenticator to use a hardware key for
cryptographic operations where supported. When set, the Authenticator may
delegate signing, decryption, and public key retrieval to the hardware
device instead of using software PGP keys.
This is a forward-looking option — integration points are documented in
auth.go but not yet wired up.
Usage: pass WithHardwareKey(...) into New(...) to enable a HardwareKey implementation.
### `WithSessionStore`
```go
func WithSessionStore(s SessionStore) Option
```
WithSessionStore sets the SessionStore implementation.
If not provided, an in-memory store is used (sessions lost on restart).
Usage: pass WithSessionStore(...) into the related constructor to adjust the default behaviour.
### `WithSessionTTL`
```go
func WithSessionTTL(d time.Duration) Option
```
WithSessionTTL sets the lifetime of a session before it expires.
Usage: pass WithSessionTTL(...) into the related constructor to adjust the default behaviour.

521
specs/auth/RFC.md Normal file
View file

@ -0,0 +1,521 @@
# auth
**Import:** `dappco.re/go/core/crypt/auth`
**Files:** 4
## Types
### `Authenticator`
```go
type Authenticator struct {
medium io.Medium
store SessionStore
hardwareKey HardwareKey // optional hardware key (nil = software only)
challenges map[string]*Challenge // userID -> pending challenge
mu sync.RWMutex // protects challenges map only
challengeTTL time.Duration
sessionTTL time.Duration
}
```
Authenticator manages PGP-based challenge-response authentication.
All user data and keys are persisted through an io.Medium, which may
be backed by disk, memory (MockMedium), or any other storage backend.
Sessions are persisted via a SessionStore (in-memory by default,
optionally SQLite-backed for crash recovery).
An optional HardwareKey can be provided via WithHardwareKey for
hardware-backed cryptographic operations (PKCS#11, YubiKey, etc.).
See auth/hardware.go for the interface definition and integration points.
Usage: create an Authenticator with New(...) and then call Register, Login, or CreateChallenge.
#### Methods
##### `CreateChallenge`
```go
func (a *Authenticator) CreateChallenge(userID string) (*Challenge, error)
```
CreateChallenge generates a cryptographic challenge for the given user.
A random nonce is created and encrypted with the user's PGP public key.
The client must decrypt the nonce and sign it to prove key ownership.
Usage: call CreateChallenge(...) during the package's normal workflow.
##### `DeleteUser`
```go
func (a *Authenticator) DeleteUser(userID string) error
```
DeleteUser removes a user and all associated keys from storage.
The "server" user is protected and cannot be deleted (mirroring the
original TypeScript implementation's safeguard).
Usage: call DeleteUser(...) during the package's normal workflow.
##### `IsRevoked`
```go
func (a *Authenticator) IsRevoked(userID string) bool
```
IsRevoked checks whether a user's key has been revoked by inspecting the
.rev file. Returns true only if the file contains valid revocation JSON
(not the legacy "REVOCATION_PLACEHOLDER" string).
Usage: call IsRevoked(...) during the package's normal workflow.
##### `Login`
```go
func (a *Authenticator) Login(userID, password string) (*Session, error)
```
Login performs password-based authentication as a convenience method.
It verifies the password against the stored hash and, on success,
creates a new session. This bypasses the PGP challenge-response flow.
Hash format detection:
- If a .hash file exists, its content starts with "$argon2id$" and is verified
using constant-time Argon2id comparison.
- Otherwise, falls back to legacy .lthn file with LTHN hash verification.
On successful legacy login, the password is re-hashed with Argon2id and
a .hash file is written (transparent migration).
Usage: call Login(...) for password-based flows when challenge-response is not required.
##### `ReadResponseFile`
```go
func (a *Authenticator) ReadResponseFile(userID, path string) (*Session, error)
```
ReadResponseFile reads a signed response from a file and validates it,
completing the air-gapped authentication flow. The file must contain the
raw PGP signature bytes (armored).
Usage: call ReadResponseFile(...) during the package's normal workflow.
##### `RefreshSession`
```go
func (a *Authenticator) RefreshSession(token string) (*Session, error)
```
RefreshSession extends the expiry of an existing valid session.
Usage: call RefreshSession(...) during the package's normal workflow.
##### `Register`
```go
func (a *Authenticator) Register(username, password string) (*User, error)
```
Register creates a new user account. It hashes the username with LTHN to
produce a userID, generates a PGP keypair (protected by the given password),
and persists the public key, private key, revocation placeholder, password
hash (Argon2id), and encrypted metadata via the Medium.
Usage: call Register(...) during the package's normal workflow.
##### `RevokeKey`
```go
func (a *Authenticator) RevokeKey(userID, password, reason string) error
```
RevokeKey marks a user's key as revoked. It verifies the password first,
writes a JSON revocation record to the .rev file (replacing the placeholder),
and invalidates all sessions for the user.
Usage: call RevokeKey(...) during the package's normal workflow.
##### `RevokeSession`
```go
func (a *Authenticator) RevokeSession(token string) error
```
RevokeSession removes a session, invalidating the token immediately.
Usage: call RevokeSession(...) during the package's normal workflow.
##### `RotateKeyPair`
```go
func (a *Authenticator) RotateKeyPair(userID, oldPassword, newPassword string) (*User, error)
```
RotateKeyPair generates a new PGP keypair for the given user, re-encrypts
their metadata with the new key, updates the password hash, and invalidates
all existing sessions. The caller must provide the current password
(oldPassword) to decrypt existing metadata and the new password (newPassword)
to protect the new keypair.
Usage: call RotateKeyPair(...) during the package's normal workflow.
##### `StartCleanup`
```go
func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration)
```
StartCleanup runs a background goroutine that periodically removes expired
sessions from the store. It stops when the context is cancelled.
Usage: call StartCleanup(...) during the package's normal workflow.
##### `ValidateResponse`
```go
func (a *Authenticator) ValidateResponse(userID string, signedNonce []byte) (*Session, error)
```
ValidateResponse verifies a signed nonce from the client. The client must
have decrypted the challenge nonce and signed it with their private key.
On success, a new session is created and returned.
Usage: call ValidateResponse(...) during the package's normal workflow.
##### `ValidateSession`
```go
func (a *Authenticator) ValidateSession(token string) (*Session, error)
```
ValidateSession checks whether a token maps to a valid, non-expired session.
Usage: call ValidateSession(...) during the package's normal workflow.
##### `WriteChallengeFile`
```go
func (a *Authenticator) WriteChallengeFile(userID, path string) error
```
WriteChallengeFile writes an encrypted challenge to a file for air-gapped
(courier) transport. The challenge is created and then its encrypted nonce
is written to the specified path on the Medium.
Usage: call WriteChallengeFile(...) during the package's normal workflow.
### `Challenge`
```go
type Challenge struct {
Nonce []byte `json:"nonce"`
Encrypted string `json:"encrypted"` // PGP-encrypted nonce (armored)
ExpiresAt time.Time `json:"expires_at"`
}
```
Challenge is a PGP-encrypted nonce sent to a client during authentication.
Usage: use Challenge with the other exported helpers in this package.
### `HardwareKey`
```go
type HardwareKey interface {
// Sign produces a cryptographic signature over the given data using the
// hardware-stored private key. The signature format depends on the
// underlying device (e.g. ECDSA, RSA-PSS, EdDSA).
Sign(data []byte) ([]byte, error)
// Decrypt decrypts ciphertext using the hardware-stored private key.
// The ciphertext format must match what the device expects (e.g. RSA-OAEP).
Decrypt(ciphertext []byte) ([]byte, error)
// GetPublicKey returns the PEM or armored public key corresponding to the
// hardware-stored private key.
GetPublicKey() (string, error)
// IsAvailable reports whether the hardware key device is currently
// connected and operational. Callers should check this before attempting
// Sign or Decrypt to provide graceful fallback behaviour.
IsAvailable() bool
}
```
HardwareKey defines the contract for hardware-backed cryptographic operations.
Implementations should wrap PKCS#11 tokens, YubiKeys, TPM modules, or
similar tamper-resistant devices.
All methods must be safe for concurrent use.
Usage: implement HardwareKey and pass it to WithHardwareKey(...) to wire hardware-backed auth into New(...).
### `MemorySessionStore`
```go
type MemorySessionStore struct {
mu sync.RWMutex
sessions map[string]*Session
}
```
MemorySessionStore is an in-memory SessionStore backed by a map.
Usage: use MemorySessionStore with the other exported helpers in this package.
#### Methods
##### `Cleanup`
```go
func (m *MemorySessionStore) Cleanup() (int, error)
```
Cleanup removes all expired sessions and returns the count removed.
Usage: call Cleanup(...) during the package's normal workflow.
##### `Delete`
```go
func (m *MemorySessionStore) Delete(token string) error
```
Delete removes a session by token.
Usage: call Delete(...) during the package's normal workflow.
##### `DeleteByUser`
```go
func (m *MemorySessionStore) DeleteByUser(userID string) error
```
DeleteByUser removes all sessions belonging to the given user.
Usage: call DeleteByUser(...) during the package's normal workflow.
##### `Get`
```go
func (m *MemorySessionStore) Get(token string) (*Session, error)
```
Get retrieves a session by token.
Usage: call Get(...) during the package's normal workflow.
##### `Set`
```go
func (m *MemorySessionStore) Set(session *Session) error
```
Set stores a session, keyed by its token.
Usage: call Set(...) during the package's normal workflow.
### `Option`
```go
type Option func(*Authenticator)
```
Option configures an Authenticator.
Usage: use Option with the other exported helpers in this package.
### `Revocation`
```go
type Revocation struct {
UserID string `json:"user_id"`
Reason string `json:"reason"`
RevokedAt time.Time `json:"revoked_at"`
}
```
Revocation records the details of a revoked user key.
Stored as JSON in the user's .rev file, replacing the legacy placeholder.
Usage: use Revocation with the other exported helpers in this package.
### `SQLiteSessionStore`
```go
type SQLiteSessionStore struct {
mu sync.Mutex
store *store.Store
}
```
SQLiteSessionStore is a SessionStore backed by core/store (SQLite KV).
A mutex serialises all operations because SQLite is single-writer.
Usage: use SQLiteSessionStore with the other exported helpers in this package.
#### Methods
##### `Cleanup`
```go
func (s *SQLiteSessionStore) Cleanup() (int, error)
```
Cleanup removes all expired sessions and returns the count removed.
Usage: call Cleanup(...) during the package's normal workflow.
##### `Close`
```go
func (s *SQLiteSessionStore) Close() error
```
Close closes the underlying SQLite store.
Usage: call Close(...) during the package's normal workflow.
##### `Delete`
```go
func (s *SQLiteSessionStore) Delete(token string) error
```
Delete removes a session by token from SQLite.
Usage: call Delete(...) during the package's normal workflow.
##### `DeleteByUser`
```go
func (s *SQLiteSessionStore) DeleteByUser(userID string) error
```
DeleteByUser removes all sessions belonging to the given user.
Usage: call DeleteByUser(...) during the package's normal workflow.
##### `Get`
```go
func (s *SQLiteSessionStore) Get(token string) (*Session, error)
```
Get retrieves a session by token from SQLite.
Usage: call Get(...) during the package's normal workflow.
##### `Set`
```go
func (s *SQLiteSessionStore) Set(session *Session) error
```
Set stores a session in SQLite, keyed by its token.
Usage: call Set(...) during the package's normal workflow.
### `Session`
```go
type Session struct {
Token string `json:"token"`
UserID string `json:"user_id"`
ExpiresAt time.Time `json:"expires_at"`
}
```
Session represents an authenticated session.
Usage: use Session with the other exported helpers in this package.
### `SessionStore`
```go
type SessionStore interface {
Get(token string) (*Session, error)
Set(session *Session) error
Delete(token string) error
DeleteByUser(userID string) error
Cleanup() (int, error) // Remove expired sessions, return count removed
}
```
SessionStore abstracts session persistence.
Usage: use SessionStore with the other exported helpers in this package.
### `User`
```go
type User struct {
PublicKey string `json:"public_key"`
KeyID string `json:"key_id"`
Fingerprint string `json:"fingerprint"`
PasswordHash string `json:"password_hash"` // Argon2id (new) or LTHN (legacy)
Created time.Time `json:"created"`
LastLogin time.Time `json:"last_login"`
}
```
User represents a registered user with PGP credentials.
Usage: use User with the other exported helpers in this package.
## Functions
### `New`
```go
func New(m io.Medium, opts ...Option) *Authenticator
```
New creates an Authenticator that persists user data via the given Medium.
By default, sessions are stored in memory. Use WithSessionStore to provide
a persistent implementation (e.g. SQLiteSessionStore).
Usage: call New(...) to create a ready-to-use value.
### `NewMemorySessionStore`
```go
func NewMemorySessionStore() *MemorySessionStore
```
NewMemorySessionStore creates a new in-memory session store.
Usage: call NewMemorySessionStore(...) to create a ready-to-use value.
### `NewSQLiteSessionStore`
```go
func NewSQLiteSessionStore(dbPath string) (*SQLiteSessionStore, error)
```
NewSQLiteSessionStore creates a new SQLite-backed session store.
Use ":memory:" for testing or a file path for persistent storage.
Usage: call NewSQLiteSessionStore(...) to create a ready-to-use value.
### `WithChallengeTTL`
```go
func WithChallengeTTL(d time.Duration) Option
```
WithChallengeTTL sets the lifetime of a challenge before it expires.
Usage: pass WithChallengeTTL(...) into the related constructor to adjust the default behaviour.
### `WithHardwareKey`
```go
func WithHardwareKey(hk HardwareKey) Option
```
WithHardwareKey configures the Authenticator to use a hardware key for
cryptographic operations where supported. When set, the Authenticator may
delegate signing, decryption, and public key retrieval to the hardware
device instead of using software PGP keys.
This is a forward-looking option — integration points are documented in
auth.go but not yet wired up.
Usage: pass WithHardwareKey(...) into New(...) to enable a HardwareKey implementation.
### `WithSessionStore`
```go
func WithSessionStore(s SessionStore) Option
```
WithSessionStore sets the SessionStore implementation.
If not provided, an in-memory store is used (sessions lost on restart).
Usage: pass WithSessionStore(...) into the related constructor to adjust the default behaviour.
### `WithSessionTTL`
```go
func WithSessionTTL(d time.Duration) Option
```
WithSessionTTL sets the lifetime of a session before it expires.
Usage: pass WithSessionTTL(...) into the related constructor to adjust the default behaviour.

20
specs/cmd/crypt/README.md Normal file
View file

@ -0,0 +1,20 @@
# crypt
**Import:** `dappco.re/go/core/crypt/cmd/crypt`
**Files:** 5
## Types
None.
## Functions
### `AddCryptCommands`
```go
func AddCryptCommands(root *cli.Command)
```
AddCryptCommands registers the 'crypt' command group and all subcommands.
Usage: call AddCryptCommands(...) during the package's normal workflow.

View file

@ -0,0 +1,20 @@
# testcmd
**Import:** `dappco.re/go/core/crypt/cmd/testcmd`
**Files:** 4
## Types
None.
## Functions
### `AddTestCommands`
```go
func AddTestCommands(root *cli.Command)
```
AddTestCommands registers the 'test' command and all subcommands.
Usage: call AddTestCommands(...) during the package's normal workflow.

250
specs/crypt/README.md Normal file
View file

@ -0,0 +1,250 @@
# crypt
**Import:** `dappco.re/go/core/crypt/crypt`
**Files:** 6
## Types
None.
## Functions
### `AESGCMDecrypt`
```go
func AESGCMDecrypt(ciphertext, key []byte) ([]byte, error)
```
AESGCMDecrypt decrypts ciphertext encrypted with AESGCMEncrypt.
The key must be 32 bytes. Expects the nonce prepended to the ciphertext.
Usage: call AESGCMDecrypt(...) during the package's normal workflow.
### `AESGCMEncrypt`
```go
func AESGCMEncrypt(plaintext, key []byte) ([]byte, error)
```
AESGCMEncrypt encrypts plaintext using AES-256-GCM.
The key must be 32 bytes. The nonce is randomly generated and prepended
to the ciphertext.
Usage: call AESGCMEncrypt(...) during the package's normal workflow.
### `ChaCha20Decrypt`
```go
func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error)
```
ChaCha20Decrypt decrypts ciphertext encrypted with ChaCha20Encrypt.
The key must be 32 bytes. Expects the nonce prepended to the ciphertext.
Usage: call ChaCha20Decrypt(...) during the package's normal workflow.
### `ChaCha20Encrypt`
```go
func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error)
```
ChaCha20Encrypt encrypts plaintext using ChaCha20-Poly1305.
The key must be 32 bytes. The nonce is randomly generated and prepended
to the ciphertext.
Usage: call ChaCha20Encrypt(...) during the package's normal workflow.
### `Decrypt`
```go
func Decrypt(ciphertext, passphrase []byte) ([]byte, error)
```
Decrypt decrypts data encrypted with Encrypt.
Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
Usage: call Decrypt(...) during the package's normal workflow.
### `DecryptAES`
```go
func DecryptAES(ciphertext, passphrase []byte) ([]byte, error)
```
DecryptAES decrypts data encrypted with EncryptAES.
Expects format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
Usage: call DecryptAES(...) during the package's normal workflow.
### `DeriveKey`
```go
func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte
```
DeriveKey derives a key from a passphrase using Argon2id with default parameters.
The salt must be argon2SaltLen bytes. keyLen specifies the desired key length.
Usage: call DeriveKey(...) during the package's normal workflow.
### `DeriveKeyScrypt`
```go
func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error)
```
DeriveKeyScrypt derives a key from a passphrase using scrypt.
Uses recommended parameters: N=32768, r=8, p=1.
Usage: call DeriveKeyScrypt(...) during the package's normal workflow.
### `Encrypt`
```go
func Encrypt(plaintext, passphrase []byte) ([]byte, error)
```
Encrypt encrypts data with a passphrase using ChaCha20-Poly1305.
A random salt is generated and prepended to the output.
Format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
Usage: call Encrypt(...) during the package's normal workflow.
### `EncryptAES`
```go
func EncryptAES(plaintext, passphrase []byte) ([]byte, error)
```
EncryptAES encrypts data using AES-256-GCM with a passphrase.
A random salt is generated and prepended to the output.
Format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
Usage: call EncryptAES(...) during the package's normal workflow.
### `HKDF`
```go
func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error)
```
HKDF derives a key using HKDF-SHA256.
secret is the input keying material, salt is optional (can be nil),
info is optional context, and keyLen is the desired output length.
Usage: call HKDF(...) during the package's normal workflow.
### `HMACSHA256`
```go
func HMACSHA256(message, key []byte) []byte
```
HMACSHA256 computes the HMAC-SHA256 of a message using the given key.
Usage: call HMACSHA256(...) during the package's normal workflow.
### `HMACSHA512`
```go
func HMACSHA512(message, key []byte) []byte
```
HMACSHA512 computes the HMAC-SHA512 of a message using the given key.
Usage: call HMACSHA512(...) during the package's normal workflow.
### `HashBcrypt`
```go
func HashBcrypt(password string, cost int) (string, error)
```
HashBcrypt hashes a password using bcrypt with the given cost.
Cost must be between bcrypt.MinCost and bcrypt.MaxCost.
Usage: call HashBcrypt(...) during the package's normal workflow.
### `HashPassword`
```go
func HashPassword(password string) (string, error)
```
HashPassword hashes a password using Argon2id with default parameters.
Returns a string in the format: $argon2id$v=19$m=65536,t=3,p=4$<base64salt>$<base64hash>
Usage: call HashPassword(...) during the package's normal workflow.
### `SHA256File`
```go
func SHA256File(path string) (string, error)
```
SHA256File computes the SHA-256 checksum of a file and returns it as a hex string.
Usage: call SHA256File(...) during the package's normal workflow.
### `SHA256Sum`
```go
func SHA256Sum(data []byte) string
```
SHA256Sum computes the SHA-256 checksum of data and returns it as a hex string.
Usage: call SHA256Sum(...) during the package's normal workflow.
### `SHA512File`
```go
func SHA512File(path string) (string, error)
```
SHA512File computes the SHA-512 checksum of a file and returns it as a hex string.
Usage: call SHA512File(...) during the package's normal workflow.
### `SHA512Sum`
```go
func SHA512Sum(data []byte) string
```
SHA512Sum computes the SHA-512 checksum of data and returns it as a hex string.
Usage: call SHA512Sum(...) during the package's normal workflow.
### `VerifyBcrypt`
```go
func VerifyBcrypt(password, hash string) (bool, error)
```
VerifyBcrypt verifies a password against a bcrypt hash.
Usage: call VerifyBcrypt(...) during the package's normal workflow.
### `VerifyHMAC`
```go
func VerifyHMAC(message, key, mac []byte, hashFunc func() hash.Hash) bool
```
VerifyHMAC verifies an HMAC using constant-time comparison.
hashFunc should be sha256.New, sha512.New, etc.
Usage: call VerifyHMAC(...) during the package's normal workflow.
### `VerifyPassword`
```go
func VerifyPassword(password, hash string) (bool, error)
```
VerifyPassword verifies a password against an Argon2id hash string.
The hash must be in the format produced by HashPassword.
Usage: call VerifyPassword(...) during the package's normal workflow.

250
specs/crypt/RFC.md Normal file
View file

@ -0,0 +1,250 @@
# crypt
**Import:** `dappco.re/go/core/crypt/crypt`
**Files:** 6
## Types
None.
## Functions
### `AESGCMDecrypt`
```go
func AESGCMDecrypt(ciphertext, key []byte) ([]byte, error)
```
AESGCMDecrypt decrypts ciphertext encrypted with AESGCMEncrypt.
The key must be 32 bytes. Expects the nonce prepended to the ciphertext.
Usage: call AESGCMDecrypt(...) during the package's normal workflow.
### `AESGCMEncrypt`
```go
func AESGCMEncrypt(plaintext, key []byte) ([]byte, error)
```
AESGCMEncrypt encrypts plaintext using AES-256-GCM.
The key must be 32 bytes. The nonce is randomly generated and prepended
to the ciphertext.
Usage: call AESGCMEncrypt(...) during the package's normal workflow.
### `ChaCha20Decrypt`
```go
func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error)
```
ChaCha20Decrypt decrypts ciphertext encrypted with ChaCha20Encrypt.
The key must be 32 bytes. Expects the nonce prepended to the ciphertext.
Usage: call ChaCha20Decrypt(...) during the package's normal workflow.
### `ChaCha20Encrypt`
```go
func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error)
```
ChaCha20Encrypt encrypts plaintext using ChaCha20-Poly1305.
The key must be 32 bytes. The nonce is randomly generated and prepended
to the ciphertext.
Usage: call ChaCha20Encrypt(...) during the package's normal workflow.
### `Decrypt`
```go
func Decrypt(ciphertext, passphrase []byte) ([]byte, error)
```
Decrypt decrypts data encrypted with Encrypt.
Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
Usage: call Decrypt(...) during the package's normal workflow.
### `DecryptAES`
```go
func DecryptAES(ciphertext, passphrase []byte) ([]byte, error)
```
DecryptAES decrypts data encrypted with EncryptAES.
Expects format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
Usage: call DecryptAES(...) during the package's normal workflow.
### `DeriveKey`
```go
func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte
```
DeriveKey derives a key from a passphrase using Argon2id with default parameters.
The salt must be argon2SaltLen bytes. keyLen specifies the desired key length.
Usage: call DeriveKey(...) during the package's normal workflow.
### `DeriveKeyScrypt`
```go
func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error)
```
DeriveKeyScrypt derives a key from a passphrase using scrypt.
Uses recommended parameters: N=32768, r=8, p=1.
Usage: call DeriveKeyScrypt(...) during the package's normal workflow.
### `Encrypt`
```go
func Encrypt(plaintext, passphrase []byte) ([]byte, error)
```
Encrypt encrypts data with a passphrase using ChaCha20-Poly1305.
A random salt is generated and prepended to the output.
Format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
Usage: call Encrypt(...) during the package's normal workflow.
### `EncryptAES`
```go
func EncryptAES(plaintext, passphrase []byte) ([]byte, error)
```
EncryptAES encrypts data using AES-256-GCM with a passphrase.
A random salt is generated and prepended to the output.
Format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
Usage: call EncryptAES(...) during the package's normal workflow.
### `HKDF`
```go
func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error)
```
HKDF derives a key using HKDF-SHA256.
secret is the input keying material, salt is optional (can be nil),
info is optional context, and keyLen is the desired output length.
Usage: call HKDF(...) during the package's normal workflow.
### `HMACSHA256`
```go
func HMACSHA256(message, key []byte) []byte
```
HMACSHA256 computes the HMAC-SHA256 of a message using the given key.
Usage: call HMACSHA256(...) during the package's normal workflow.
### `HMACSHA512`
```go
func HMACSHA512(message, key []byte) []byte
```
HMACSHA512 computes the HMAC-SHA512 of a message using the given key.
Usage: call HMACSHA512(...) during the package's normal workflow.
### `HashBcrypt`
```go
func HashBcrypt(password string, cost int) (string, error)
```
HashBcrypt hashes a password using bcrypt with the given cost.
Cost must be between bcrypt.MinCost and bcrypt.MaxCost.
Usage: call HashBcrypt(...) during the package's normal workflow.
### `HashPassword`
```go
func HashPassword(password string) (string, error)
```
HashPassword hashes a password using Argon2id with default parameters.
Returns a string in the format: $argon2id$v=19$m=65536,t=3,p=4$<base64salt>$<base64hash>
Usage: call HashPassword(...) during the package's normal workflow.
### `SHA256File`
```go
func SHA256File(path string) (string, error)
```
SHA256File computes the SHA-256 checksum of a file and returns it as a hex string.
Usage: call SHA256File(...) during the package's normal workflow.
### `SHA256Sum`
```go
func SHA256Sum(data []byte) string
```
SHA256Sum computes the SHA-256 checksum of data and returns it as a hex string.
Usage: call SHA256Sum(...) during the package's normal workflow.
### `SHA512File`
```go
func SHA512File(path string) (string, error)
```
SHA512File computes the SHA-512 checksum of a file and returns it as a hex string.
Usage: call SHA512File(...) during the package's normal workflow.
### `SHA512Sum`
```go
func SHA512Sum(data []byte) string
```
SHA512Sum computes the SHA-512 checksum of data and returns it as a hex string.
Usage: call SHA512Sum(...) during the package's normal workflow.
### `VerifyBcrypt`
```go
func VerifyBcrypt(password, hash string) (bool, error)
```
VerifyBcrypt verifies a password against a bcrypt hash.
Usage: call VerifyBcrypt(...) during the package's normal workflow.
### `VerifyHMAC`
```go
func VerifyHMAC(message, key, mac []byte, hashFunc func() hash.Hash) bool
```
VerifyHMAC verifies an HMAC using constant-time comparison.
hashFunc should be sha256.New, sha512.New, etc.
Usage: call VerifyHMAC(...) during the package's normal workflow.
### `VerifyPassword`
```go
func VerifyPassword(password, hash string) (bool, error)
```
VerifyPassword verifies a password against an Argon2id hash string.
The hash must be in the format produced by HashPassword.
Usage: call VerifyPassword(...) during the package's normal workflow.

View file

@ -0,0 +1,30 @@
# chachapoly
**Import:** `dappco.re/go/core/crypt/crypt/chachapoly`
**Files:** 1
## Types
None.
## Functions
### `Decrypt`
```go
func Decrypt(ciphertext []byte, key []byte) ([]byte, error)
```
Decrypt decrypts data using ChaCha20-Poly1305.
Usage: call Decrypt(...) during the package's normal workflow.
### `Encrypt`
```go
func Encrypt(plaintext []byte, key []byte) ([]byte, error)
```
Encrypt encrypts data using ChaCha20-Poly1305.
Usage: call Encrypt(...) during the package's normal workflow.

View file

@ -0,0 +1,30 @@
# chachapoly
**Import:** `dappco.re/go/core/crypt/crypt/chachapoly`
**Files:** 1
## Types
None.
## Functions
### `Decrypt`
```go
func Decrypt(ciphertext []byte, key []byte) ([]byte, error)
```
Decrypt decrypts data using ChaCha20-Poly1305.
Usage: call Decrypt(...) during the package's normal workflow.
### `Encrypt`
```go
func Encrypt(plaintext []byte, key []byte) ([]byte, error)
```
Encrypt encrypts data using ChaCha20-Poly1305.
Usage: call Encrypt(...) during the package's normal workflow.

View file

@ -0,0 +1,63 @@
# lthn
**Import:** `dappco.re/go/core/crypt/crypt/lthn`
**Files:** 1
## Types
None.
## Functions
### `GetKeyMap`
```go
func GetKeyMap() map[rune]rune
```
GetKeyMap returns the current character substitution map.
Usage: call GetKeyMap(...) during the package's normal workflow.
### `Hash`
```go
func Hash(input string) string
```
Hash computes the LTHN hash of the input string.
The algorithm:
1. Derive a quasi-salt by reversing the input and applying character substitutions
2. Concatenate: input + salt
3. Compute SHA-256 of the concatenated string
4. Return the hex-encoded digest (64 characters, lowercase)
The same input always produces the same hash, enabling verification
without storing a separate salt value.
Usage: call Hash(...) when you need a deterministic content-style digest rather than a password hash.
### `SetKeyMap`
```go
func SetKeyMap(newKeyMap map[rune]rune)
```
SetKeyMap replaces the default character substitution map.
Use this to customize the quasi-salt derivation for specific applications.
Changes affect all subsequent Hash and Verify calls.
Usage: call SetKeyMap(...) during the package's normal workflow.
### `Verify`
```go
func Verify(input string, hash string) bool
```
Verify checks if an input string produces the given hash.
Returns true if Hash(input) equals the provided hash value.
Uses constant-time comparison to prevent timing attacks.
Usage: call Verify(...) during the package's normal workflow.

63
specs/crypt/lthn/RFC.md Normal file
View file

@ -0,0 +1,63 @@
# lthn
**Import:** `dappco.re/go/core/crypt/crypt/lthn`
**Files:** 1
## Types
None.
## Functions
### `GetKeyMap`
```go
func GetKeyMap() map[rune]rune
```
GetKeyMap returns the current character substitution map.
Usage: call GetKeyMap(...) during the package's normal workflow.
### `Hash`
```go
func Hash(input string) string
```
Hash computes the LTHN hash of the input string.
The algorithm:
1. Derive a quasi-salt by reversing the input and applying character substitutions
2. Concatenate: input + salt
3. Compute SHA-256 of the concatenated string
4. Return the hex-encoded digest (64 characters, lowercase)
The same input always produces the same hash, enabling verification
without storing a separate salt value.
Usage: call Hash(...) when you need a deterministic content-style digest rather than a password hash.
### `SetKeyMap`
```go
func SetKeyMap(newKeyMap map[rune]rune)
```
SetKeyMap replaces the default character substitution map.
Use this to customize the quasi-salt derivation for specific applications.
Changes affect all subsequent Hash and Verify calls.
Usage: call SetKeyMap(...) during the package's normal workflow.
### `Verify`
```go
func Verify(input string, hash string) bool
```
Verify checks if an input string produces the given hash.
Returns true if Hash(input) equals the provided hash value.
Uses constant-time comparison to prevent timing attacks.
Usage: call Verify(...) during the package's normal workflow.

View file

@ -0,0 +1,69 @@
# openpgp
**Import:** `dappco.re/go/core/crypt/crypt/openpgp`
**Files:** 1
## Types
### `Service`
```go
type Service struct {
core *framework.Core
}
```
Service provides OpenPGP cryptographic operations.
Usage: use Service with the other exported helpers in this package.
#### Methods
##### `CreateKeyPair`
```go
func (s *Service) CreateKeyPair(name, passphrase string) (string, error)
```
CreateKeyPair generates a new RSA-4096 PGP keypair.
Returns the armored private key string.
Usage: call CreateKeyPair(...) during the package's normal workflow.
##### `DecryptPGP`
```go
func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error)
```
DecryptPGP decrypts a PGP message using the provided armored private key and passphrase.
Usage: call DecryptPGP(...) during the package's normal workflow.
##### `EncryptPGP`
```go
func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error)
```
EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath).
The encrypted data is written to the provided writer and also returned as an armored string.
Usage: call EncryptPGP(...) during the package's normal workflow.
##### `HandleIPCEvents`
```go
func (s *Service) HandleIPCEvents(c *framework.Core, msg framework.Message) error
```
HandleIPCEvents handles PGP-related IPC messages.
Usage: call HandleIPCEvents(...) during the package's normal workflow.
## Functions
### `New`
```go
func New(c *framework.Core) (any, error)
```
New creates a new OpenPGP service instance.
Usage: call New(...) to create a ready-to-use value.

View file

@ -0,0 +1,69 @@
# openpgp
**Import:** `dappco.re/go/core/crypt/crypt/openpgp`
**Files:** 1
## Types
### `Service`
```go
type Service struct {
core *framework.Core
}
```
Service provides OpenPGP cryptographic operations.
Usage: use Service with the other exported helpers in this package.
#### Methods
##### `CreateKeyPair`
```go
func (s *Service) CreateKeyPair(name, passphrase string) (string, error)
```
CreateKeyPair generates a new RSA-4096 PGP keypair.
Returns the armored private key string.
Usage: call CreateKeyPair(...) during the package's normal workflow.
##### `DecryptPGP`
```go
func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error)
```
DecryptPGP decrypts a PGP message using the provided armored private key and passphrase.
Usage: call DecryptPGP(...) during the package's normal workflow.
##### `EncryptPGP`
```go
func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error)
```
EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath).
The encrypted data is written to the provided writer and also returned as an armored string.
Usage: call EncryptPGP(...) during the package's normal workflow.
##### `HandleIPCEvents`
```go
func (s *Service) HandleIPCEvents(c *framework.Core, msg framework.Message) error
```
HandleIPCEvents handles PGP-related IPC messages.
Usage: call HandleIPCEvents(...) during the package's normal workflow.
## Functions
### `New`
```go
func New(c *framework.Core) (any, error)
```
New creates a new OpenPGP service instance.
Usage: call New(...) to create a ready-to-use value.

77
specs/crypt/pgp/README.md Normal file
View file

@ -0,0 +1,77 @@
# pgp
**Import:** `dappco.re/go/core/crypt/crypt/pgp`
**Files:** 1
## Types
### `KeyPair`
```go
type KeyPair struct {
PublicKey string
PrivateKey string
}
```
KeyPair holds armored PGP public and private keys.
Usage: use KeyPair with the other exported helpers in this package.
## Functions
### `CreateKeyPair`
```go
func CreateKeyPair(name, email, password string) (*KeyPair, error)
```
CreateKeyPair generates a new PGP key pair for the given identity.
If password is non-empty, the private key is encrypted with it.
Returns a KeyPair with armored public and private keys.
Usage: call CreateKeyPair(...) during the package's normal workflow.
### `Decrypt`
```go
func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error)
```
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.
Usage: call Decrypt(...) during the package's normal workflow.
### `Encrypt`
```go
func Encrypt(data []byte, publicKeyArmor string) ([]byte, error)
```
Encrypt encrypts data for the recipient identified by their armored public key.
Returns the encrypted data as armored PGP output.
Usage: call Encrypt(...) during the package's normal workflow.
### `Sign`
```go
func Sign(data []byte, privateKeyArmor, password string) ([]byte, error)
```
Sign creates an armored detached signature for the given data using
the armored private key. If the key is encrypted, the password is used
to decrypt it first.
Usage: call Sign(...) during the package's normal workflow.
### `Verify`
```go
func Verify(data, signature []byte, publicKeyArmor string) error
```
Verify verifies an armored detached signature against the given data
and armored public key. Returns nil if the signature is valid.
Usage: call Verify(...) during the package's normal workflow.

77
specs/crypt/pgp/RFC.md Normal file
View file

@ -0,0 +1,77 @@
# pgp
**Import:** `dappco.re/go/core/crypt/crypt/pgp`
**Files:** 1
## Types
### `KeyPair`
```go
type KeyPair struct {
PublicKey string
PrivateKey string
}
```
KeyPair holds armored PGP public and private keys.
Usage: use KeyPair with the other exported helpers in this package.
## Functions
### `CreateKeyPair`
```go
func CreateKeyPair(name, email, password string) (*KeyPair, error)
```
CreateKeyPair generates a new PGP key pair for the given identity.
If password is non-empty, the private key is encrypted with it.
Returns a KeyPair with armored public and private keys.
Usage: call CreateKeyPair(...) during the package's normal workflow.
### `Decrypt`
```go
func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error)
```
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.
Usage: call Decrypt(...) during the package's normal workflow.
### `Encrypt`
```go
func Encrypt(data []byte, publicKeyArmor string) ([]byte, error)
```
Encrypt encrypts data for the recipient identified by their armored public key.
Returns the encrypted data as armored PGP output.
Usage: call Encrypt(...) during the package's normal workflow.
### `Sign`
```go
func Sign(data []byte, privateKeyArmor, password string) ([]byte, error)
```
Sign creates an armored detached signature for the given data using
the armored private key. If the key is encrypted, the password is used
to decrypt it first.
Usage: call Sign(...) during the package's normal workflow.
### `Verify`
```go
func Verify(data, signature []byte, publicKeyArmor string) error
```
Verify verifies an armored detached signature against the given data
and armored public key. Returns nil if the signature is valid.
Usage: call Verify(...) during the package's normal workflow.

56
specs/crypt/rsa/README.md Normal file
View file

@ -0,0 +1,56 @@
# rsa
**Import:** `dappco.re/go/core/crypt/crypt/rsa`
**Files:** 1
## Types
### `Service`
```go
type Service struct{}
```
Service provides RSA functionality.
Usage: use Service with the other exported helpers in this package.
#### Methods
##### `Decrypt`
```go
func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error)
```
Decrypt decrypts data with a private key.
Usage: call Decrypt(...) during the package's normal workflow.
##### `Encrypt`
```go
func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error)
```
Encrypt encrypts data with a public key.
Usage: call Encrypt(...) during the package's normal workflow.
##### `GenerateKeyPair`
```go
func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err error)
```
GenerateKeyPair creates a new RSA key pair.
Usage: call GenerateKeyPair(...) during the package's normal workflow.
## Functions
### `NewService`
```go
func NewService() *Service
```
NewService creates and returns a new Service instance for performing RSA-related operations.
Usage: call NewService(...) to create a ready-to-use value.

56
specs/crypt/rsa/RFC.md Normal file
View file

@ -0,0 +1,56 @@
# rsa
**Import:** `dappco.re/go/core/crypt/crypt/rsa`
**Files:** 1
## Types
### `Service`
```go
type Service struct{}
```
Service provides RSA functionality.
Usage: use Service with the other exported helpers in this package.
#### Methods
##### `Decrypt`
```go
func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error)
```
Decrypt decrypts data with a private key.
Usage: call Decrypt(...) during the package's normal workflow.
##### `Encrypt`
```go
func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error)
```
Encrypt encrypts data with a public key.
Usage: call Encrypt(...) during the package's normal workflow.
##### `GenerateKeyPair`
```go
func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err error)
```
GenerateKeyPair creates a new RSA key pair.
Usage: call GenerateKeyPair(...) during the package's normal workflow.
## Functions
### `NewService`
```go
func NewService() *Service
```
NewService creates and returns a new Service instance for performing RSA-related operations.
Usage: call NewService(...) to create a ready-to-use value.

599
specs/trust/README.md Normal file
View file

@ -0,0 +1,599 @@
# trust
**Import:** `dappco.re/go/core/crypt/trust`
**Files:** 5
## Types
### `Agent`
```go
type Agent struct {
// Name is the unique identifier for the agent (e.g., "Athena", "Clotho").
Name string
// Tier is the agent's trust level.
Tier Tier
// ScopedRepos limits repo access for Tier 2 agents. Empty means no repo access.
// Tier 3 agents ignore this field (they have access to all repos).
ScopedRepos []string
// RateLimit is the maximum requests per minute. 0 means unlimited.
RateLimit int
// TokenExpiresAt is when the agent's token expires.
TokenExpiresAt time.Time
// CreatedAt is when the agent was registered.
CreatedAt time.Time
}
```
Agent represents an agent identity in the trust system.
Usage: use Agent with the other exported helpers in this package.
### `ApprovalQueue`
```go
type ApprovalQueue struct {
mu sync.RWMutex
requests map[string]*ApprovalRequest
nextID int
}
```
ApprovalQueue manages pending approval requests for NeedsApproval decisions.
Usage: use ApprovalQueue with the other exported helpers in this package.
#### Methods
##### `Approve`
```go
func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) error
```
Approve marks a pending request as approved. Returns an error if the
request is not found or is not in pending status.
Usage: call Approve(...) during the package's normal workflow.
##### `Deny`
```go
func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error
```
Deny marks a pending request as denied. Returns an error if the
request is not found or is not in pending status.
Usage: call Deny(...) during the package's normal workflow.
##### `Get`
```go
func (q *ApprovalQueue) Get(id string) *ApprovalRequest
```
Get returns the approval request with the given ID, or nil if not found.
Usage: call Get(...) during the package's normal workflow.
##### `Len`
```go
func (q *ApprovalQueue) Len() int
```
Len returns the total number of requests in the queue.
Usage: call Len(...) during the package's normal workflow.
##### `Pending`
```go
func (q *ApprovalQueue) Pending() []ApprovalRequest
```
Pending returns all requests with ApprovalPending status.
Usage: call Pending(...) during the package's normal workflow.
##### `PendingSeq`
```go
func (q *ApprovalQueue) PendingSeq() iter.Seq[ApprovalRequest]
```
PendingSeq returns an iterator over all requests with ApprovalPending status.
Usage: call PendingSeq(...) during the package's normal workflow.
##### `Submit`
```go
func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (string, error)
```
Submit creates a new approval request and returns its ID.
Returns an error if the agent name or capability is empty.
Usage: call Submit(...) during the package's normal workflow.
### `ApprovalRequest`
```go
type ApprovalRequest struct {
// ID is the unique identifier for this request.
ID string
// Agent is the name of the requesting agent.
Agent string
// Cap is the capability being requested.
Cap Capability
// Repo is the optional repo context for repo-scoped capabilities.
Repo string
// Status is the current approval status.
Status ApprovalStatus
// Reason is a human-readable explanation from the reviewer.
Reason string
// RequestedAt is when the request was created.
RequestedAt time.Time
// ReviewedAt is when the request was reviewed (zero if pending).
ReviewedAt time.Time
// ReviewedBy is the name of the admin who reviewed the request.
ReviewedBy string
}
```
ApprovalRequest represents a queued capability approval request.
Usage: use ApprovalRequest with the other exported helpers in this package.
### `ApprovalStatus`
```go
type ApprovalStatus int
```
ApprovalStatus represents the state of an approval request.
Usage: use ApprovalStatus with the other exported helpers in this package.
#### Methods
##### `String`
```go
func (s ApprovalStatus) String() string
```
String returns the human-readable name of the approval status.
Usage: call String(...) during the package's normal workflow.
### `AuditEntry`
```go
type AuditEntry struct {
// Timestamp is when the evaluation occurred.
Timestamp time.Time `json:"timestamp"`
// Agent is the name of the agent being evaluated.
Agent string `json:"agent"`
// Cap is the capability that was evaluated.
Cap Capability `json:"capability"`
// Repo is the repo context (empty if not repo-scoped).
Repo string `json:"repo,omitempty"`
// Decision is the evaluation outcome.
Decision Decision `json:"decision"`
// Reason is the human-readable reason for the decision.
Reason string `json:"reason"`
}
```
AuditEntry records a single policy evaluation for compliance.
Usage: use AuditEntry with the other exported helpers in this package.
### `AuditLog`
```go
type AuditLog struct {
mu sync.Mutex
entries []AuditEntry
writer io.Writer
}
```
AuditLog is an append-only log of policy evaluations.
Usage: use AuditLog with the other exported helpers in this package.
#### Methods
##### `Entries`
```go
func (l *AuditLog) Entries() []AuditEntry
```
Entries returns a snapshot of all audit entries.
Usage: call Entries(...) during the package's normal workflow.
##### `EntriesFor`
```go
func (l *AuditLog) EntriesFor(agent string) []AuditEntry
```
EntriesFor returns all audit entries for a specific agent.
Usage: call EntriesFor(...) during the package's normal workflow.
##### `EntriesForSeq`
```go
func (l *AuditLog) EntriesForSeq(agent string) iter.Seq[AuditEntry]
```
EntriesForSeq returns an iterator over audit entries for a specific agent.
Usage: call EntriesForSeq(...) during the package's normal workflow.
##### `EntriesSeq`
```go
func (l *AuditLog) EntriesSeq() iter.Seq[AuditEntry]
```
EntriesSeq returns an iterator over all audit entries.
Usage: call EntriesSeq(...) during the package's normal workflow.
##### `Len`
```go
func (l *AuditLog) Len() int
```
Len returns the number of entries in the log.
Usage: call Len(...) during the package's normal workflow.
##### `Record`
```go
func (l *AuditLog) Record(result EvalResult, repo string) error
```
Record appends an evaluation result to the audit log.
Usage: call Record(...) during the package's normal workflow.
### `Capability`
```go
type Capability string
```
Capability represents a specific action an agent can perform.
Usage: use Capability with the other exported helpers in this package.
### `Decision`
```go
type Decision int
```
Decision is the result of a policy evaluation.
Usage: use Decision with the other exported helpers in this package.
#### Methods
##### `MarshalJSON`
```go
func (d Decision) MarshalJSON() ([]byte, error)
```
MarshalJSON implements custom JSON encoding for Decision.
Usage: call MarshalJSON(...) during the package's normal workflow.
##### `String`
```go
func (d Decision) String() string
```
String returns the human-readable name of the decision.
Usage: call String(...) during the package's normal workflow.
##### `UnmarshalJSON`
```go
func (d *Decision) UnmarshalJSON(data []byte) error
```
UnmarshalJSON implements custom JSON decoding for Decision.
Usage: call UnmarshalJSON(...) during the package's normal workflow.
### `EvalResult`
```go
type EvalResult struct {
Decision Decision
Agent string
Cap Capability
Reason string
}
```
EvalResult contains the outcome of a capability evaluation.
Usage: use EvalResult with the other exported helpers in this package.
### `PoliciesConfig`
```go
type PoliciesConfig struct {
Policies []PolicyConfig `json:"policies"`
}
```
PoliciesConfig is the top-level configuration containing all tier policies.
Usage: use PoliciesConfig with the other exported helpers in this package.
### `Policy`
```go
type Policy struct {
// Tier is the trust level this policy applies to.
Tier Tier
// Allowed lists the capabilities granted at this tier.
Allowed []Capability
// RequiresApproval lists capabilities that need human/higher-tier approval.
RequiresApproval []Capability
// Denied lists explicitly denied capabilities.
Denied []Capability
}
```
Policy defines the access rules for a given trust tier.
Usage: use Policy with the other exported helpers in this package.
### `PolicyConfig`
```go
type PolicyConfig struct {
Tier int `json:"tier"`
Allowed []string `json:"allowed"`
RequiresApproval []string `json:"requires_approval,omitempty"`
Denied []string `json:"denied,omitempty"`
}
```
PolicyConfig is the JSON-serialisable representation of a trust policy.
Usage: use PolicyConfig with the other exported helpers in this package.
### `PolicyEngine`
```go
type PolicyEngine struct {
registry *Registry
policies map[Tier]*Policy
}
```
PolicyEngine evaluates capability requests against registered policies.
Usage: use PolicyEngine with the other exported helpers in this package.
#### Methods
##### `ApplyPolicies`
```go
func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error
```
ApplyPolicies loads policies from a reader and sets them on the engine,
replacing any existing policies for the same tiers.
Usage: call ApplyPolicies(...) during the package's normal workflow.
##### `ApplyPoliciesFromFile`
```go
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error
```
ApplyPoliciesFromFile loads policies from a JSON file and sets them on the engine.
Usage: call ApplyPoliciesFromFile(...) during the package's normal workflow.
##### `Evaluate`
```go
func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string) EvalResult
```
Evaluate checks whether the named agent can perform the given capability.
If the agent has scoped repos and the capability is repo-scoped, the repo
parameter is checked against the agent's allowed repos.
Usage: call Evaluate(...) during the package's normal workflow.
##### `ExportPolicies`
```go
func (pe *PolicyEngine) ExportPolicies(w io.Writer) error
```
ExportPolicies serialises the current policies as JSON to the given writer.
Usage: call ExportPolicies(...) during the package's normal workflow.
##### `GetPolicy`
```go
func (pe *PolicyEngine) GetPolicy(t Tier) *Policy
```
GetPolicy returns the policy for a tier, or nil if none is set.
Usage: call GetPolicy(...) during the package's normal workflow.
##### `SetPolicy`
```go
func (pe *PolicyEngine) SetPolicy(p Policy) error
```
SetPolicy replaces the policy for a given tier.
Usage: call SetPolicy(...) during the package's normal workflow.
### `Registry`
```go
type Registry struct {
mu sync.RWMutex
agents map[string]*Agent
}
```
Registry manages agent identities and their trust tiers.
Usage: use Registry with the other exported helpers in this package.
#### Methods
##### `Get`
```go
func (r *Registry) Get(name string) *Agent
```
Get returns the agent with the given name, or nil if not found.
Usage: call Get(...) during the package's normal workflow.
##### `Len`
```go
func (r *Registry) Len() int
```
Len returns the number of registered agents.
Usage: call Len(...) during the package's normal workflow.
##### `List`
```go
func (r *Registry) List() []Agent
```
List returns all registered agents. The returned slice is a snapshot.
Usage: call List(...) during the package's normal workflow.
##### `ListSeq`
```go
func (r *Registry) ListSeq() iter.Seq[Agent]
```
ListSeq returns an iterator over all registered agents.
Usage: call ListSeq(...) during the package's normal workflow.
##### `Register`
```go
func (r *Registry) Register(agent Agent) error
```
Register adds or updates an agent in the registry.
Returns an error if the agent name is empty or the tier is invalid.
Usage: call Register(...) during the package's normal workflow.
##### `Remove`
```go
func (r *Registry) Remove(name string) bool
```
Remove deletes an agent from the registry.
Usage: call Remove(...) during the package's normal workflow.
### `Tier`
```go
type Tier int
```
Tier represents an agent's trust level in the system.
Usage: use Tier with the other exported helpers in this package.
#### Methods
##### `String`
```go
func (t Tier) String() string
```
String returns the human-readable name of the tier.
Usage: call String(...) during the package's normal workflow.
##### `Valid`
```go
func (t Tier) Valid() bool
```
Valid returns true if the tier is a recognised trust level.
Usage: call Valid(...) during the package's normal workflow.
## Functions
### `LoadPolicies`
```go
func LoadPolicies(r io.Reader) ([]Policy, error)
```
LoadPolicies reads JSON from a reader and returns parsed policies.
Usage: call LoadPolicies(...) during the package's normal workflow.
### `LoadPoliciesFromFile`
```go
func LoadPoliciesFromFile(path string) ([]Policy, error)
```
LoadPoliciesFromFile reads a JSON file and returns parsed policies.
Usage: call LoadPoliciesFromFile(...) during the package's normal workflow.
### `NewApprovalQueue`
```go
func NewApprovalQueue() *ApprovalQueue
```
NewApprovalQueue creates an empty approval queue.
Usage: call NewApprovalQueue(...) to create a ready-to-use value.
### `NewAuditLog`
```go
func NewAuditLog(w io.Writer) *AuditLog
```
NewAuditLog creates an in-memory audit log. If a writer is provided,
each entry is also written as a JSON line to that writer (append-only).
Usage: call NewAuditLog(...) to create a ready-to-use value.
### `NewPolicyEngine`
```go
func NewPolicyEngine(registry *Registry) *PolicyEngine
```
NewPolicyEngine creates a policy engine with the given registry and default policies.
Usage: call NewPolicyEngine(...) to create a ready-to-use value.
### `NewRegistry`
```go
func NewRegistry() *Registry
```
NewRegistry creates an empty agent registry.
Usage: call NewRegistry(...) to create a ready-to-use value.

599
specs/trust/RFC.md Normal file
View file

@ -0,0 +1,599 @@
# trust
**Import:** `dappco.re/go/core/crypt/trust`
**Files:** 5
## Types
### `Agent`
```go
type Agent struct {
// Name is the unique identifier for the agent (e.g., "Athena", "Clotho").
Name string
// Tier is the agent's trust level.
Tier Tier
// ScopedRepos limits repo access for Tier 2 agents. Empty means no repo access.
// Tier 3 agents ignore this field (they have access to all repos).
ScopedRepos []string
// RateLimit is the maximum requests per minute. 0 means unlimited.
RateLimit int
// TokenExpiresAt is when the agent's token expires.
TokenExpiresAt time.Time
// CreatedAt is when the agent was registered.
CreatedAt time.Time
}
```
Agent represents an agent identity in the trust system.
Usage: use Agent with the other exported helpers in this package.
### `ApprovalQueue`
```go
type ApprovalQueue struct {
mu sync.RWMutex
requests map[string]*ApprovalRequest
nextID int
}
```
ApprovalQueue manages pending approval requests for NeedsApproval decisions.
Usage: use ApprovalQueue with the other exported helpers in this package.
#### Methods
##### `Approve`
```go
func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) error
```
Approve marks a pending request as approved. Returns an error if the
request is not found or is not in pending status.
Usage: call Approve(...) during the package's normal workflow.
##### `Deny`
```go
func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error
```
Deny marks a pending request as denied. Returns an error if the
request is not found or is not in pending status.
Usage: call Deny(...) during the package's normal workflow.
##### `Get`
```go
func (q *ApprovalQueue) Get(id string) *ApprovalRequest
```
Get returns the approval request with the given ID, or nil if not found.
Usage: call Get(...) during the package's normal workflow.
##### `Len`
```go
func (q *ApprovalQueue) Len() int
```
Len returns the total number of requests in the queue.
Usage: call Len(...) during the package's normal workflow.
##### `Pending`
```go
func (q *ApprovalQueue) Pending() []ApprovalRequest
```
Pending returns all requests with ApprovalPending status.
Usage: call Pending(...) during the package's normal workflow.
##### `PendingSeq`
```go
func (q *ApprovalQueue) PendingSeq() iter.Seq[ApprovalRequest]
```
PendingSeq returns an iterator over all requests with ApprovalPending status.
Usage: call PendingSeq(...) during the package's normal workflow.
##### `Submit`
```go
func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (string, error)
```
Submit creates a new approval request and returns its ID.
Returns an error if the agent name or capability is empty.
Usage: call Submit(...) during the package's normal workflow.
### `ApprovalRequest`
```go
type ApprovalRequest struct {
// ID is the unique identifier for this request.
ID string
// Agent is the name of the requesting agent.
Agent string
// Cap is the capability being requested.
Cap Capability
// Repo is the optional repo context for repo-scoped capabilities.
Repo string
// Status is the current approval status.
Status ApprovalStatus
// Reason is a human-readable explanation from the reviewer.
Reason string
// RequestedAt is when the request was created.
RequestedAt time.Time
// ReviewedAt is when the request was reviewed (zero if pending).
ReviewedAt time.Time
// ReviewedBy is the name of the admin who reviewed the request.
ReviewedBy string
}
```
ApprovalRequest represents a queued capability approval request.
Usage: use ApprovalRequest with the other exported helpers in this package.
### `ApprovalStatus`
```go
type ApprovalStatus int
```
ApprovalStatus represents the state of an approval request.
Usage: use ApprovalStatus with the other exported helpers in this package.
#### Methods
##### `String`
```go
func (s ApprovalStatus) String() string
```
String returns the human-readable name of the approval status.
Usage: call String(...) during the package's normal workflow.
### `AuditEntry`
```go
type AuditEntry struct {
// Timestamp is when the evaluation occurred.
Timestamp time.Time `json:"timestamp"`
// Agent is the name of the agent being evaluated.
Agent string `json:"agent"`
// Cap is the capability that was evaluated.
Cap Capability `json:"capability"`
// Repo is the repo context (empty if not repo-scoped).
Repo string `json:"repo,omitempty"`
// Decision is the evaluation outcome.
Decision Decision `json:"decision"`
// Reason is the human-readable reason for the decision.
Reason string `json:"reason"`
}
```
AuditEntry records a single policy evaluation for compliance.
Usage: use AuditEntry with the other exported helpers in this package.
### `AuditLog`
```go
type AuditLog struct {
mu sync.Mutex
entries []AuditEntry
writer io.Writer
}
```
AuditLog is an append-only log of policy evaluations.
Usage: use AuditLog with the other exported helpers in this package.
#### Methods
##### `Entries`
```go
func (l *AuditLog) Entries() []AuditEntry
```
Entries returns a snapshot of all audit entries.
Usage: call Entries(...) during the package's normal workflow.
##### `EntriesFor`
```go
func (l *AuditLog) EntriesFor(agent string) []AuditEntry
```
EntriesFor returns all audit entries for a specific agent.
Usage: call EntriesFor(...) during the package's normal workflow.
##### `EntriesForSeq`
```go
func (l *AuditLog) EntriesForSeq(agent string) iter.Seq[AuditEntry]
```
EntriesForSeq returns an iterator over audit entries for a specific agent.
Usage: call EntriesForSeq(...) during the package's normal workflow.
##### `EntriesSeq`
```go
func (l *AuditLog) EntriesSeq() iter.Seq[AuditEntry]
```
EntriesSeq returns an iterator over all audit entries.
Usage: call EntriesSeq(...) during the package's normal workflow.
##### `Len`
```go
func (l *AuditLog) Len() int
```
Len returns the number of entries in the log.
Usage: call Len(...) during the package's normal workflow.
##### `Record`
```go
func (l *AuditLog) Record(result EvalResult, repo string) error
```
Record appends an evaluation result to the audit log.
Usage: call Record(...) during the package's normal workflow.
### `Capability`
```go
type Capability string
```
Capability represents a specific action an agent can perform.
Usage: use Capability with the other exported helpers in this package.
### `Decision`
```go
type Decision int
```
Decision is the result of a policy evaluation.
Usage: use Decision with the other exported helpers in this package.
#### Methods
##### `MarshalJSON`
```go
func (d Decision) MarshalJSON() ([]byte, error)
```
MarshalJSON implements custom JSON encoding for Decision.
Usage: call MarshalJSON(...) during the package's normal workflow.
##### `String`
```go
func (d Decision) String() string
```
String returns the human-readable name of the decision.
Usage: call String(...) during the package's normal workflow.
##### `UnmarshalJSON`
```go
func (d *Decision) UnmarshalJSON(data []byte) error
```
UnmarshalJSON implements custom JSON decoding for Decision.
Usage: call UnmarshalJSON(...) during the package's normal workflow.
### `EvalResult`
```go
type EvalResult struct {
Decision Decision
Agent string
Cap Capability
Reason string
}
```
EvalResult contains the outcome of a capability evaluation.
Usage: use EvalResult with the other exported helpers in this package.
### `PoliciesConfig`
```go
type PoliciesConfig struct {
Policies []PolicyConfig `json:"policies"`
}
```
PoliciesConfig is the top-level configuration containing all tier policies.
Usage: use PoliciesConfig with the other exported helpers in this package.
### `Policy`
```go
type Policy struct {
// Tier is the trust level this policy applies to.
Tier Tier
// Allowed lists the capabilities granted at this tier.
Allowed []Capability
// RequiresApproval lists capabilities that need human/higher-tier approval.
RequiresApproval []Capability
// Denied lists explicitly denied capabilities.
Denied []Capability
}
```
Policy defines the access rules for a given trust tier.
Usage: use Policy with the other exported helpers in this package.
### `PolicyConfig`
```go
type PolicyConfig struct {
Tier int `json:"tier"`
Allowed []string `json:"allowed"`
RequiresApproval []string `json:"requires_approval,omitempty"`
Denied []string `json:"denied,omitempty"`
}
```
PolicyConfig is the JSON-serialisable representation of a trust policy.
Usage: use PolicyConfig with the other exported helpers in this package.
### `PolicyEngine`
```go
type PolicyEngine struct {
registry *Registry
policies map[Tier]*Policy
}
```
PolicyEngine evaluates capability requests against registered policies.
Usage: use PolicyEngine with the other exported helpers in this package.
#### Methods
##### `ApplyPolicies`
```go
func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error
```
ApplyPolicies loads policies from a reader and sets them on the engine,
replacing any existing policies for the same tiers.
Usage: call ApplyPolicies(...) during the package's normal workflow.
##### `ApplyPoliciesFromFile`
```go
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error
```
ApplyPoliciesFromFile loads policies from a JSON file and sets them on the engine.
Usage: call ApplyPoliciesFromFile(...) during the package's normal workflow.
##### `Evaluate`
```go
func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string) EvalResult
```
Evaluate checks whether the named agent can perform the given capability.
If the agent has scoped repos and the capability is repo-scoped, the repo
parameter is checked against the agent's allowed repos.
Usage: call Evaluate(...) during the package's normal workflow.
##### `ExportPolicies`
```go
func (pe *PolicyEngine) ExportPolicies(w io.Writer) error
```
ExportPolicies serialises the current policies as JSON to the given writer.
Usage: call ExportPolicies(...) during the package's normal workflow.
##### `GetPolicy`
```go
func (pe *PolicyEngine) GetPolicy(t Tier) *Policy
```
GetPolicy returns the policy for a tier, or nil if none is set.
Usage: call GetPolicy(...) during the package's normal workflow.
##### `SetPolicy`
```go
func (pe *PolicyEngine) SetPolicy(p Policy) error
```
SetPolicy replaces the policy for a given tier.
Usage: call SetPolicy(...) during the package's normal workflow.
### `Registry`
```go
type Registry struct {
mu sync.RWMutex
agents map[string]*Agent
}
```
Registry manages agent identities and their trust tiers.
Usage: use Registry with the other exported helpers in this package.
#### Methods
##### `Get`
```go
func (r *Registry) Get(name string) *Agent
```
Get returns the agent with the given name, or nil if not found.
Usage: call Get(...) during the package's normal workflow.
##### `Len`
```go
func (r *Registry) Len() int
```
Len returns the number of registered agents.
Usage: call Len(...) during the package's normal workflow.
##### `List`
```go
func (r *Registry) List() []Agent
```
List returns all registered agents. The returned slice is a snapshot.
Usage: call List(...) during the package's normal workflow.
##### `ListSeq`
```go
func (r *Registry) ListSeq() iter.Seq[Agent]
```
ListSeq returns an iterator over all registered agents.
Usage: call ListSeq(...) during the package's normal workflow.
##### `Register`
```go
func (r *Registry) Register(agent Agent) error
```
Register adds or updates an agent in the registry.
Returns an error if the agent name is empty or the tier is invalid.
Usage: call Register(...) during the package's normal workflow.
##### `Remove`
```go
func (r *Registry) Remove(name string) bool
```
Remove deletes an agent from the registry.
Usage: call Remove(...) during the package's normal workflow.
### `Tier`
```go
type Tier int
```
Tier represents an agent's trust level in the system.
Usage: use Tier with the other exported helpers in this package.
#### Methods
##### `String`
```go
func (t Tier) String() string
```
String returns the human-readable name of the tier.
Usage: call String(...) during the package's normal workflow.
##### `Valid`
```go
func (t Tier) Valid() bool
```
Valid returns true if the tier is a recognised trust level.
Usage: call Valid(...) during the package's normal workflow.
## Functions
### `LoadPolicies`
```go
func LoadPolicies(r io.Reader) ([]Policy, error)
```
LoadPolicies reads JSON from a reader and returns parsed policies.
Usage: call LoadPolicies(...) during the package's normal workflow.
### `LoadPoliciesFromFile`
```go
func LoadPoliciesFromFile(path string) ([]Policy, error)
```
LoadPoliciesFromFile reads a JSON file and returns parsed policies.
Usage: call LoadPoliciesFromFile(...) during the package's normal workflow.
### `NewApprovalQueue`
```go
func NewApprovalQueue() *ApprovalQueue
```
NewApprovalQueue creates an empty approval queue.
Usage: call NewApprovalQueue(...) to create a ready-to-use value.
### `NewAuditLog`
```go
func NewAuditLog(w io.Writer) *AuditLog
```
NewAuditLog creates an in-memory audit log. If a writer is provided,
each entry is also written as a JSON line to that writer (append-only).
Usage: call NewAuditLog(...) to create a ready-to-use value.
### `NewPolicyEngine`
```go
func NewPolicyEngine(registry *Registry) *PolicyEngine
```
NewPolicyEngine creates a policy engine with the given registry and default policies.
Usage: call NewPolicyEngine(...) to create a ready-to-use value.
### `NewRegistry`
```go
func NewRegistry() *Registry
```
NewRegistry creates an empty agent registry.
Usage: call NewRegistry(...) to create a ready-to-use value.

View file

@ -1,27 +1,32 @@
package trust
import (
"fmt"
"iter"
"sync"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// ApprovalStatus represents the state of an approval request.
// Usage: use ApprovalStatus with the other exported helpers in this package.
type ApprovalStatus int
const (
// ApprovalPending means the request is awaiting review.
// Usage: compare or pass ApprovalPending when using the related package APIs.
ApprovalPending ApprovalStatus = iota
// ApprovalApproved means the request was approved.
// Usage: compare or pass ApprovalApproved when using the related package APIs.
ApprovalApproved
// ApprovalDenied means the request was denied.
// Usage: compare or pass ApprovalDenied when using the related package APIs.
ApprovalDenied
)
// String returns the human-readable name of the approval status.
// Usage: call String(...) during the package's normal workflow.
func (s ApprovalStatus) String() string {
switch s {
case ApprovalPending:
@ -31,11 +36,12 @@ func (s ApprovalStatus) String() string {
case ApprovalDenied:
return "denied"
default:
return fmt.Sprintf("unknown(%d)", int(s))
return core.Sprintf("unknown(%d)", int(s))
}
}
// ApprovalRequest represents a queued capability approval request.
// Usage: use ApprovalRequest with the other exported helpers in this package.
type ApprovalRequest struct {
// ID is the unique identifier for this request.
ID string
@ -58,6 +64,7 @@ type ApprovalRequest struct {
}
// ApprovalQueue manages pending approval requests for NeedsApproval decisions.
// Usage: use ApprovalQueue with the other exported helpers in this package.
type ApprovalQueue struct {
mu sync.RWMutex
requests map[string]*ApprovalRequest
@ -65,6 +72,7 @@ type ApprovalQueue struct {
}
// NewApprovalQueue creates an empty approval queue.
// Usage: call NewApprovalQueue(...) to create a ready-to-use value.
func NewApprovalQueue() *ApprovalQueue {
return &ApprovalQueue{
requests: make(map[string]*ApprovalRequest),
@ -73,6 +81,7 @@ func NewApprovalQueue() *ApprovalQueue {
// Submit creates a new approval request and returns its ID.
// Returns an error if the agent name or capability is empty.
// Usage: call Submit(...) during the package's normal workflow.
func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (string, error) {
if agent == "" {
return "", coreerr.E("trust.ApprovalQueue.Submit", "agent name is required", nil)
@ -85,7 +94,7 @@ func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (strin
defer q.mu.Unlock()
q.nextID++
id := fmt.Sprintf("approval-%d", q.nextID)
id := core.Sprintf("approval-%d", q.nextID)
q.requests[id] = &ApprovalRequest{
ID: id,
@ -101,16 +110,17 @@ func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (strin
// Approve marks a pending request as approved. Returns an error if the
// request is not found or is not in pending status.
// Usage: call Approve(...) during the package's normal workflow.
func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) error {
q.mu.Lock()
defer q.mu.Unlock()
req, ok := q.requests[id]
if !ok {
return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q not found", id), nil)
return coreerr.E("trust.ApprovalQueue.Approve", core.Sprintf("request %q not found", id), nil)
}
if req.Status != ApprovalPending {
return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q is already %s", id, req.Status), nil)
return coreerr.E("trust.ApprovalQueue.Approve", core.Sprintf("request %q is already %s", id, req.Status), nil)
}
req.Status = ApprovalApproved
@ -122,16 +132,17 @@ func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) err
// Deny marks a pending request as denied. Returns an error if the
// request is not found or is not in pending status.
// Usage: call Deny(...) during the package's normal workflow.
func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error {
q.mu.Lock()
defer q.mu.Unlock()
req, ok := q.requests[id]
if !ok {
return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q not found", id), nil)
return coreerr.E("trust.ApprovalQueue.Deny", core.Sprintf("request %q not found", id), nil)
}
if req.Status != ApprovalPending {
return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q is already %s", id, req.Status), nil)
return coreerr.E("trust.ApprovalQueue.Deny", core.Sprintf("request %q is already %s", id, req.Status), nil)
}
req.Status = ApprovalDenied
@ -142,6 +153,7 @@ func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error
}
// Get returns the approval request with the given ID, or nil if not found.
// Usage: call Get(...) during the package's normal workflow.
func (q *ApprovalQueue) Get(id string) *ApprovalRequest {
q.mu.RLock()
defer q.mu.RUnlock()
@ -156,6 +168,7 @@ func (q *ApprovalQueue) Get(id string) *ApprovalRequest {
}
// Pending returns all requests with ApprovalPending status.
// Usage: call Pending(...) during the package's normal workflow.
func (q *ApprovalQueue) Pending() []ApprovalRequest {
q.mu.RLock()
defer q.mu.RUnlock()
@ -170,6 +183,7 @@ func (q *ApprovalQueue) Pending() []ApprovalRequest {
}
// PendingSeq returns an iterator over all requests with ApprovalPending status.
// Usage: call PendingSeq(...) during the package's normal workflow.
func (q *ApprovalQueue) PendingSeq() iter.Seq[ApprovalRequest] {
return func(yield func(ApprovalRequest) bool) {
q.mu.RLock()
@ -186,6 +200,7 @@ func (q *ApprovalQueue) PendingSeq() iter.Seq[ApprovalRequest] {
}
// Len returns the total number of requests in the queue.
// Usage: call Len(...) during the package's normal workflow.
func (q *ApprovalQueue) Len() int {
q.mu.RLock()
defer q.mu.RUnlock()

View file

@ -1,29 +1,29 @@
package trust
import (
"fmt"
"sync"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- ApprovalStatus ---
func TestApprovalStatusString_Good(t *testing.T) {
func TestApproval_ApprovalStatusString_Good(t *testing.T) {
assert.Equal(t, "pending", ApprovalPending.String())
assert.Equal(t, "approved", ApprovalApproved.String())
assert.Equal(t, "denied", ApprovalDenied.String())
}
func TestApprovalStatusString_Bad_Unknown(t *testing.T) {
func TestApproval_ApprovalStatusStringUnknown_Bad(t *testing.T) {
assert.Contains(t, ApprovalStatus(99).String(), "unknown")
}
// --- Submit ---
func TestApprovalSubmit_Good(t *testing.T) {
func TestApproval_ApprovalSubmit_Good(t *testing.T) {
q := NewApprovalQueue()
id, err := q.Submit("Clotho", CapMergePR, "host-uk/core")
require.NoError(t, err)
@ -31,7 +31,7 @@ func TestApprovalSubmit_Good(t *testing.T) {
assert.Equal(t, 1, q.Len())
}
func TestApprovalSubmit_Good_MultipleRequests(t *testing.T) {
func TestApproval_ApprovalSubmitMultipleRequests_Good(t *testing.T) {
q := NewApprovalQueue()
id1, err := q.Submit("Clotho", CapMergePR, "host-uk/core")
require.NoError(t, err)
@ -42,7 +42,7 @@ func TestApprovalSubmit_Good_MultipleRequests(t *testing.T) {
assert.Equal(t, 2, q.Len())
}
func TestApprovalSubmit_Good_EmptyRepo(t *testing.T) {
func TestApproval_ApprovalSubmitEmptyRepo_Good(t *testing.T) {
q := NewApprovalQueue()
id, err := q.Submit("Clotho", CapMergePR, "")
require.NoError(t, err)
@ -53,14 +53,14 @@ func TestApprovalSubmit_Good_EmptyRepo(t *testing.T) {
assert.Empty(t, req.Repo)
}
func TestApprovalSubmit_Bad_EmptyAgent(t *testing.T) {
func TestApproval_ApprovalSubmitEmptyAgent_Bad(t *testing.T) {
q := NewApprovalQueue()
_, err := q.Submit("", CapMergePR, "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "agent name is required")
}
func TestApprovalSubmit_Bad_EmptyCapability(t *testing.T) {
func TestApproval_ApprovalSubmitEmptyCapability_Bad(t *testing.T) {
q := NewApprovalQueue()
_, err := q.Submit("Clotho", "", "")
assert.Error(t, err)
@ -69,7 +69,7 @@ func TestApprovalSubmit_Bad_EmptyCapability(t *testing.T) {
// --- Get ---
func TestApprovalGet_Good(t *testing.T) {
func TestApproval_ApprovalGet_Good(t *testing.T) {
q := NewApprovalQueue()
id, err := q.Submit("Clotho", CapMergePR, "host-uk/core")
require.NoError(t, err)
@ -85,7 +85,7 @@ func TestApprovalGet_Good(t *testing.T) {
assert.True(t, req.ReviewedAt.IsZero())
}
func TestApprovalGet_Good_ReturnsSnapshot(t *testing.T) {
func TestApproval_ApprovalGetReturnsSnapshot_Good(t *testing.T) {
q := NewApprovalQueue()
id, err := q.Submit("Clotho", CapMergePR, "host-uk/core")
require.NoError(t, err)
@ -99,14 +99,14 @@ func TestApprovalGet_Good_ReturnsSnapshot(t *testing.T) {
assert.Equal(t, ApprovalPending, original.Status)
}
func TestApprovalGet_Bad_NotFound(t *testing.T) {
func TestApproval_ApprovalGetNotFound_Bad(t *testing.T) {
q := NewApprovalQueue()
assert.Nil(t, q.Get("nonexistent"))
}
// --- Approve ---
func TestApprovalApprove_Good(t *testing.T) {
func TestApproval_ApprovalApprove_Good(t *testing.T) {
q := NewApprovalQueue()
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
@ -121,14 +121,14 @@ func TestApprovalApprove_Good(t *testing.T) {
assert.False(t, req.ReviewedAt.IsZero())
}
func TestApprovalApprove_Bad_NotFound(t *testing.T) {
func TestApproval_ApprovalApproveNotFound_Bad(t *testing.T) {
q := NewApprovalQueue()
err := q.Approve("nonexistent", "admin", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestApprovalApprove_Bad_AlreadyApproved(t *testing.T) {
func TestApproval_ApprovalApproveAlreadyApproved_Bad(t *testing.T) {
q := NewApprovalQueue()
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
require.NoError(t, q.Approve(id, "admin", ""))
@ -138,7 +138,7 @@ func TestApprovalApprove_Bad_AlreadyApproved(t *testing.T) {
assert.Contains(t, err.Error(), "already approved")
}
func TestApprovalApprove_Bad_AlreadyDenied(t *testing.T) {
func TestApproval_ApprovalApproveAlreadyDenied_Bad(t *testing.T) {
q := NewApprovalQueue()
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
require.NoError(t, q.Deny(id, "admin", "nope"))
@ -150,7 +150,7 @@ func TestApprovalApprove_Bad_AlreadyDenied(t *testing.T) {
// --- Deny ---
func TestApprovalDeny_Good(t *testing.T) {
func TestApproval_ApprovalDeny_Good(t *testing.T) {
q := NewApprovalQueue()
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
@ -165,14 +165,14 @@ func TestApprovalDeny_Good(t *testing.T) {
assert.False(t, req.ReviewedAt.IsZero())
}
func TestApprovalDeny_Bad_NotFound(t *testing.T) {
func TestApproval_ApprovalDenyNotFound_Bad(t *testing.T) {
q := NewApprovalQueue()
err := q.Deny("nonexistent", "admin", "")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestApprovalDeny_Bad_AlreadyDenied(t *testing.T) {
func TestApproval_ApprovalDenyAlreadyDenied_Bad(t *testing.T) {
q := NewApprovalQueue()
id, _ := q.Submit("Clotho", CapMergePR, "host-uk/core")
require.NoError(t, q.Deny(id, "admin", ""))
@ -184,7 +184,7 @@ func TestApprovalDeny_Bad_AlreadyDenied(t *testing.T) {
// --- Pending ---
func TestApprovalPending_Good(t *testing.T) {
func TestApproval_ApprovalPending_Good(t *testing.T) {
q := NewApprovalQueue()
q.Submit("Clotho", CapMergePR, "host-uk/core")
q.Submit("Hypnos", CapMergePR, "host-uk/docs")
@ -196,12 +196,12 @@ func TestApprovalPending_Good(t *testing.T) {
assert.Len(t, pending, 2)
}
func TestApprovalPending_Good_Empty(t *testing.T) {
func TestApproval_ApprovalPendingEmpty_Good(t *testing.T) {
q := NewApprovalQueue()
assert.Empty(t, q.Pending())
}
func TestApprovalPendingSeq_Good(t *testing.T) {
func TestApproval_ApprovalPendingSeq_Good(t *testing.T) {
q := NewApprovalQueue()
q.Submit("Clotho", CapMergePR, "host-uk/core")
q.Submit("Hypnos", CapMergePR, "host-uk/docs")
@ -219,7 +219,7 @@ func TestApprovalPendingSeq_Good(t *testing.T) {
// --- Concurrent operations ---
func TestApprovalConcurrent_Good(t *testing.T) {
func TestApproval_ApprovalConcurrent_Good(t *testing.T) {
q := NewApprovalQueue()
const n = 10
@ -234,7 +234,7 @@ func TestApprovalConcurrent_Good(t *testing.T) {
go func(idx int) {
defer wg.Done()
id, err := q.Submit(
fmt.Sprintf("agent-%d", idx),
core.Sprintf("agent-%d", idx),
CapMergePR,
"host-uk/core",
)
@ -270,7 +270,7 @@ func TestApprovalConcurrent_Good(t *testing.T) {
// --- Integration: PolicyEngine + ApprovalQueue ---
func TestApprovalWorkflow_Good_EndToEnd(t *testing.T) {
func TestApproval_ApprovalWorkflowEndToEnd_Good(t *testing.T) {
pe := newTestEngine(t)
q := NewApprovalQueue()
@ -293,7 +293,7 @@ func TestApprovalWorkflow_Good_EndToEnd(t *testing.T) {
assert.Equal(t, "Virgil", req.ReviewedBy)
}
func TestApprovalWorkflow_Good_DenyEndToEnd(t *testing.T) {
func TestApproval_ApprovalWorkflowDenyEndToEnd_Good(t *testing.T) {
pe := newTestEngine(t)
q := NewApprovalQueue()

View file

@ -1,16 +1,17 @@
package trust
import (
"encoding/json"
"io"
"iter"
"sync"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// AuditEntry records a single policy evaluation for compliance.
// Usage: use AuditEntry with the other exported helpers in this package.
type AuditEntry struct {
// Timestamp is when the evaluation occurred.
Timestamp time.Time `json:"timestamp"`
@ -27,14 +28,23 @@ type AuditEntry struct {
}
// MarshalJSON implements custom JSON encoding for Decision.
// Usage: call MarshalJSON(...) during the package's normal workflow.
func (d Decision) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
result := core.JSONMarshal(d.String())
if !result.OK {
err, _ := result.Value.(error)
return nil, err
}
return result.Value.([]byte), nil
}
// UnmarshalJSON implements custom JSON decoding for Decision.
// Usage: call UnmarshalJSON(...) during the package's normal workflow.
func (d *Decision) UnmarshalJSON(data []byte) error {
var s string
if err := json.Unmarshal(data, &s); err != nil {
result := core.JSONUnmarshal(data, &s)
if !result.OK {
err, _ := result.Value.(error)
return err
}
switch s {
@ -51,6 +61,7 @@ func (d *Decision) UnmarshalJSON(data []byte) error {
}
// AuditLog is an append-only log of policy evaluations.
// Usage: use AuditLog with the other exported helpers in this package.
type AuditLog struct {
mu sync.Mutex
entries []AuditEntry
@ -59,6 +70,7 @@ type AuditLog struct {
// NewAuditLog creates an in-memory audit log. If a writer is provided,
// each entry is also written as a JSON line to that writer (append-only).
// Usage: call NewAuditLog(...) to create a ready-to-use value.
func NewAuditLog(w io.Writer) *AuditLog {
return &AuditLog{
writer: w,
@ -66,6 +78,7 @@ func NewAuditLog(w io.Writer) *AuditLog {
}
// Record appends an evaluation result to the audit log.
// Usage: call Record(...) during the package's normal workflow.
func (l *AuditLog) Record(result EvalResult, repo string) error {
entry := AuditEntry{
Timestamp: time.Now(),
@ -82,11 +95,12 @@ func (l *AuditLog) Record(result EvalResult, repo string) error {
l.entries = append(l.entries, entry)
if l.writer != nil {
data, err := json.Marshal(entry)
if err != nil {
dataResult := core.JSONMarshal(entry)
if !dataResult.OK {
err, _ := dataResult.Value.(error)
return coreerr.E("trust.AuditLog.Record", "marshal failed", err)
}
data = append(data, '\n')
data := append(dataResult.Value.([]byte), '\n')
if _, err := l.writer.Write(data); err != nil {
return coreerr.E("trust.AuditLog.Record", "write failed", err)
}
@ -96,6 +110,7 @@ func (l *AuditLog) Record(result EvalResult, repo string) error {
}
// Entries returns a snapshot of all audit entries.
// Usage: call Entries(...) during the package's normal workflow.
func (l *AuditLog) Entries() []AuditEntry {
l.mu.Lock()
defer l.mu.Unlock()
@ -106,6 +121,7 @@ func (l *AuditLog) Entries() []AuditEntry {
}
// EntriesSeq returns an iterator over all audit entries.
// Usage: call EntriesSeq(...) during the package's normal workflow.
func (l *AuditLog) EntriesSeq() iter.Seq[AuditEntry] {
return func(yield func(AuditEntry) bool) {
l.mu.Lock()
@ -120,6 +136,7 @@ func (l *AuditLog) EntriesSeq() iter.Seq[AuditEntry] {
}
// Len returns the number of entries in the log.
// Usage: call Len(...) during the package's normal workflow.
func (l *AuditLog) Len() int {
l.mu.Lock()
defer l.mu.Unlock()
@ -127,6 +144,7 @@ func (l *AuditLog) Len() int {
}
// EntriesFor returns all audit entries for a specific agent.
// Usage: call EntriesFor(...) during the package's normal workflow.
func (l *AuditLog) EntriesFor(agent string) []AuditEntry {
l.mu.Lock()
defer l.mu.Unlock()
@ -141,6 +159,7 @@ func (l *AuditLog) EntriesFor(agent string) []AuditEntry {
}
// EntriesForSeq returns an iterator over audit entries for a specific agent.
// Usage: call EntriesForSeq(...) during the package's normal workflow.
func (l *AuditLog) EntriesForSeq(agent string) iter.Seq[AuditEntry] {
return func(yield func(AuditEntry) bool) {
l.mu.Lock()

View file

@ -1,21 +1,18 @@
package trust
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"sync"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- AuditLog basic ---
func TestAuditRecord_Good(t *testing.T) {
func TestAudit_AuditRecord_Good(t *testing.T) {
log := NewAuditLog(nil)
result := EvalResult{
@ -29,7 +26,7 @@ func TestAuditRecord_Good(t *testing.T) {
assert.Equal(t, 1, log.Len())
}
func TestAuditRecord_Good_EntryFields(t *testing.T) {
func TestAudit_AuditRecordEntryFields_Good(t *testing.T) {
log := NewAuditLog(nil)
result := EvalResult{
@ -53,7 +50,7 @@ func TestAuditRecord_Good_EntryFields(t *testing.T) {
assert.False(t, e.Timestamp.IsZero())
}
func TestAuditRecord_Good_NoRepo(t *testing.T) {
func TestAudit_AuditRecordNoRepo_Good(t *testing.T) {
log := NewAuditLog(nil)
result := EvalResult{
Decision: Allow,
@ -69,7 +66,7 @@ func TestAuditRecord_Good_NoRepo(t *testing.T) {
assert.Empty(t, entries[0].Repo)
}
func TestAuditEntries_Good_Snapshot(t *testing.T) {
func TestAudit_AuditEntriesSnapshot_Good(t *testing.T) {
log := NewAuditLog(nil)
log.Record(EvalResult{Agent: "A", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "")
@ -81,17 +78,17 @@ func TestAuditEntries_Good_Snapshot(t *testing.T) {
assert.Equal(t, "A", log.Entries()[0].Agent)
}
func TestAuditEntries_Good_Empty(t *testing.T) {
func TestAudit_AuditEntriesEmpty_Good(t *testing.T) {
log := NewAuditLog(nil)
assert.Empty(t, log.Entries())
}
func TestAuditEntries_Good_AppendOnly(t *testing.T) {
func TestAudit_AuditEntriesAppendOnly_Good(t *testing.T) {
log := NewAuditLog(nil)
for i := range 5 {
log.Record(EvalResult{
Agent: fmt.Sprintf("agent-%d", i),
Agent: core.Sprintf("agent-%d", i),
Cap: CapPushRepo,
Decision: Allow,
Reason: "ok",
@ -102,7 +99,7 @@ func TestAuditEntries_Good_AppendOnly(t *testing.T) {
// --- EntriesFor ---
func TestAuditEntriesFor_Good(t *testing.T) {
func TestAudit_AuditEntriesFor_Good(t *testing.T) {
log := NewAuditLog(nil)
log.Record(EvalResult{Agent: "Athena", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "")
@ -124,7 +121,7 @@ func TestAuditEntriesFor_Good(t *testing.T) {
assert.Equal(t, 2, count)
}
func TestAuditEntriesSeq_Good(t *testing.T) {
func TestAudit_AuditEntriesSeq_Good(t *testing.T) {
log := NewAuditLog(nil)
log.Record(EvalResult{Agent: "Athena", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "")
log.Record(EvalResult{Agent: "Clotho", Cap: CapCreatePR, Decision: Allow, Reason: "ok"}, "")
@ -136,7 +133,7 @@ func TestAuditEntriesSeq_Good(t *testing.T) {
assert.Equal(t, 2, count)
}
func TestAuditEntriesFor_Bad_NotFound(t *testing.T) {
func TestAudit_AuditEntriesForNotFound_Bad(t *testing.T) {
log := NewAuditLog(nil)
log.Record(EvalResult{Agent: "Athena", Cap: CapPushRepo, Decision: Allow, Reason: "ok"}, "")
@ -145,9 +142,9 @@ func TestAuditEntriesFor_Bad_NotFound(t *testing.T) {
// --- Writer output ---
func TestAuditRecord_Good_WritesToWriter(t *testing.T) {
var buf bytes.Buffer
log := NewAuditLog(&buf)
func TestAudit_AuditRecordWritesToWriter_Good(t *testing.T) {
buf := core.NewBuilder()
log := NewAuditLog(buf)
result := EvalResult{
Decision: Allow,
@ -160,42 +157,42 @@ func TestAuditRecord_Good_WritesToWriter(t *testing.T) {
// Should have written a JSON line.
output := buf.String()
assert.True(t, strings.HasSuffix(output, "\n"))
assert.True(t, core.HasSuffix(output, "\n"))
var entry AuditEntry
err = json.Unmarshal([]byte(output), &entry)
require.NoError(t, err)
decodeResult := core.JSONUnmarshal([]byte(output), &entry)
require.Truef(t, decodeResult.OK, "failed to unmarshal audit entry: %v", decodeResult.Value)
assert.Equal(t, "Athena", entry.Agent)
assert.Equal(t, CapPushRepo, entry.Cap)
assert.Equal(t, Allow, entry.Decision)
assert.Equal(t, "host-uk/core", entry.Repo)
}
func TestAuditRecord_Good_MultipleLines(t *testing.T) {
var buf bytes.Buffer
log := NewAuditLog(&buf)
func TestAudit_AuditRecordMultipleLines_Good(t *testing.T) {
buf := core.NewBuilder()
log := NewAuditLog(buf)
for i := range 3 {
log.Record(EvalResult{
Agent: fmt.Sprintf("agent-%d", i),
Agent: core.Sprintf("agent-%d", i),
Cap: CapPushRepo,
Decision: Allow,
Reason: "ok",
}, "")
}
lines := strings.Split(strings.TrimSpace(buf.String()), "\n")
lines := core.Split(core.Trim(buf.String()), "\n")
assert.Len(t, lines, 3)
// Each line should be valid JSON.
for _, line := range lines {
var entry AuditEntry
err := json.Unmarshal([]byte(line), &entry)
assert.NoError(t, err)
result := core.JSONUnmarshal([]byte(line), &entry)
assert.Truef(t, result.OK, "failed to unmarshal audit line: %v", result.Value)
}
}
func TestAuditRecord_Bad_WriterError(t *testing.T) {
func TestAudit_AuditRecordWriterError_Bad(t *testing.T) {
log := NewAuditLog(&failWriter{})
result := EvalResult{
@ -221,40 +218,42 @@ func (f *failWriter) Write(_ []byte) (int, error) {
// --- Decision JSON marshalling ---
func TestDecisionJSON_Good_RoundTrip(t *testing.T) {
func TestAudit_DecisionJSONRoundTrip_Good(t *testing.T) {
decisions := []Decision{Deny, Allow, NeedsApproval}
expected := []string{`"deny"`, `"allow"`, `"needs_approval"`}
for i, d := range decisions {
data, err := json.Marshal(d)
require.NoError(t, err)
assert.Equal(t, expected[i], string(data))
result := core.JSONMarshal(d)
require.Truef(t, result.OK, "failed to marshal decision: %v", result.Value)
assert.Equal(t, expected[i], string(result.Value.([]byte)))
var decoded Decision
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
decodeResult := core.JSONUnmarshal(result.Value.([]byte), &decoded)
require.Truef(t, decodeResult.OK, "failed to unmarshal decision: %v", decodeResult.Value)
assert.Equal(t, d, decoded)
}
}
func TestDecisionJSON_Bad_UnknownString(t *testing.T) {
func TestAudit_DecisionJSONUnknownString_Bad(t *testing.T) {
var d Decision
err := json.Unmarshal([]byte(`"invalid"`), &d)
result := core.JSONUnmarshal([]byte(`"invalid"`), &d)
err, _ := result.Value.(error)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unknown decision")
}
func TestDecisionJSON_Bad_NonString(t *testing.T) {
func TestAudit_DecisionJSONNonString_Bad(t *testing.T) {
var d Decision
err := json.Unmarshal([]byte(`42`), &d)
result := core.JSONUnmarshal([]byte(`42`), &d)
err, _ := result.Value.(error)
assert.Error(t, err)
}
// --- Concurrent audit logging ---
func TestAuditConcurrent_Good(t *testing.T) {
var buf bytes.Buffer
log := NewAuditLog(&buf)
func TestAudit_AuditConcurrent_Good(t *testing.T) {
buf := core.NewBuilder()
log := NewAuditLog(buf)
const n = 10
var wg sync.WaitGroup
@ -264,7 +263,7 @@ func TestAuditConcurrent_Good(t *testing.T) {
go func(idx int) {
defer wg.Done()
log.Record(EvalResult{
Agent: fmt.Sprintf("agent-%d", idx),
Agent: core.Sprintf("agent-%d", idx),
Cap: CapPushRepo,
Decision: Allow,
Reason: "ok",
@ -278,9 +277,9 @@ func TestAuditConcurrent_Good(t *testing.T) {
// --- Integration: PolicyEngine + AuditLog ---
func TestAuditPolicyIntegration_Good(t *testing.T) {
var buf bytes.Buffer
log := NewAuditLog(&buf)
func TestAudit_AuditPolicyIntegration_Good(t *testing.T) {
buf := core.NewBuilder()
log := NewAuditLog(buf)
pe := newTestEngine(t)
// Evaluate and record

View file

@ -1,8 +1,9 @@
package trust
import (
"fmt"
"testing"
core "dappco.re/go/core"
)
// BenchmarkPolicyEvaluate measures policy evaluation across 100 registered agents.
@ -17,7 +18,7 @@ func BenchmarkPolicyEvaluate(b *testing.B) {
tier = TierVerified
}
_ = r.Register(Agent{
Name: fmt.Sprintf("agent-%d", i),
Name: core.Sprintf("agent-%d", i),
Tier: tier,
ScopedRepos: []string{"host-uk/core", "host-uk/docs"},
})
@ -32,7 +33,7 @@ func BenchmarkPolicyEvaluate(b *testing.B) {
b.ResetTimer()
for i := range b.N {
agentName := fmt.Sprintf("agent-%d", i%100)
agentName := core.Sprintf("agent-%d", i%100)
cap := caps[i%len(caps)]
_ = pe.Evaluate(agentName, cap, "host-uk/core")
}
@ -43,14 +44,14 @@ func BenchmarkRegistryGet(b *testing.B) {
r := NewRegistry()
for i := range 100 {
_ = r.Register(Agent{
Name: fmt.Sprintf("agent-%d", i),
Name: core.Sprintf("agent-%d", i),
Tier: TierVerified,
})
}
b.ResetTimer()
for i := range b.N {
name := fmt.Sprintf("agent-%d", i%100)
name := core.Sprintf("agent-%d", i%100)
_ = r.Get(name)
}
}
@ -62,7 +63,7 @@ func BenchmarkRegistryRegister(b *testing.B) {
b.ResetTimer()
for i := range b.N {
_ = r.Register(Agent{
Name: fmt.Sprintf("bench-agent-%d", i),
Name: core.Sprintf("bench-agent-%d", i),
Tier: TierVerified,
})
}

View file

@ -1,15 +1,14 @@
package trust
import (
"encoding/json"
"fmt"
"io"
"os"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// PolicyConfig is the JSON-serialisable representation of a trust policy.
// Usage: use PolicyConfig with the other exported helpers in this package.
type PolicyConfig struct {
Tier int `json:"tier"`
Allowed []string `json:"allowed"`
@ -18,26 +17,40 @@ type PolicyConfig struct {
}
// PoliciesConfig is the top-level configuration containing all tier policies.
// Usage: use PoliciesConfig with the other exported helpers in this package.
type PoliciesConfig struct {
Policies []PolicyConfig `json:"policies"`
}
// LoadPoliciesFromFile reads a JSON file and returns parsed policies.
// Usage: call LoadPoliciesFromFile(...) during the package's normal workflow.
func LoadPoliciesFromFile(path string) ([]Policy, error) {
f, err := os.Open(path)
if err != nil {
openResult := (&core.Fs{}).New("/").Open(path)
if !openResult.OK {
err, _ := openResult.Value.(error)
return nil, coreerr.E("trust.LoadPoliciesFromFile", "failed to open file", err)
}
defer f.Close()
return LoadPolicies(f)
return LoadPolicies(openResult.Value.(io.Reader))
}
// LoadPolicies reads JSON from a reader and returns parsed policies.
// Usage: call LoadPolicies(...) during the package's normal workflow.
func LoadPolicies(r io.Reader) ([]Policy, error) {
readResult := core.ReadAll(r)
if !readResult.OK {
err, _ := readResult.Value.(error)
return nil, coreerr.E("trust.LoadPolicies", "failed to decode JSON", err)
}
data := []byte(readResult.Value.(string))
if err := validatePoliciesJSON(data); err != nil {
return nil, coreerr.E("trust.LoadPolicies", "failed to decode JSON", err)
}
var cfg PoliciesConfig
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
if err := dec.Decode(&cfg); err != nil {
decodeResult := core.JSONUnmarshal(data, &cfg)
if !decodeResult.OK {
err, _ := decodeResult.Value.(error)
return nil, coreerr.E("trust.LoadPolicies", "failed to decode JSON", err)
}
return convertPolicies(cfg)
@ -50,7 +63,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
for i, pc := range cfg.Policies {
tier := Tier(pc.Tier)
if !tier.Valid() {
return nil, coreerr.E("trust.LoadPolicies", fmt.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil)
return nil, coreerr.E("trust.LoadPolicies", core.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil)
}
p := Policy{
@ -67,6 +80,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
// ApplyPolicies loads policies from a reader and sets them on the engine,
// replacing any existing policies for the same tiers.
// Usage: call ApplyPolicies(...) during the package's normal workflow.
func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
policies, err := LoadPolicies(r)
if err != nil {
@ -81,16 +95,18 @@ func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
}
// ApplyPoliciesFromFile loads policies from a JSON file and sets them on the engine.
// Usage: call ApplyPoliciesFromFile(...) during the package's normal workflow.
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error {
f, err := os.Open(path)
if err != nil {
openResult := (&core.Fs{}).New("/").Open(path)
if !openResult.OK {
err, _ := openResult.Value.(error)
return coreerr.E("trust.ApplyPoliciesFromFile", "failed to open file", err)
}
defer f.Close()
return pe.ApplyPolicies(f)
return pe.ApplyPolicies(openResult.Value.(io.Reader))
}
// ExportPolicies serialises the current policies as JSON to the given writer.
// Usage: call ExportPolicies(...) during the package's normal workflow.
func (pe *PolicyEngine) ExportPolicies(w io.Writer) error {
var cfg PoliciesConfig
for _, tier := range []Tier{TierUntrusted, TierVerified, TierFull} {
@ -106,14 +122,66 @@ func (pe *PolicyEngine) ExportPolicies(w io.Writer) error {
})
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(cfg); err != nil {
dataResult := core.JSONMarshal(cfg)
if !dataResult.OK {
err, _ := dataResult.Value.(error)
return coreerr.E("trust.ExportPolicies", "failed to encode JSON", err)
}
if _, err := w.Write(dataResult.Value.([]byte)); err != nil {
return coreerr.E("trust.ExportPolicies", "failed to encode JSON", err)
}
return nil
}
func validatePoliciesJSON(data []byte) error {
var raw map[string]any
result := core.JSONUnmarshal(data, &raw)
if !result.OK {
err, _ := result.Value.(error)
return err
}
for key := range raw {
if key != "policies" {
return core.NewError(core.Sprintf("json: unknown field %q", key))
}
}
rawPolicies, ok := raw["policies"]
if !ok {
return nil
}
policies, ok := rawPolicies.([]any)
if !ok {
return nil
}
for _, rawPolicy := range policies {
fields, ok := rawPolicy.(map[string]any)
if !ok {
continue
}
for key := range fields {
if !isKnownPolicyConfigKey(key) {
return core.NewError(core.Sprintf("json: unknown field %q", key))
}
}
}
return nil
}
func isKnownPolicyConfigKey(key string) bool {
switch key {
case "tier", "allowed", "requires_approval", "denied":
return true
default:
return false
}
}
// toCapabilities converts string slices to Capability slices.
func toCapabilities(ss []string) []Capability {
if len(ss) == 0 {

View file

@ -1,13 +1,9 @@
package trust
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -34,14 +30,14 @@ const validPolicyJSON = `{
// --- LoadPolicies ---
func TestLoadPolicies_Good(t *testing.T) {
policies, err := LoadPolicies(strings.NewReader(validPolicyJSON))
func TestConfig_LoadPolicies_Good(t *testing.T) {
policies, err := LoadPolicies(core.NewReader(validPolicyJSON))
require.NoError(t, err)
assert.Len(t, policies, 3)
}
func TestLoadPolicies_Good_FieldMapping(t *testing.T) {
policies, err := LoadPolicies(strings.NewReader(validPolicyJSON))
func TestConfig_LoadPoliciesFieldMapping_Good(t *testing.T) {
policies, err := LoadPolicies(core.NewReader(validPolicyJSON))
require.NoError(t, err)
// Tier 3
@ -64,65 +60,64 @@ func TestLoadPolicies_Good_FieldMapping(t *testing.T) {
assert.Len(t, policies[2].Denied, 2)
}
func TestLoadPolicies_Good_EmptyPolicies(t *testing.T) {
func TestConfig_LoadPoliciesEmptyPolicies_Good(t *testing.T) {
input := `{"policies": []}`
policies, err := LoadPolicies(strings.NewReader(input))
policies, err := LoadPolicies(core.NewReader(input))
require.NoError(t, err)
assert.Empty(t, policies)
}
func TestLoadPolicies_Bad_InvalidJSON(t *testing.T) {
_, err := LoadPolicies(strings.NewReader(`{invalid`))
func TestConfig_LoadPoliciesInvalidJSON_Bad(t *testing.T) {
_, err := LoadPolicies(core.NewReader(`{invalid`))
assert.Error(t, err)
}
func TestLoadPolicies_Bad_InvalidTier(t *testing.T) {
func TestConfig_LoadPoliciesInvalidTier_Bad(t *testing.T) {
input := `{"policies": [{"tier": 0, "allowed": ["repo.push"]}]}`
_, err := LoadPolicies(strings.NewReader(input))
_, err := LoadPolicies(core.NewReader(input))
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid tier")
}
func TestLoadPolicies_Bad_TierTooHigh(t *testing.T) {
func TestConfig_LoadPoliciesTierTooHigh_Bad(t *testing.T) {
input := `{"policies": [{"tier": 99, "allowed": ["repo.push"]}]}`
_, err := LoadPolicies(strings.NewReader(input))
_, err := LoadPolicies(core.NewReader(input))
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid tier")
}
func TestLoadPolicies_Bad_UnknownField(t *testing.T) {
func TestConfig_LoadPoliciesUnknownField_Bad(t *testing.T) {
input := `{"policies": [{"tier": 1, "allowed": ["repo.push"], "bogus": true}]}`
_, err := LoadPolicies(strings.NewReader(input))
_, err := LoadPolicies(core.NewReader(input))
assert.Error(t, err, "DisallowUnknownFields should reject unknown fields")
}
// --- LoadPoliciesFromFile ---
func TestLoadPoliciesFromFile_Good(t *testing.T) {
func TestConfig_LoadPoliciesFromFile_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "policies.json")
err := os.WriteFile(path, []byte(validPolicyJSON), 0644)
require.NoError(t, err)
path := core.Path(dir, "policies.json")
writePolicyFile(t, path, validPolicyJSON)
policies, err := LoadPoliciesFromFile(path)
require.NoError(t, err)
assert.Len(t, policies, 3)
}
func TestLoadPoliciesFromFile_Bad_NotFound(t *testing.T) {
func TestConfig_LoadPoliciesFromFileNotFound_Bad(t *testing.T) {
_, err := LoadPoliciesFromFile("/nonexistent/path/policies.json")
assert.Error(t, err)
}
// --- ApplyPolicies ---
func TestApplyPolicies_Good(t *testing.T) {
func TestConfig_ApplyPolicies_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "TestAgent", Tier: TierVerified}))
pe := NewPolicyEngine(r)
// Apply custom policies from JSON
err := pe.ApplyPolicies(strings.NewReader(validPolicyJSON))
err := pe.ApplyPolicies(core.NewReader(validPolicyJSON))
require.NoError(t, err)
// Verify the Tier 2 policy was replaced
@ -140,36 +135,35 @@ func TestApplyPolicies_Good(t *testing.T) {
assert.Equal(t, Allow, result.Decision)
}
func TestApplyPolicies_Bad_InvalidJSON(t *testing.T) {
func TestConfig_ApplyPoliciesInvalidJSON_Bad(t *testing.T) {
r := NewRegistry()
pe := NewPolicyEngine(r)
err := pe.ApplyPolicies(strings.NewReader(`{invalid`))
err := pe.ApplyPolicies(core.NewReader(`{invalid`))
assert.Error(t, err)
}
func TestApplyPolicies_Bad_InvalidTier(t *testing.T) {
func TestConfig_ApplyPoliciesInvalidTier_Bad(t *testing.T) {
r := NewRegistry()
pe := NewPolicyEngine(r)
input := `{"policies": [{"tier": 0, "allowed": ["repo.push"]}]}`
err := pe.ApplyPolicies(strings.NewReader(input))
err := pe.ApplyPolicies(core.NewReader(input))
assert.Error(t, err)
}
// --- ApplyPoliciesFromFile ---
func TestApplyPoliciesFromFile_Good(t *testing.T) {
func TestConfig_ApplyPoliciesFromFile_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "policies.json")
err := os.WriteFile(path, []byte(validPolicyJSON), 0644)
require.NoError(t, err)
path := core.Path(dir, "policies.json")
writePolicyFile(t, path, validPolicyJSON)
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "A", Tier: TierFull}))
pe := NewPolicyEngine(r)
err = pe.ApplyPoliciesFromFile(path)
err := pe.ApplyPoliciesFromFile(path)
require.NoError(t, err)
// Verify Tier 3 was replaced — only 3 allowed caps now
@ -178,7 +172,7 @@ func TestApplyPoliciesFromFile_Good(t *testing.T) {
assert.Len(t, p.Allowed, 3)
}
func TestApplyPoliciesFromFile_Bad_NotFound(t *testing.T) {
func TestConfig_ApplyPoliciesFromFileNotFound_Bad(t *testing.T) {
r := NewRegistry()
pe := NewPolicyEngine(r)
err := pe.ApplyPoliciesFromFile("/nonexistent/policies.json")
@ -187,36 +181,36 @@ func TestApplyPoliciesFromFile_Bad_NotFound(t *testing.T) {
// --- ExportPolicies ---
func TestExportPolicies_Good(t *testing.T) {
func TestConfig_ExportPolicies_Good(t *testing.T) {
r := NewRegistry()
pe := NewPolicyEngine(r) // loads defaults
var buf bytes.Buffer
err := pe.ExportPolicies(&buf)
buf := core.NewBuilder()
err := pe.ExportPolicies(buf)
require.NoError(t, err)
// Output should be valid JSON
var cfg PoliciesConfig
err = json.Unmarshal(buf.Bytes(), &cfg)
require.NoError(t, err)
result := core.JSONUnmarshalString(buf.String(), &cfg)
require.Truef(t, result.OK, "failed to unmarshal exported policies: %v", result.Value)
assert.Len(t, cfg.Policies, 3)
}
func TestExportPolicies_Good_RoundTrip(t *testing.T) {
func TestConfig_ExportPoliciesRoundTrip_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "A", Tier: TierFull}))
pe := NewPolicyEngine(r)
// Export
var buf bytes.Buffer
err := pe.ExportPolicies(&buf)
buf := core.NewBuilder()
err := pe.ExportPolicies(buf)
require.NoError(t, err)
// Create a new engine and apply the exported policies
r2 := NewRegistry()
require.NoError(t, r2.Register(Agent{Name: "A", Tier: TierFull}))
pe2 := NewPolicyEngine(r2)
err = pe2.ApplyPolicies(strings.NewReader(buf.String()))
err = pe2.ApplyPolicies(core.NewReader(buf.String()))
require.NoError(t, err)
// Evaluations should produce the same results
@ -229,28 +223,35 @@ func TestExportPolicies_Good_RoundTrip(t *testing.T) {
}
}
func writePolicyFile(t *testing.T, path, content string) {
t.Helper()
result := (&core.Fs{}).New("/").WriteMode(path, content, 0o644)
require.Truef(t, result.OK, "failed to write %s: %v", path, result.Value)
}
// --- Helper conversion ---
func TestToCapabilities_Good(t *testing.T) {
func TestConfig_ToCapabilities_Good(t *testing.T) {
caps := toCapabilities([]string{"repo.push", "pr.merge"})
assert.Len(t, caps, 2)
assert.Equal(t, CapPushRepo, caps[0])
assert.Equal(t, CapMergePR, caps[1])
}
func TestToCapabilities_Good_Empty(t *testing.T) {
func TestConfig_ToCapabilitiesEmpty_Good(t *testing.T) {
assert.Nil(t, toCapabilities(nil))
assert.Nil(t, toCapabilities([]string{}))
}
func TestFromCapabilities_Good(t *testing.T) {
func TestConfig_FromCapabilities_Good(t *testing.T) {
ss := fromCapabilities([]Capability{CapPushRepo, CapMergePR})
assert.Len(t, ss, 2)
assert.Equal(t, "repo.push", ss[0])
assert.Equal(t, "pr.merge", ss[1])
}
func TestFromCapabilities_Good_Empty(t *testing.T) {
func TestConfig_FromCapabilitiesEmpty_Good(t *testing.T) {
assert.Nil(t, fromCapabilities(nil))
assert.Nil(t, fromCapabilities([]Capability{}))
}

View file

@ -1,14 +1,14 @@
package trust
import (
"fmt"
"slices"
"strings"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// Policy defines the access rules for a given trust tier.
// Usage: use Policy with the other exported helpers in this package.
type Policy struct {
// Tier is the trust level this policy applies to.
Tier Tier
@ -21,24 +21,30 @@ type Policy struct {
}
// PolicyEngine evaluates capability requests against registered policies.
// Usage: use PolicyEngine with the other exported helpers in this package.
type PolicyEngine struct {
registry *Registry
policies map[Tier]*Policy
}
// Decision is the result of a policy evaluation.
// Usage: use Decision with the other exported helpers in this package.
type Decision int
const (
// Deny means the action is not permitted.
// Usage: compare or pass Deny when using the related package APIs.
Deny Decision = iota
// Allow means the action is permitted.
// Usage: compare or pass Allow when using the related package APIs.
Allow
// NeedsApproval means the action requires human or higher-tier approval.
// Usage: compare or pass NeedsApproval when using the related package APIs.
NeedsApproval
)
// String returns the human-readable name of the decision.
// Usage: call String(...) during the package's normal workflow.
func (d Decision) String() string {
switch d {
case Deny:
@ -48,11 +54,12 @@ func (d Decision) String() string {
case NeedsApproval:
return "needs_approval"
default:
return fmt.Sprintf("unknown(%d)", int(d))
return core.Sprintf("unknown(%d)", int(d))
}
}
// EvalResult contains the outcome of a capability evaluation.
// Usage: use EvalResult with the other exported helpers in this package.
type EvalResult struct {
Decision Decision
Agent string
@ -61,6 +68,7 @@ type EvalResult struct {
}
// NewPolicyEngine creates a policy engine with the given registry and default policies.
// Usage: call NewPolicyEngine(...) to create a ready-to-use value.
func NewPolicyEngine(registry *Registry) *PolicyEngine {
pe := &PolicyEngine{
registry: registry,
@ -73,6 +81,7 @@ func NewPolicyEngine(registry *Registry) *PolicyEngine {
// Evaluate checks whether the named agent can perform the given capability.
// If the agent has scoped repos and the capability is repo-scoped, the repo
// parameter is checked against the agent's allowed repos.
// Usage: call Evaluate(...) during the package's normal workflow.
func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string) EvalResult {
agent := pe.registry.Get(agentName)
if agent == nil {
@ -90,7 +99,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Deny,
Agent: agentName,
Cap: cap,
Reason: fmt.Sprintf("no policy for tier %s", agent.Tier),
Reason: core.Sprintf("no policy for tier %s", agent.Tier),
}
}
@ -100,7 +109,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Deny,
Agent: agentName,
Cap: cap,
Reason: fmt.Sprintf("capability %s is denied for tier %s", cap, agent.Tier),
Reason: core.Sprintf("capability %s is denied for tier %s", cap, agent.Tier),
}
}
@ -110,7 +119,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: NeedsApproval,
Agent: agentName,
Cap: cap,
Reason: fmt.Sprintf("capability %s requires approval for tier %s", cap, agent.Tier),
Reason: core.Sprintf("capability %s requires approval for tier %s", cap, agent.Tier),
}
}
@ -124,7 +133,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Deny,
Agent: agentName,
Cap: cap,
Reason: fmt.Sprintf("agent %q does not have access to repo %q", agentName, repo),
Reason: core.Sprintf("agent %q does not have access to repo %q", agentName, repo),
}
}
}
@ -132,7 +141,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Allow,
Agent: agentName,
Cap: cap,
Reason: fmt.Sprintf("capability %s allowed for tier %s", cap, agent.Tier),
Reason: core.Sprintf("capability %s allowed for tier %s", cap, agent.Tier),
}
}
}
@ -141,20 +150,22 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Deny,
Agent: agentName,
Cap: cap,
Reason: fmt.Sprintf("capability %s not granted for tier %s", cap, agent.Tier),
Reason: core.Sprintf("capability %s not granted for tier %s", cap, agent.Tier),
}
}
// SetPolicy replaces the policy for a given tier.
// Usage: call SetPolicy(...) during the package's normal workflow.
func (pe *PolicyEngine) SetPolicy(p Policy) error {
if !p.Tier.Valid() {
return coreerr.E("trust.SetPolicy", fmt.Sprintf("invalid tier %d", p.Tier), nil)
return coreerr.E("trust.SetPolicy", core.Sprintf("invalid tier %d", p.Tier), nil)
}
pe.policies[p.Tier] = &p
return nil
}
// GetPolicy returns the policy for a tier, or nil if none is set.
// Usage: call GetPolicy(...) during the package's normal workflow.
func (pe *PolicyEngine) GetPolicy(t Tier) *Policy {
return pe.policies[t]
}
@ -218,8 +229,8 @@ func (pe *PolicyEngine) loadDefaults() {
// isRepoScoped returns true if the capability is constrained by repo scope.
func isRepoScoped(cap Capability) bool {
return strings.HasPrefix(string(cap), "repo.") ||
strings.HasPrefix(string(cap), "pr.") ||
return core.HasPrefix(string(cap), "repo.") ||
core.HasPrefix(string(cap), "pr.") ||
cap == CapReadSecrets
}
@ -248,14 +259,14 @@ func matchScope(pattern, repo string) bool {
}
// Check for wildcard patterns.
if !strings.Contains(pattern, "*") {
if !core.Contains(pattern, "*") {
return false
}
// "prefix/**" — recursive: matches anything under prefix/.
if strings.HasSuffix(pattern, "/**") {
if core.HasSuffix(pattern, "/**") {
prefix := pattern[:len(pattern)-3] // strip "/**"
if !strings.HasPrefix(repo, prefix+"/") {
if !core.HasPrefix(repo, prefix+"/") {
return false
}
// Must have something after the prefix/.
@ -263,14 +274,14 @@ func matchScope(pattern, repo string) bool {
}
// "prefix/*" — single level: matches prefix/X but not prefix/X/Y.
if strings.HasSuffix(pattern, "/*") {
if core.HasSuffix(pattern, "/*") {
prefix := pattern[:len(pattern)-2] // strip "/*"
if !strings.HasPrefix(repo, prefix+"/") {
if !core.HasPrefix(repo, prefix+"/") {
return false
}
remainder := repo[len(prefix)+1:]
// Must have a non-empty name, and no further slashes.
return remainder != "" && !strings.Contains(remainder, "/")
return remainder != "" && !core.Contains(remainder, "/")
}
// Unsupported wildcard position — fall back to no match.

View file

@ -29,19 +29,19 @@ func newTestEngine(t *testing.T) *PolicyEngine {
// --- Decision ---
func TestDecisionString_Good(t *testing.T) {
func TestPolicy_DecisionString_Good(t *testing.T) {
assert.Equal(t, "deny", Deny.String())
assert.Equal(t, "allow", Allow.String())
assert.Equal(t, "needs_approval", NeedsApproval.String())
}
func TestDecisionString_Bad_Unknown(t *testing.T) {
func TestPolicy_DecisionStringUnknown_Bad(t *testing.T) {
assert.Contains(t, Decision(99).String(), "unknown")
}
// --- Tier 3 (Full Trust) ---
func TestEvaluate_Good_Tier3CanDoAnything(t *testing.T) {
func TestPolicy_EvaluateTier3CanDoAnything_Good(t *testing.T) {
pe := newTestEngine(t)
caps := []Capability{
@ -57,56 +57,56 @@ func TestEvaluate_Good_Tier3CanDoAnything(t *testing.T) {
// --- Tier 2 (Verified) ---
func TestEvaluate_Good_Tier2CanCreatePR(t *testing.T) {
func TestPolicy_EvaluateTier2CanCreatePR_Good(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Clotho", CapCreatePR, "host-uk/core")
assert.Equal(t, Allow, result.Decision)
}
func TestEvaluate_Good_Tier2CanPushToScopedRepo(t *testing.T) {
func TestPolicy_EvaluateTier2CanPushToScopedRepo_Good(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Clotho", CapPushRepo, "host-uk/core")
assert.Equal(t, Allow, result.Decision)
}
func TestEvaluate_Good_Tier2NeedsApprovalToMerge(t *testing.T) {
func TestPolicy_EvaluateTier2NeedsApprovalToMerge_Good(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Clotho", CapMergePR, "host-uk/core")
assert.Equal(t, NeedsApproval, result.Decision)
}
func TestEvaluate_Good_Tier2CanCreateIssue(t *testing.T) {
func TestPolicy_EvaluateTier2CanCreateIssue_Good(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Clotho", CapCreateIssue, "")
assert.Equal(t, Allow, result.Decision)
}
func TestEvaluate_Bad_Tier2CannotAccessWorkspace(t *testing.T) {
func TestPolicy_EvaluateTier2CannotAccessWorkspace_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Clotho", CapAccessWorkspace, "")
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluate_Bad_Tier2CannotModifyFlows(t *testing.T) {
func TestPolicy_EvaluateTier2CannotModifyFlows_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Clotho", CapModifyFlows, "")
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluate_Bad_Tier2CannotRunPrivileged(t *testing.T) {
func TestPolicy_EvaluateTier2CannotRunPrivileged_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Clotho", CapRunPrivileged, "")
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluate_Bad_Tier2CannotPushToUnscopedRepo(t *testing.T) {
func TestPolicy_EvaluateTier2CannotPushToUnscopedRepo_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Clotho", CapPushRepo, "host-uk/secret-repo")
assert.Equal(t, Deny, result.Decision)
assert.Contains(t, result.Reason, "does not have access")
}
func TestEvaluate_Bad_Tier2RepoScopeEmptyRepo(t *testing.T) {
func TestPolicy_EvaluateTier2RepoScopeEmptyRepo_Bad(t *testing.T) {
pe := newTestEngine(t)
// Push without specifying a repo should be denied for scoped agents.
result := pe.Evaluate("Clotho", CapPushRepo, "")
@ -115,43 +115,43 @@ func TestEvaluate_Bad_Tier2RepoScopeEmptyRepo(t *testing.T) {
// --- Tier 1 (Untrusted) ---
func TestEvaluate_Good_Tier1CanCreatePR(t *testing.T) {
func TestPolicy_EvaluateTier1CanCreatePR_Good(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("BugSETI-001", CapCreatePR, "")
assert.Equal(t, Allow, result.Decision)
}
func TestEvaluate_Good_Tier1CanCommentIssue(t *testing.T) {
func TestPolicy_EvaluateTier1CanCommentIssue_Good(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("BugSETI-001", CapCommentIssue, "")
assert.Equal(t, Allow, result.Decision)
}
func TestEvaluate_Bad_Tier1CannotPush(t *testing.T) {
func TestPolicy_EvaluateTier1CannotPush_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("BugSETI-001", CapPushRepo, "")
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluate_Bad_Tier1CannotMerge(t *testing.T) {
func TestPolicy_EvaluateTier1CannotMerge_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("BugSETI-001", CapMergePR, "")
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluate_Bad_Tier1CannotCreateIssue(t *testing.T) {
func TestPolicy_EvaluateTier1CannotCreateIssue_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("BugSETI-001", CapCreateIssue, "")
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluate_Bad_Tier1CannotReadSecrets(t *testing.T) {
func TestPolicy_EvaluateTier1CannotReadSecrets_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("BugSETI-001", CapReadSecrets, "")
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluate_Bad_Tier1CannotRunPrivileged(t *testing.T) {
func TestPolicy_EvaluateTier1CannotRunPrivileged_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("BugSETI-001", CapRunPrivileged, "")
assert.Equal(t, Deny, result.Decision)
@ -159,14 +159,14 @@ func TestEvaluate_Bad_Tier1CannotRunPrivileged(t *testing.T) {
// --- Edge cases ---
func TestEvaluate_Bad_UnknownAgent(t *testing.T) {
func TestPolicy_EvaluateUnknownAgent_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Unknown", CapCreatePR, "")
assert.Equal(t, Deny, result.Decision)
assert.Contains(t, result.Reason, "not registered")
}
func TestEvaluate_Good_EvalResultFields(t *testing.T) {
func TestPolicy_EvaluateEvalResultFields_Good(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Athena", CapPushRepo, "")
assert.Equal(t, "Athena", result.Agent)
@ -176,7 +176,7 @@ func TestEvaluate_Good_EvalResultFields(t *testing.T) {
// --- SetPolicy ---
func TestSetPolicy_Good(t *testing.T) {
func TestPolicy_SetPolicy_Good(t *testing.T) {
pe := newTestEngine(t)
err := pe.SetPolicy(Policy{
Tier: TierVerified,
@ -189,64 +189,64 @@ func TestSetPolicy_Good(t *testing.T) {
assert.Equal(t, Allow, result.Decision)
}
func TestSetPolicy_Bad_InvalidTier(t *testing.T) {
func TestPolicy_SetPolicyInvalidTier_Bad(t *testing.T) {
pe := newTestEngine(t)
err := pe.SetPolicy(Policy{Tier: Tier(0)})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid tier")
}
func TestGetPolicy_Good(t *testing.T) {
func TestPolicy_GetPolicy_Good(t *testing.T) {
pe := newTestEngine(t)
p := pe.GetPolicy(TierFull)
require.NotNil(t, p)
assert.Equal(t, TierFull, p.Tier)
}
func TestGetPolicy_Bad_NotFound(t *testing.T) {
func TestPolicy_GetPolicyNotFound_Bad(t *testing.T) {
pe := newTestEngine(t)
assert.Nil(t, pe.GetPolicy(Tier(99)))
}
// --- isRepoScoped / repoAllowed helpers ---
func TestIsRepoScoped_Good(t *testing.T) {
func TestPolicy_IsRepoScoped_Good(t *testing.T) {
assert.True(t, isRepoScoped(CapPushRepo))
assert.True(t, isRepoScoped(CapCreatePR))
assert.True(t, isRepoScoped(CapMergePR))
assert.True(t, isRepoScoped(CapReadSecrets))
}
func TestIsRepoScoped_Bad_NotScoped(t *testing.T) {
func TestPolicy_IsRepoScopedNotScoped_Bad(t *testing.T) {
assert.False(t, isRepoScoped(CapRunPrivileged))
assert.False(t, isRepoScoped(CapAccessWorkspace))
assert.False(t, isRepoScoped(CapModifyFlows))
}
func TestRepoAllowed_Good(t *testing.T) {
func TestPolicy_RepoAllowed_Good(t *testing.T) {
scoped := []string{"host-uk/core", "host-uk/docs"}
assert.True(t, repoAllowed(scoped, "host-uk/core"))
assert.True(t, repoAllowed(scoped, "host-uk/docs"))
}
func TestRepoAllowed_Bad_NotInScope(t *testing.T) {
func TestPolicy_RepoAllowedNotInScope_Bad(t *testing.T) {
scoped := []string{"host-uk/core"}
assert.False(t, repoAllowed(scoped, "host-uk/secret"))
}
func TestRepoAllowed_Bad_EmptyRepo(t *testing.T) {
func TestPolicy_RepoAllowedEmptyRepo_Bad(t *testing.T) {
scoped := []string{"host-uk/core"}
assert.False(t, repoAllowed(scoped, ""))
}
func TestRepoAllowed_Bad_EmptyScope(t *testing.T) {
func TestPolicy_RepoAllowedEmptyScope_Bad(t *testing.T) {
assert.False(t, repoAllowed(nil, "host-uk/core"))
assert.False(t, repoAllowed([]string{}, "host-uk/core"))
}
// --- Tier 3 ignores repo scoping ---
func TestEvaluate_Good_Tier3IgnoresRepoScope(t *testing.T) {
func TestPolicy_EvaluateTier3IgnoresRepoScope_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "Virgil",
@ -261,7 +261,7 @@ func TestEvaluate_Good_Tier3IgnoresRepoScope(t *testing.T) {
// --- Default rate limits ---
func TestDefaultRateLimit(t *testing.T) {
func TestPolicy_DefaultRateLimit_Good(t *testing.T) {
assert.Equal(t, 10, defaultRateLimit(TierUntrusted))
assert.Equal(t, 60, defaultRateLimit(TierVerified))
assert.Equal(t, 0, defaultRateLimit(TierFull))
@ -270,11 +270,11 @@ func TestDefaultRateLimit(t *testing.T) {
// --- Phase 0 Additions ---
// TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll verifies that a Tier 2
// TestPolicy_EvaluateTier2EmptyScopedReposAllowsAll_Good verifies that a Tier 2
// agent with empty ScopedRepos is treated as "unrestricted" for repo-scoped
// capabilities. NOTE: This is a potential security concern documented in
// FINDINGS.md — empty ScopedRepos bypasses the repo scope check entirely.
func TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll(t *testing.T) {
func TestPolicy_EvaluateTier2EmptyScopedReposAllowsAll_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "Hypnos",
@ -301,9 +301,9 @@ func TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll(t *testing.T) {
assert.Equal(t, Allow, result.Decision)
}
// TestEvaluate_Bad_CapabilityNotInAnyList verifies that a capability not in
// TestPolicy_EvaluateCapabilityNotInAnyList_Bad verifies that a capability not in
// allowed, denied, or requires_approval lists defaults to deny.
func TestEvaluate_Bad_CapabilityNotInAnyList(t *testing.T) {
func TestPolicy_EvaluateCapabilityNotInAnyList_Bad(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "TestAgent",
@ -325,9 +325,9 @@ func TestEvaluate_Bad_CapabilityNotInAnyList(t *testing.T) {
assert.Contains(t, result.Reason, "not granted")
}
// TestEvaluate_Bad_UnknownCapability verifies that a completely invented
// TestPolicy_EvaluateUnknownCapability_Bad verifies that a completely invented
// capability string is denied.
func TestEvaluate_Bad_UnknownCapability(t *testing.T) {
func TestPolicy_EvaluateUnknownCapability_Bad(t *testing.T) {
pe := newTestEngine(t)
result := pe.Evaluate("Athena", Capability("nonexistent.capability"), "")
@ -335,9 +335,9 @@ func TestEvaluate_Bad_UnknownCapability(t *testing.T) {
assert.Contains(t, result.Reason, "not granted")
}
// TestConcurrentEvaluate_Good verifies that concurrent policy evaluations
// TestPolicy_ConcurrentEvaluate_Good verifies that concurrent policy evaluations
// with 10 goroutines do not race.
func TestConcurrentEvaluate_Good(t *testing.T) {
func TestPolicy_ConcurrentEvaluate_Good(t *testing.T) {
pe := newTestEngine(t)
const n = 10
@ -360,10 +360,10 @@ func TestConcurrentEvaluate_Good(t *testing.T) {
wg.Wait()
}
// TestEvaluate_Bad_Tier2ScopedReposWithEmptyRepoParam verifies that
// TestPolicy_EvaluateTier2ScopedReposWithEmptyRepoParam_Bad verifies that
// a scoped agent requesting a repo-scoped capability without specifying
// the repo is denied.
func TestEvaluate_Bad_Tier2ScopedReposWithEmptyRepoParam(t *testing.T) {
func TestPolicy_EvaluateTier2ScopedReposWithEmptyRepoParam_Bad(t *testing.T) {
pe := newTestEngine(t)
// Clotho has ScopedRepos but passes empty repo

View file

@ -9,100 +9,100 @@ import (
// --- matchScope ---
func TestMatchScope_Good_ExactMatch(t *testing.T) {
func TestScope_MatchScopeExactMatch_Good(t *testing.T) {
assert.True(t, matchScope("host-uk/core", "host-uk/core"))
}
func TestMatchScope_Good_SingleWildcard(t *testing.T) {
func TestScope_MatchScopeSingleWildcard_Good(t *testing.T) {
assert.True(t, matchScope("core/*", "core/php"))
assert.True(t, matchScope("core/*", "core/go-crypt"))
assert.True(t, matchScope("host-uk/*", "host-uk/core"))
}
func TestMatchScope_Good_RecursiveWildcard(t *testing.T) {
func TestScope_MatchScopeRecursiveWildcard_Good(t *testing.T) {
assert.True(t, matchScope("core/**", "core/php"))
assert.True(t, matchScope("core/**", "core/php/sub"))
assert.True(t, matchScope("core/**", "core/a/b/c"))
}
func TestMatchScope_Bad_ExactMismatch(t *testing.T) {
func TestScope_MatchScopeExactMismatch_Bad(t *testing.T) {
assert.False(t, matchScope("host-uk/core", "host-uk/docs"))
}
func TestMatchScope_Bad_SingleWildcardNoNested(t *testing.T) {
func TestScope_MatchScopeSingleWildcardNoNested_Bad(t *testing.T) {
// "core/*" should NOT match "core/php/sub" — only single level.
assert.False(t, matchScope("core/*", "core/php/sub"))
assert.False(t, matchScope("core/*", "core/a/b"))
}
func TestMatchScope_Bad_SingleWildcardNoPrefix(t *testing.T) {
func TestScope_MatchScopeSingleWildcardNoPrefix_Bad(t *testing.T) {
// "core/*" should NOT match "other/php".
assert.False(t, matchScope("core/*", "other/php"))
}
func TestMatchScope_Bad_RecursiveWildcardNoPrefix(t *testing.T) {
func TestScope_MatchScopeRecursiveWildcardNoPrefix_Bad(t *testing.T) {
assert.False(t, matchScope("core/**", "other/php"))
}
func TestMatchScope_Bad_EmptyRepo(t *testing.T) {
func TestScope_MatchScopeEmptyRepo_Bad(t *testing.T) {
assert.False(t, matchScope("core/*", ""))
}
func TestMatchScope_Bad_WildcardInMiddle(t *testing.T) {
func TestScope_MatchScopeWildcardInMiddle_Bad(t *testing.T) {
// Wildcard not at the end — should not match.
assert.False(t, matchScope("core/*/sub", "core/php/sub"))
}
func TestMatchScope_Bad_WildcardOnlyPrefix(t *testing.T) {
func TestScope_MatchScopeWildcardOnlyPrefix_Bad(t *testing.T) {
// "core/*" should not match the prefix itself.
assert.False(t, matchScope("core/*", "core"))
assert.False(t, matchScope("core/*", "core/"))
}
func TestMatchScope_Good_RecursiveWildcardSingleLevel(t *testing.T) {
func TestScope_MatchScopeRecursiveWildcardSingleLevel_Good(t *testing.T) {
// "core/**" should also match single-level children.
assert.True(t, matchScope("core/**", "core/php"))
}
func TestMatchScope_Bad_RecursiveWildcardPrefixOnly(t *testing.T) {
func TestScope_MatchScopeRecursiveWildcardPrefixOnly_Bad(t *testing.T) {
assert.False(t, matchScope("core/**", "core"))
assert.False(t, matchScope("core/**", "corefoo"))
}
// --- repoAllowed with wildcards ---
func TestRepoAllowedWildcard_Good(t *testing.T) {
func TestScope_RepoAllowedWildcard_Good(t *testing.T) {
scoped := []string{"core/*", "host-uk/docs"}
assert.True(t, repoAllowed(scoped, "core/php"))
assert.True(t, repoAllowed(scoped, "core/go-crypt"))
assert.True(t, repoAllowed(scoped, "host-uk/docs"))
}
func TestRepoAllowedWildcard_Good_Recursive(t *testing.T) {
func TestScope_RepoAllowedWildcardRecursive_Good(t *testing.T) {
scoped := []string{"core/**"}
assert.True(t, repoAllowed(scoped, "core/php"))
assert.True(t, repoAllowed(scoped, "core/php/sub"))
}
func TestRepoAllowedWildcard_Bad_NoMatch(t *testing.T) {
func TestScope_RepoAllowedWildcardNoMatch_Bad(t *testing.T) {
scoped := []string{"core/*"}
assert.False(t, repoAllowed(scoped, "other/repo"))
assert.False(t, repoAllowed(scoped, "core/php/sub"))
}
func TestRepoAllowedWildcard_Bad_EmptyRepo(t *testing.T) {
func TestScope_RepoAllowedWildcardEmptyRepo_Bad(t *testing.T) {
scoped := []string{"core/*"}
assert.False(t, repoAllowed(scoped, ""))
}
func TestRepoAllowedWildcard_Bad_EmptyScope(t *testing.T) {
func TestScope_RepoAllowedWildcardEmptyScope_Bad(t *testing.T) {
assert.False(t, repoAllowed(nil, "core/php"))
assert.False(t, repoAllowed([]string{}, "core/php"))
}
// --- Integration: PolicyEngine with wildcard scopes ---
func TestEvaluateWildcardScope_Good_SingleLevel(t *testing.T) {
func TestScope_EvaluateWildcardScopeSingleLevel_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "WildAgent",
@ -118,7 +118,7 @@ func TestEvaluateWildcardScope_Good_SingleLevel(t *testing.T) {
assert.Equal(t, Allow, result.Decision)
}
func TestEvaluateWildcardScope_Bad_OutOfScope(t *testing.T) {
func TestScope_EvaluateWildcardScopeOutOfScope_Bad(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "WildAgent",
@ -132,7 +132,7 @@ func TestEvaluateWildcardScope_Bad_OutOfScope(t *testing.T) {
assert.Contains(t, result.Reason, "does not have access")
}
func TestEvaluateWildcardScope_Bad_NestedNotAllowedBySingleStar(t *testing.T) {
func TestScope_EvaluateWildcardScopeNestedNotAllowedBySingleStar_Bad(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "WildAgent",
@ -145,7 +145,7 @@ func TestEvaluateWildcardScope_Bad_NestedNotAllowedBySingleStar(t *testing.T) {
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluateWildcardScope_Good_RecursiveAllowsNested(t *testing.T) {
func TestScope_EvaluateWildcardScopeRecursiveAllowsNested_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "DeepAgent",
@ -158,7 +158,7 @@ func TestEvaluateWildcardScope_Good_RecursiveAllowsNested(t *testing.T) {
assert.Equal(t, Allow, result.Decision)
}
func TestEvaluateWildcardScope_Good_MixedExactAndWildcard(t *testing.T) {
func TestScope_EvaluateWildcardScopeMixedExactAndWildcard_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "MixedAgent",
@ -180,7 +180,7 @@ func TestEvaluateWildcardScope_Good_MixedExactAndWildcard(t *testing.T) {
assert.Equal(t, Deny, result.Decision)
}
func TestEvaluateWildcardScope_Good_ReadSecretsScoped(t *testing.T) {
func TestScope_EvaluateWildcardScopeReadSecretsScoped_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{
Name: "ScopedSecrets",

View file

@ -11,27 +11,32 @@
package trust
import (
"fmt"
"iter"
"sync"
"time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
)
// Tier represents an agent's trust level in the system.
// Usage: use Tier with the other exported helpers in this package.
type Tier int
const (
// TierUntrusted is for external/community agents with minimal access.
// Usage: compare or pass TierUntrusted when using the related package APIs.
TierUntrusted Tier = 1
// TierVerified is for partner agents with scoped access.
// Usage: compare or pass TierVerified when using the related package APIs.
TierVerified Tier = 2
// TierFull is for internal agents with full access.
// Usage: compare or pass TierFull when using the related package APIs.
TierFull Tier = 3
)
// String returns the human-readable name of the tier.
// Usage: call String(...) during the package's normal workflow.
func (t Tier) String() string {
switch t {
case TierUntrusted:
@ -41,31 +46,52 @@ func (t Tier) String() string {
case TierFull:
return "full"
default:
return fmt.Sprintf("unknown(%d)", int(t))
return core.Sprintf("unknown(%d)", int(t))
}
}
// Valid returns true if the tier is a recognised trust level.
// Usage: call Valid(...) during the package's normal workflow.
func (t Tier) Valid() bool {
return t >= TierUntrusted && t <= TierFull
}
// Capability represents a specific action an agent can perform.
// Usage: use Capability with the other exported helpers in this package.
type Capability string
const (
CapPushRepo Capability = "repo.push"
CapMergePR Capability = "pr.merge"
CapCreatePR Capability = "pr.create"
CapCreateIssue Capability = "issue.create"
CapCommentIssue Capability = "issue.comment"
CapReadSecrets Capability = "secrets.read"
CapRunPrivileged Capability = "cmd.privileged"
// CapPushRepo allows pushing commits to a repository.
// Usage: pass CapPushRepo to PolicyEngine.Evaluate or include it in a Policy.
CapPushRepo Capability = "repo.push"
// CapMergePR allows merging a pull request.
// Usage: pass CapMergePR to PolicyEngine.Evaluate or include it in a Policy.
CapMergePR Capability = "pr.merge"
// CapCreatePR allows creating a pull request.
// Usage: pass CapCreatePR to PolicyEngine.Evaluate or include it in a Policy.
CapCreatePR Capability = "pr.create"
// CapCreateIssue allows creating an issue.
// Usage: pass CapCreateIssue to PolicyEngine.Evaluate or include it in a Policy.
CapCreateIssue Capability = "issue.create"
// CapCommentIssue allows commenting on an issue.
// Usage: pass CapCommentIssue to PolicyEngine.Evaluate or include it in a Policy.
CapCommentIssue Capability = "issue.comment"
// CapReadSecrets allows reading secret material.
// Usage: pass CapReadSecrets to PolicyEngine.Evaluate or include it in a Policy.
CapReadSecrets Capability = "secrets.read"
// CapRunPrivileged allows running privileged commands.
// Usage: pass CapRunPrivileged to PolicyEngine.Evaluate or include it in a Policy.
CapRunPrivileged Capability = "cmd.privileged"
// CapAccessWorkspace allows accessing the workspace filesystem.
// Usage: pass CapAccessWorkspace to PolicyEngine.Evaluate or include it in a Policy.
CapAccessWorkspace Capability = "workspace.access"
CapModifyFlows Capability = "flows.modify"
// CapModifyFlows allows modifying workflow definitions.
// Usage: pass CapModifyFlows to PolicyEngine.Evaluate or include it in a Policy.
CapModifyFlows Capability = "flows.modify"
)
// Agent represents an agent identity in the trust system.
// Usage: use Agent with the other exported helpers in this package.
type Agent struct {
// Name is the unique identifier for the agent (e.g., "Athena", "Clotho").
Name string
@ -83,12 +109,14 @@ type Agent struct {
}
// Registry manages agent identities and their trust tiers.
// Usage: use Registry with the other exported helpers in this package.
type Registry struct {
mu sync.RWMutex
agents map[string]*Agent
}
// NewRegistry creates an empty agent registry.
// Usage: call NewRegistry(...) to create a ready-to-use value.
func NewRegistry() *Registry {
return &Registry{
agents: make(map[string]*Agent),
@ -97,12 +125,13 @@ func NewRegistry() *Registry {
// Register adds or updates an agent in the registry.
// Returns an error if the agent name is empty or the tier is invalid.
// Usage: call Register(...) during the package's normal workflow.
func (r *Registry) Register(agent Agent) error {
if agent.Name == "" {
return coreerr.E("trust.Register", "agent name is required", nil)
}
if !agent.Tier.Valid() {
return coreerr.E("trust.Register", fmt.Sprintf("invalid tier %d for agent %q", agent.Tier, agent.Name), nil)
return coreerr.E("trust.Register", core.Sprintf("invalid tier %d for agent %q", agent.Tier, agent.Name), nil)
}
if agent.CreatedAt.IsZero() {
agent.CreatedAt = time.Now()
@ -118,6 +147,7 @@ func (r *Registry) Register(agent Agent) error {
}
// Get returns the agent with the given name, or nil if not found.
// Usage: call Get(...) during the package's normal workflow.
func (r *Registry) Get(name string) *Agent {
r.mu.RLock()
defer r.mu.RUnlock()
@ -125,6 +155,7 @@ func (r *Registry) Get(name string) *Agent {
}
// Remove deletes an agent from the registry.
// Usage: call Remove(...) during the package's normal workflow.
func (r *Registry) Remove(name string) bool {
r.mu.Lock()
defer r.mu.Unlock()
@ -136,6 +167,7 @@ func (r *Registry) Remove(name string) bool {
}
// List returns all registered agents. The returned slice is a snapshot.
// Usage: call List(...) during the package's normal workflow.
func (r *Registry) List() []Agent {
r.mu.RLock()
defer r.mu.RUnlock()
@ -147,6 +179,7 @@ func (r *Registry) List() []Agent {
}
// ListSeq returns an iterator over all registered agents.
// Usage: call ListSeq(...) during the package's normal workflow.
func (r *Registry) ListSeq() iter.Seq[Agent] {
return func(yield func(Agent) bool) {
r.mu.RLock()
@ -160,6 +193,7 @@ func (r *Registry) ListSeq() iter.Seq[Agent] {
}
// Len returns the number of registered agents.
// Usage: call Len(...) during the package's normal workflow.
func (r *Registry) Len() int {
r.mu.RLock()
defer r.mu.RUnlock()

View file

@ -1,34 +1,34 @@
package trust
import (
"fmt"
"sync"
"testing"
"time"
core "dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- Tier ---
func TestTierString_Good(t *testing.T) {
func TestTrust_TierString_Good(t *testing.T) {
assert.Equal(t, "untrusted", TierUntrusted.String())
assert.Equal(t, "verified", TierVerified.String())
assert.Equal(t, "full", TierFull.String())
}
func TestTierString_Bad_Unknown(t *testing.T) {
func TestTrust_TierStringUnknown_Bad(t *testing.T) {
assert.Contains(t, Tier(99).String(), "unknown")
}
func TestTierValid_Good(t *testing.T) {
func TestTrust_TierValid_Good(t *testing.T) {
assert.True(t, TierUntrusted.Valid())
assert.True(t, TierVerified.Valid())
assert.True(t, TierFull.Valid())
}
func TestTierValid_Bad(t *testing.T) {
func TestTrust_TierValid_Bad(t *testing.T) {
assert.False(t, Tier(0).Valid())
assert.False(t, Tier(4).Valid())
assert.False(t, Tier(-1).Valid())
@ -36,14 +36,14 @@ func TestTierValid_Bad(t *testing.T) {
// --- Registry ---
func TestRegistryRegister_Good(t *testing.T) {
func TestTrust_RegistryRegister_Good(t *testing.T) {
r := NewRegistry()
err := r.Register(Agent{Name: "Athena", Tier: TierFull})
require.NoError(t, err)
assert.Equal(t, 1, r.Len())
}
func TestRegistryRegister_Good_SetsDefaults(t *testing.T) {
func TestTrust_RegistryRegisterSetsDefaults_Good(t *testing.T) {
r := NewRegistry()
err := r.Register(Agent{Name: "Athena", Tier: TierFull})
require.NoError(t, err)
@ -54,7 +54,7 @@ func TestRegistryRegister_Good_SetsDefaults(t *testing.T) {
assert.False(t, a.CreatedAt.IsZero())
}
func TestRegistryRegister_Good_TierDefaults(t *testing.T) {
func TestTrust_RegistryRegisterTierDefaults_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "A", Tier: TierUntrusted}))
require.NoError(t, r.Register(Agent{Name: "B", Tier: TierVerified}))
@ -65,14 +65,14 @@ func TestRegistryRegister_Good_TierDefaults(t *testing.T) {
assert.Equal(t, 0, r.Get("C").RateLimit)
}
func TestRegistryRegister_Good_PreservesExplicitRateLimit(t *testing.T) {
func TestTrust_RegistryRegisterPreservesExplicitRateLimit_Good(t *testing.T) {
r := NewRegistry()
err := r.Register(Agent{Name: "Custom", Tier: TierVerified, RateLimit: 30})
require.NoError(t, err)
assert.Equal(t, 30, r.Get("Custom").RateLimit)
}
func TestRegistryRegister_Good_Update(t *testing.T) {
func TestTrust_RegistryRegisterUpdate_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierVerified}))
require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull}))
@ -81,21 +81,21 @@ func TestRegistryRegister_Good_Update(t *testing.T) {
assert.Equal(t, TierFull, r.Get("Athena").Tier)
}
func TestRegistryRegister_Bad_EmptyName(t *testing.T) {
func TestTrust_RegistryRegisterEmptyName_Bad(t *testing.T) {
r := NewRegistry()
err := r.Register(Agent{Tier: TierFull})
assert.Error(t, err)
assert.Contains(t, err.Error(), "name is required")
}
func TestRegistryRegister_Bad_InvalidTier(t *testing.T) {
func TestTrust_RegistryRegisterInvalidTier_Bad(t *testing.T) {
r := NewRegistry()
err := r.Register(Agent{Name: "Bad", Tier: Tier(0)})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid tier")
}
func TestRegistryGet_Good(t *testing.T) {
func TestTrust_RegistryGet_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull}))
a := r.Get("Athena")
@ -103,24 +103,24 @@ func TestRegistryGet_Good(t *testing.T) {
assert.Equal(t, "Athena", a.Name)
}
func TestRegistryGet_Bad_NotFound(t *testing.T) {
func TestTrust_RegistryGetNotFound_Bad(t *testing.T) {
r := NewRegistry()
assert.Nil(t, r.Get("nonexistent"))
}
func TestRegistryRemove_Good(t *testing.T) {
func TestTrust_RegistryRemove_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull}))
assert.True(t, r.Remove("Athena"))
assert.Equal(t, 0, r.Len())
}
func TestRegistryRemove_Bad_NotFound(t *testing.T) {
func TestTrust_RegistryRemoveNotFound_Bad(t *testing.T) {
r := NewRegistry()
assert.False(t, r.Remove("nonexistent"))
}
func TestRegistryList_Good(t *testing.T) {
func TestTrust_RegistryList_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull}))
require.NoError(t, r.Register(Agent{Name: "Clotho", Tier: TierVerified}))
@ -136,12 +136,12 @@ func TestRegistryList_Good(t *testing.T) {
assert.True(t, names["Clotho"])
}
func TestRegistryList_Good_Empty(t *testing.T) {
func TestTrust_RegistryListEmpty_Good(t *testing.T) {
r := NewRegistry()
assert.Empty(t, r.List())
}
func TestRegistryList_Good_Snapshot(t *testing.T) {
func TestTrust_RegistryListSnapshot_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull}))
agents := r.List()
@ -151,7 +151,7 @@ func TestRegistryList_Good_Snapshot(t *testing.T) {
assert.Equal(t, TierFull, r.Get("Athena").Tier)
}
func TestRegistryListSeq_Good(t *testing.T) {
func TestTrust_RegistryListSeq_Good(t *testing.T) {
r := NewRegistry()
require.NoError(t, r.Register(Agent{Name: "Athena", Tier: TierFull}))
require.NoError(t, r.Register(Agent{Name: "Clotho", Tier: TierVerified}))
@ -169,7 +169,7 @@ func TestRegistryListSeq_Good(t *testing.T) {
// --- Agent ---
func TestAgentTokenExpiry(t *testing.T) {
func TestTrust_AgentTokenExpiry_Good(t *testing.T) {
agent := Agent{
Name: "Test",
Tier: TierVerified,
@ -183,9 +183,9 @@ func TestAgentTokenExpiry(t *testing.T) {
// --- Phase 0 Additions ---
// TestConcurrentRegistryOperations_Good verifies that Register/Get/Remove
// TestTrust_ConcurrentRegistryOperations_Good verifies that Register/Get/Remove
// from 10 goroutines do not race.
func TestConcurrentRegistryOperations_Good(t *testing.T) {
func TestTrust_ConcurrentRegistryOperations_Good(t *testing.T) {
r := NewRegistry()
const n = 10
@ -196,7 +196,7 @@ func TestConcurrentRegistryOperations_Good(t *testing.T) {
for i := range n {
go func(idx int) {
defer wg.Done()
name := fmt.Sprintf("agent-%d", idx)
name := core.Sprintf("agent-%d", idx)
err := r.Register(Agent{Name: name, Tier: TierVerified})
assert.NoError(t, err)
}(i)
@ -206,7 +206,7 @@ func TestConcurrentRegistryOperations_Good(t *testing.T) {
for i := range n {
go func(idx int) {
defer wg.Done()
name := fmt.Sprintf("agent-%d", idx)
name := core.Sprintf("agent-%d", idx)
_ = r.Get(name) // Just exercise the read path
}(i)
}
@ -215,7 +215,7 @@ func TestConcurrentRegistryOperations_Good(t *testing.T) {
for i := range n {
go func(idx int) {
defer wg.Done()
name := fmt.Sprintf("agent-%d", idx)
name := core.Sprintf("agent-%d", idx)
_ = r.Remove(name)
}(i)
}
@ -224,24 +224,24 @@ func TestConcurrentRegistryOperations_Good(t *testing.T) {
// No panic or data race = success (run with -race flag)
}
// TestRegisterTierZero_Bad verifies that Tier 0 is rejected.
func TestRegisterTierZero_Bad(t *testing.T) {
// TestTrust_RegisterTierZero_Bad verifies that Tier 0 is rejected.
func TestTrust_RegisterTierZero_Bad(t *testing.T) {
r := NewRegistry()
err := r.Register(Agent{Name: "InvalidTierAgent", Tier: Tier(0)})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid tier")
}
// TestRegisterNegativeTier_Bad verifies that negative tiers are rejected.
func TestRegisterNegativeTier_Bad(t *testing.T) {
// TestTrust_RegisterNegativeTier_Bad verifies that negative tiers are rejected.
func TestTrust_RegisterNegativeTier_Bad(t *testing.T) {
r := NewRegistry()
err := r.Register(Agent{Name: "NegativeTier", Tier: Tier(-1)})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid tier")
}
// TestTokenExpiryBoundary_Good verifies token expiry checking.
func TestTokenExpiryBoundary_Good(t *testing.T) {
// TestTrust_TokenExpiryBoundary_Good verifies token expiry checking.
func TestTrust_TokenExpiryBoundary_Good(t *testing.T) {
// Token that expires in the future — should be valid
futureAgent := Agent{
Name: "FutureAgent",
@ -256,8 +256,8 @@ func TestTokenExpiryBoundary_Good(t *testing.T) {
"token should now be expired")
}
// TestTokenExpiryZeroValue_Ugly verifies zero-value TokenExpiresAt behaviour.
func TestTokenExpiryZeroValue_Ugly(t *testing.T) {
// TestTrust_TokenExpiryZeroValue_Ugly verifies zero-value TokenExpiresAt behaviour.
func TestTrust_TokenExpiryZeroValue_Ugly(t *testing.T) {
agent := Agent{
Name: "ZeroExpiry",
Tier: TierVerified,
@ -274,14 +274,14 @@ func TestTokenExpiryZeroValue_Ugly(t *testing.T) {
"zero-value token expiry should be in the past")
}
// TestConcurrentListDuringMutations_Good verifies List is safe during writes.
func TestConcurrentListDuringMutations_Good(t *testing.T) {
// TestTrust_ConcurrentListDuringMutations_Good verifies List is safe during writes.
func TestTrust_ConcurrentListDuringMutations_Good(t *testing.T) {
r := NewRegistry()
// Pre-populate
for i := range 5 {
require.NoError(t, r.Register(Agent{
Name: fmt.Sprintf("base-%d", i),
Name: core.Sprintf("base-%d", i),
Tier: TierFull,
}))
}
@ -302,7 +302,7 @@ func TestConcurrentListDuringMutations_Good(t *testing.T) {
for i := range 10 {
go func(idx int) {
defer wg.Done()
name := fmt.Sprintf("concurrent-%d", idx)
name := core.Sprintf("concurrent-%d", idx)
_ = r.Register(Agent{Name: name, Tier: TierUntrusted})
}(i)
}