[agent/codex] A specs/ folder has been injected into this workspace with R... #13
73 changed files with 4561 additions and 712 deletions
|
|
@ -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
149
UPGRADE.md
Normal 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.
|
||||
81
auth/auth.go
81
auth/auth.go
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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():]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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[:])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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, ¶llelism); 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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
6
go.mod
|
|
@ -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
10
go.sum
|
|
@ -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
521
specs/auth/README.md
Normal 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
521
specs/auth/RFC.md
Normal 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
20
specs/cmd/crypt/README.md
Normal 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.
|
||||
20
specs/cmd/testcmd/README.md
Normal file
20
specs/cmd/testcmd/README.md
Normal 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
250
specs/crypt/README.md
Normal 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
250
specs/crypt/RFC.md
Normal 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.
|
||||
30
specs/crypt/chachapoly/README.md
Normal file
30
specs/crypt/chachapoly/README.md
Normal 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.
|
||||
30
specs/crypt/chachapoly/RFC.md
Normal file
30
specs/crypt/chachapoly/RFC.md
Normal 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.
|
||||
63
specs/crypt/lthn/README.md
Normal file
63
specs/crypt/lthn/README.md
Normal 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
63
specs/crypt/lthn/RFC.md
Normal 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.
|
||||
69
specs/crypt/openpgp/README.md
Normal file
69
specs/crypt/openpgp/README.md
Normal 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.
|
||||
69
specs/crypt/openpgp/RFC.md
Normal file
69
specs/crypt/openpgp/RFC.md
Normal 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
77
specs/crypt/pgp/README.md
Normal 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
77
specs/crypt/pgp/RFC.md
Normal 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
56
specs/crypt/rsa/README.md
Normal 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
56
specs/crypt/rsa/RFC.md
Normal 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
599
specs/trust/README.md
Normal 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
599
specs/trust/RFC.md
Normal 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.
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
104
trust/config.go
104
trust/config.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue