Compare commits

..

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

34 changed files with 222 additions and 292 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

@ -35,11 +35,12 @@ import (
"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 +324,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 +341,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 +390,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 +420,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 +566,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
} }
@ -655,25 +646,19 @@ func (a *Authenticator) verifyPassword(userID, password string) error {
// 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 coreerr.E(op, "failed to verify password", nil)
}
if !valid {
return coreerr.E(op, "invalid password", nil)
}
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 coreerr.E(op, "user not found", 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

@ -5,7 +5,7 @@ import (
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-log"
) )
// ErrSessionNotFound is returned when a session token is not found. // ErrSessionNotFound is returned when a session token is not found.

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

@ -4,9 +4,9 @@ import (
"fmt" "fmt"
"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"
coreio "forge.lthn.ai/core/go-io"
) )
// Encrypt command flags // Encrypt command flags

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

@ -9,8 +9,8 @@ import (
"runtime" "runtime"
"strings" "strings"
"dappco.re/go/core/i18n" "forge.lthn.ai/core/go-i18n"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-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 {
@ -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 {

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,7 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-log"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
@ -27,23 +27,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, coreerr.E("chachapoly.Decrypt", fmt.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil)
} }
nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():] nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():]
decrypted, err := aead.Open(nil, nonce, ciphertext, nil) decrypted, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil { if err != nil {
return nil, 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

@ -8,7 +8,7 @@ import (
"bytes" "bytes"
"io" "io"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-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"
@ -34,9 +34,7 @@ func CreateKeyPair(name, email, password string) (*KeyPair, error) {
// 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
@ -168,9 +166,7 @@ func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
} }
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)
}
} }
} }
} }

View file

@ -8,7 +8,7 @@ import (
"encoding/pem" "encoding/pem"
"fmt" "fmt"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-log"
) )
// Service provides RSA functionality. // Service provides RSA functionality.

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) {

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

@ -6,7 +6,7 @@ import (
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-log"
) )
// ApprovalStatus represents the state of an approval request. // ApprovalStatus represents the state of an approval request.
@ -151,8 +151,8 @@ func (q *ApprovalQueue) Get(id string) *ApprovalRequest {
return nil return nil
} }
// Return a copy to prevent mutation. // Return a copy to prevent mutation.
snapshot := *req copy := *req
return &snapshot return &copy
} }
// Pending returns all requests with ApprovalPending status. // Pending returns all requests with ApprovalPending status.

View file

@ -7,7 +7,7 @@ import (
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-log"
) )
// AuditEntry records a single policy evaluation for compliance. // AuditEntry records a single policy evaluation for compliance.

View file

@ -6,7 +6,7 @@ import (
"io" "io"
"os" "os"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-log"
) )
// PolicyConfig is the JSON-serialisable representation of a trust policy. // PolicyConfig is the JSON-serialisable representation of a trust policy.
@ -34,21 +34,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, coreerr.E("trust.LoadPolicies", "failed to decode JSON", err)
} }
// Reject trailing data after the decoded value
var extra json.RawMessage
if err := dec.Decode(&extra); err != io.EOF {
return nil, coreerr.E(op, "unexpected trailing data in JSON", nil)
}
return convertPolicies(cfg) return convertPolicies(cfg)
} }

View file

@ -5,7 +5,7 @@ import (
"slices" "slices"
"strings" "strings"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-log"
) )
// Policy defines the access rules for a given trust tier. // Policy defines the access rules for a given trust tier.
@ -117,9 +117,9 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
// Check if capability is allowed. // Check if capability is allowed.
for _, allowed := range policy.Allowed { for _, allowed := range policy.Allowed {
if allowed == cap { if allowed == cap {
// For repo-scoped capabilities, verify repo access for 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,
@ -247,11 +247,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

@ -16,7 +16,7 @@ import (
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log" coreerr "forge.lthn.ai/core/go-log"
) )
// Tier represents an agent's trust level in the system. // Tier represents an agent's trust level in the system.
@ -71,9 +71,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.