Compare commits

..

No commits in common. "dev" and "v0.1.4" have entirely different histories.
dev ... v0.1.4

42 changed files with 695 additions and 1213 deletions

View file

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

View file

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

2
.gitignore vendored
View file

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

8
.idea/modules.xml generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

41
go.mod
View file

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

94
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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