Compare commits

..

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

34 changed files with 286 additions and 388 deletions

View file

@ -2,7 +2,7 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
You are a dedicated domain expert for `dappco.re/go/core/crypt`. Virgil (in You are a dedicated domain expert for `forge.lthn.ai/core/go-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.
@ -39,16 +39,16 @@ 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 All `forge.lthn.ai/core/*` modules are resolved through the Go workspace
(`~/Code/go.work`). Do not add replace directives to `go.mod` — use the (`~/Code/go.work`). Do not add replace directives to `go.mod` — use the
workspace file instead. workspace file instead.
| Module | Local Path | Purpose | | Module | Local Path | Purpose |
|--------|-----------|---------| |--------|-----------|---------|
| `dappco.re/go/core` | `../go` | Framework: `core.Crypt` interface, `io.Medium` | | `forge.lthn.ai/core/go` | `../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/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 | | `forge.lthn.ai/core/cli` | `../cli` | CLI framework for `cmd/crypt` commands |
No C toolchain or CGo required — all crypto uses pure Go implementations. No C toolchain or CGo required — all crypto uses pure Go implementations.
@ -58,9 +58,9 @@ No C toolchain or CGo required — all crypto uses pure Go implementations.
- **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)` (imported
as `coreerr "dappco.re/go/core/log"`); never include secrets in error strings from `forge.lthn.ai/core/go-log`); never include secrets in error strings
- **Randomness**: `crypto/rand` only; never `math/rand` - **Randomness**: `crypto/rand` only; never `math/rand`
- **Conventional commits**: `feat(auth):`, `fix(crypt):`, `refactor(trust):` - **Conventional commits**: `feat(auth):`, `fix(crypt):`, `refactor(trust):`
Scopes match package names: `auth`, `crypt`, `trust`, `pgp`, `lthn`, `rsa`, Scopes match package names: `auth`, `crypt`, `trust`, `pgp`, `lthn`, `rsa`,
@ -70,6 +70,6 @@ No C toolchain or CGo required — all crypto uses pure Go implementations.
## 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,8 @@
package testcmd package testcmd
import ( import (
"dappco.re/go/core/i18n"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
) )
// Style aliases from shared // Style aliases from shared

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

@ -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

@ -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

24
go.mod
View file

@ -1,24 +1,22 @@
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.3.1
dappco.re/go/core/i18n v0.2.0 forge.lthn.ai/core/go v0.3.1
dappco.re/go/core/io v0.2.0 forge.lthn.ai/core/go-i18n v0.1.4
dappco.re/go/core/log v0.1.0 forge.lthn.ai/core/go-io v0.1.2
forge.lthn.ai/core/cli v0.3.7 forge.lthn.ai/core/go-log v0.0.4
forge.lthn.ai/core/go-store v0.1.10 forge.lthn.ai/core/go-store v0.1.6
github.com/ProtonMail/go-crypto v1.4.0 github.com/ProtonMail/go-crypto v1.4.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
) )
require ( require (
forge.lthn.ai/core/go v0.3.2 // indirect forge.lthn.ai/core/go-inference v0.1.4 // indirect
forge.lthn.ai/core/go-i18n v0.1.7 // indirect forge.lthn.ai/core/go-process v0.2.2 // 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.3 // indirect
@ -48,7 +46,7 @@ require (
github.com/spf13/cobra v1.10.2 // 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-20260312153236-7ab1446f8b90 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect golang.org/x/text v0.35.0 // indirect
@ -56,5 +54,5 @@ require (
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.47.0 // indirect modernc.org/sqlite v1.46.1 // indirect
) )

36
go.sum
View file

@ -1,23 +1,19 @@
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U= forge.lthn.ai/core/cli v0.3.1 h1:ZpHhaDrdbaV98JDxj/f0E5nytYk9tTMRu3qohGyK4M0=
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= forge.lthn.ai/core/cli v0.3.1/go.mod h1:28cOl9eK0H033Otkjrv9f/QCmtHcJl+IIx4om8JskOg=
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI= forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok= forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= forge.lthn.ai/core/go-i18n v0.1.4 h1:zOHUUJDgRo88/3tj++kN+VELg/buyZ4T2OSdG3HBbLQ=
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= forge.lthn.ai/core/go-i18n v0.1.4/go.mod h1:aDyAfz7MMgWYgLkZCptfFmZ7jJg3ocwjEJ1WkJSvv4U=
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg= forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs= forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g=
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= 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-process v0.2.2 h1:bnHFtzg92udochDDB6bD2luzzmr9ETKWmGzSsGjFFYE=
forge.lthn.ai/core/go-store v0.1.10/go.mod h1:VNnHh94TMD3+L+sSgvxn0GHtDKhJR8FD6JiuIuRtjuk= forge.lthn.ai/core/go-process v0.2.2/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU=
forge.lthn.ai/core/go-store v0.1.6 h1:7T+K5cciXOaWRxge0WnGkt0PcK3epliWBa1G2FLEuac=
forge.lthn.ai/core/go-store v0.1.6/go.mod h1:/2vqaAn+HgGU14N29B+vIfhjIsBzy7RC+AluI6BIUKI=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= 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/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=
@ -139,8 +135,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()