Compare commits
No commits in common. "dev" and "v0.1.7" have entirely different histories.
34 changed files with 286 additions and 388 deletions
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
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
|
||||
done, commit and push.
|
||||
|
||||
|
|
@ -39,16 +39,16 @@ go test -bench=. -benchmem ./crypt/... # Benchmarks
|
|||
|
||||
## 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
|
||||
workspace file instead.
|
||||
|
||||
| Module | Local Path | Purpose |
|
||||
|--------|-----------|---------|
|
||||
| `dappco.re/go/core` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
|
||||
| `dappco.re/go/core/log` | `../go-log` | `coreerr.E()` contextual error wrapping |
|
||||
| `dappco.re/go/core/io` | `../go-io` | `io.Medium` storage abstraction |
|
||||
| `forge.lthn.ai/core/go` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
|
||||
| `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 |
|
||||
|
||||
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
|
||||
- **Tests**: testify assert/require, `_Good`/`_Bad`/`_Ugly` naming convention
|
||||
- **Concurrency tests**: 10 goroutines via WaitGroup; must pass `-race`
|
||||
- **Imports**: stdlib → dappco.re/forge.lthn.ai → third-party, separated by blank lines
|
||||
- **Errors**: use `coreerr.E("package.Function", "lowercase message", err)` (imported
|
||||
as `coreerr "dappco.re/go/core/log"`); never include secrets in error strings
|
||||
- **Imports**: stdlib → forge.lthn.ai → third-party, separated by blank lines
|
||||
- **Errors**: use `core.E("package.Function", "lowercase message", err)` (imported
|
||||
from `forge.lthn.ai/core/go-log`); never include secrets in error strings
|
||||
- **Randomness**: `crypto/rand` only; never `math/rand`
|
||||
- **Conventional commits**: `feat(auth):`, `fix(crypt):`, `refactor(trust):`
|
||||
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
|
||||
|
||||
- **Repo**: `dappco.re/go/core/crypt`
|
||||
- **Repo**: `forge.lthn.ai/core/go-crypt`
|
||||
- **Push via SSH**: `git push forge main`
|
||||
(remote: `ssh://git@forge.lthn.ai:2223/core/go-crypt.git`)
|
||||
|
|
|
|||
88
auth/auth.go
88
auth/auth.go
|
|
@ -30,16 +30,18 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt"
|
||||
"dappco.re/go/core/crypt/crypt/lthn"
|
||||
"dappco.re/go/core/crypt/crypt/pgp"
|
||||
"dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
|
||||
"forge.lthn.ai/core/go-crypt/crypt"
|
||||
"forge.lthn.ai/core/go-crypt/crypt/lthn"
|
||||
"forge.lthn.ai/core/go-crypt/crypt/pgp"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
// 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 err := a.store.Delete(token); err != nil {
|
||||
return nil, coreerr.E(op, "session expired", err)
|
||||
}
|
||||
_ = a.store.Delete(token)
|
||||
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 err := a.store.Delete(token); err != nil {
|
||||
return nil, coreerr.E(op, "session expired", err)
|
||||
}
|
||||
_ = a.store.Delete(token)
|
||||
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
|
||||
if err := a.store.DeleteByUser(userID); err != nil {
|
||||
return coreerr.E(op, "failed to delete user sessions", err)
|
||||
}
|
||||
_ = a.store.DeleteByUser(userID)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(storedHash, "$argon2id$") {
|
||||
return nil, coreerr.E(op, "corrupted password hash", nil)
|
||||
if strings.HasPrefix(storedHash, "$argon2id$") {
|
||||
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"))
|
||||
if err != nil {
|
||||
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
|
||||
if err := a.store.DeleteByUser(userID); err != nil {
|
||||
return coreerr.E(op, "failed to delete user sessions", err)
|
||||
}
|
||||
_ = a.store.DeleteByUser(userID)
|
||||
|
||||
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).
|
||||
// Returns nil on success, or an error describing the failure.
|
||||
func (a *Authenticator) verifyPassword(userID, password string) error {
|
||||
const op = "auth.verifyPassword"
|
||||
|
||||
// Try Argon2id hash first (.hash file)
|
||||
if a.medium.IsFile(userPath(userID, ".hash")) {
|
||||
storedHash, err := a.medium.Read(userPath(userID, ".hash"))
|
||||
if err != nil {
|
||||
return coreerr.E(op, "failed to read password hash", err)
|
||||
if err == nil && strings.HasPrefix(storedHash, "$argon2id$") {
|
||||
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"))
|
||||
if err != nil {
|
||||
return coreerr.E(op, "user not found", nil)
|
||||
return errors.New("user not found")
|
||||
}
|
||||
if !lthn.Verify(password, storedHash) {
|
||||
return coreerr.E(op, "invalid password", nil)
|
||||
return errors.New("invalid password")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -687,11 +671,9 @@ func (a *Authenticator) verifyPassword(userID, password string) error {
|
|||
// createSession generates a cryptographically random session token and
|
||||
// stores the session via the SessionStore.
|
||||
func (a *Authenticator) createSession(userID string) (*Session, error) {
|
||||
const op = "auth.createSession"
|
||||
|
||||
tokenBytes := make([]byte, 32)
|
||||
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{
|
||||
|
|
@ -701,7 +683,7 @@ func (a *Authenticator) createSession(userID string) (*Session, error) {
|
|||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -11,9 +11,9 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt/lthn"
|
||||
"dappco.re/go/core/crypt/crypt/pgp"
|
||||
"dappco.re/go/core/io"
|
||||
"forge.lthn.ai/core/go-crypt/crypt/lthn"
|
||||
"forge.lthn.ai/core/go-crypt/crypt/pgp"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
// helper creates a fresh Authenticator backed by MockMedium.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,14 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type SessionStore interface {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt/lthn"
|
||||
"dappco.re/go/core/io"
|
||||
"forge.lthn.ai/core/go-crypt/crypt/lthn"
|
||||
"forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
// --- MemorySessionStore ---
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import (
|
|||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-crypt/crypt"
|
||||
)
|
||||
|
||||
// Checksum command flags
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ package crypt
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt"
|
||||
coreio "dappco.re/go/core/io"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-crypt/crypt"
|
||||
)
|
||||
|
||||
// Encrypt command flags
|
||||
|
|
@ -53,11 +53,10 @@ func runEncrypt(path string) error {
|
|||
return cli.Err("passphrase cannot be empty")
|
||||
}
|
||||
|
||||
raw, err := coreio.Local.Read(path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to read file")
|
||||
}
|
||||
data := []byte(raw)
|
||||
|
||||
var encrypted []byte
|
||||
if encryptAES {
|
||||
|
|
@ -70,7 +69,7 @@ func runEncrypt(path string) error {
|
|||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
|
|
@ -87,11 +86,10 @@ func runDecrypt(path string) error {
|
|||
return cli.Err("passphrase cannot be empty")
|
||||
}
|
||||
|
||||
raw, err := coreio.Local.Read(path)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "failed to read file")
|
||||
}
|
||||
data := []byte(raw)
|
||||
|
||||
var decrypted []byte
|
||||
if encryptAES {
|
||||
|
|
@ -108,7 +106,7 @@ func runDecrypt(path string) error {
|
|||
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")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ package crypt
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
"forge.lthn.ai/core/go-crypt/crypt"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@
|
|||
package testcmd
|
||||
|
||||
import (
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
// Style aliases from shared
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core/i18n"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
type packageCoverage struct {
|
||||
|
|
@ -33,8 +33,8 @@ func parseTestOutput(output string) testResults {
|
|||
results := testResults{}
|
||||
|
||||
// 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 dappco.re/go/core/crypt/crypt (cached) coverage: 91.2% of statements
|
||||
// Example: ok forge.lthn.ai/core/go-crypt/crypt 0.015s 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.]+)%)?`)
|
||||
failPattern := regexp.MustCompile(`^FAIL\s+(\S+)`)
|
||||
skipPattern := regexp.MustCompile(`^\?\s+(\S+)\s+\[no test files\]`)
|
||||
|
|
@ -171,15 +171,15 @@ func formatCoverage(cov float64) string {
|
|||
}
|
||||
|
||||
func shortenPackageName(name string) string {
|
||||
const modulePrefix = "dappco.re/go/"
|
||||
if strings.HasPrefix(name, modulePrefix) {
|
||||
remainder := strings.TrimPrefix(name, modulePrefix)
|
||||
// If there's a sub-path (e.g. "go/pkg/foo"), strip the module name
|
||||
if idx := strings.Index(remainder, "/"); idx >= 0 {
|
||||
return remainder[idx+1:]
|
||||
// Remove common prefixes
|
||||
prefixes := []string{
|
||||
"forge.lthn.ai/core/cli/",
|
||||
"forge.lthn.ai/core/gui/",
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package testcmd
|
|||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
|
@ -9,14 +10,13 @@ import (
|
|||
"runtime"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core/i18n"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"forge.lthn.ai/core/go-i18n"
|
||||
)
|
||||
|
||||
func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error {
|
||||
// Detect if we're in a Go project
|
||||
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
|
||||
|
|
@ -49,11 +49,7 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
|
|||
|
||||
// Create command
|
||||
cmd := exec.Command("go", args...)
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return coreerr.E("cmd.test", "failed to determine working directory", err)
|
||||
}
|
||||
cmd.Dir = cwd
|
||||
cmd.Dir, _ = os.Getwd()
|
||||
|
||||
// Set environment to suppress macOS linker warnings
|
||||
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
|
||||
}
|
||||
|
||||
err = cmd.Run()
|
||||
err := cmd.Run()
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
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
|
||||
printJSONResults(results, exitCode)
|
||||
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
|
||||
}
|
||||
|
|
@ -114,7 +110,7 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
|
|||
|
||||
if exitCode != 0 {
|
||||
fmt.Printf("\n%s %s\n", testFailStyle.Render(i18n.T("cli.fail")), i18n.T("cmd.test.tests_failed"))
|
||||
return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), nil)
|
||||
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"))
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
)
|
||||
|
||||
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, "bar", shortenPackageName("github.com/other/bar"))
|
||||
}
|
||||
|
|
@ -19,16 +19,16 @@ func TestFormatCoverageTest(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestParseTestOutput(t *testing.T) {
|
||||
output := `ok dappco.re/go/core/pkg/foo 0.100s coverage: 50.0% of statements
|
||||
FAIL dappco.re/go/core/pkg/bar
|
||||
? dappco.re/go/core/pkg/baz [no test files]
|
||||
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements
|
||||
FAIL forge.lthn.ai/core/go/pkg/bar
|
||||
? forge.lthn.ai/core/go/pkg/baz [no test files]
|
||||
`
|
||||
results := parseTestOutput(output)
|
||||
assert.Equal(t, 1, results.passed)
|
||||
assert.Equal(t, 1, results.failed)
|
||||
assert.Equal(t, 1, results.skipped)
|
||||
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, 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
|
||||
results := testResults{
|
||||
packages: []packageCoverage{
|
||||
{name: "dappco.re/go/core/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/short", coverage: 100, 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,
|
||||
totalCov: 180,
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
|
|
@ -27,23 +25,21 @@ func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
|||
|
||||
// Decrypt decrypts data using ChaCha20-Poly1305.
|
||||
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
const op = "chachapoly.Decrypt"
|
||||
|
||||
aead, err := chacha20poly1305.NewX(key)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "failed to create cipher", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
minLen := aead.NonceSize() + aead.Overhead()
|
||||
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():]
|
||||
|
||||
decrypted, err := aead.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, coreerr.E(op, "decryption failed", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(decrypted) == 0 {
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ package chachapoly
|
|||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -13,7 +12,7 @@ import (
|
|||
type mockReader struct{}
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -7,20 +7,20 @@ import (
|
|||
"io"
|
||||
"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.
|
||||
func SHA256File(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
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() }()
|
||||
|
||||
h := sha256.New()
|
||||
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
|
||||
|
|
@ -30,13 +30,13 @@ func SHA256File(path string) (string, error) {
|
|||
func SHA512File(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
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() }()
|
||||
|
||||
h := sha512.New()
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
coreerr "dappco.re/go/core/log"
|
||||
core "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
// Encrypt encrypts data with a passphrase using ChaCha20-Poly1305.
|
||||
|
|
@ -10,14 +10,14 @@ import (
|
|||
func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
|
||||
salt, err := generateSalt(argon2SaltLen)
|
||||
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)
|
||||
|
||||
encrypted, err := ChaCha20Encrypt(plaintext, key)
|
||||
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)
|
||||
|
|
@ -31,7 +31,7 @@ func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
|
|||
// Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
|
||||
func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
|
||||
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]
|
||||
|
|
@ -41,7 +41,7 @@ func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
|
|||
|
||||
plaintext, err := ChaCha20Decrypt(encrypted, key)
|
||||
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
|
||||
|
|
@ -53,14 +53,14 @@ func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
|
|||
func EncryptAES(plaintext, passphrase []byte) ([]byte, error) {
|
||||
salt, err := generateSalt(argon2SaltLen)
|
||||
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)
|
||||
|
||||
encrypted, err := AESGCMEncrypt(plaintext, key)
|
||||
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))
|
||||
|
|
@ -73,7 +73,7 @@ func EncryptAES(plaintext, passphrase []byte) ([]byte, error) {
|
|||
// Expects format: salt (16 bytes) + nonce (12 bytes) + ciphertext.
|
||||
func DecryptAES(ciphertext, passphrase []byte) ([]byte, error) {
|
||||
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]
|
||||
|
|
@ -83,7 +83,7 @@ func DecryptAES(ciphertext, passphrase []byte) ([]byte, error) {
|
|||
|
||||
plaintext, err := AESGCMDecrypt(encrypted, key)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,8 +6,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
core "forge.lthn.ai/core/go-log"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
|
@ -17,7 +16,7 @@ import (
|
|||
func HashPassword(password string) (string, error) {
|
||||
salt, err := generateSalt(argon2SaltLen)
|
||||
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)
|
||||
|
|
@ -37,29 +36,29 @@ func HashPassword(password string) (string, error) {
|
|||
func VerifyPassword(password, hash string) (bool, error) {
|
||||
parts := strings.Split(hash, "$")
|
||||
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
|
||||
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 time uint32
|
||||
var parallelism uint8
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, ¶llelism); 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])
|
||||
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])
|
||||
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)))
|
||||
|
|
@ -72,7 +71,7 @@ func VerifyPassword(password, hash string) (bool, error) {
|
|||
func HashBcrypt(password string, cost int) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
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
|
||||
}
|
||||
|
|
@ -84,7 +83,7 @@ func VerifyBcrypt(password, hash string) (bool, error) {
|
|||
return false, 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,7 @@ import (
|
|||
"crypto/sha256"
|
||||
"io"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
core "forge.lthn.ai/core/go-log"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/hkdf"
|
||||
"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) {
|
||||
key, err := scrypt.Key(passphrase, salt, 32768, 8, 1, keyLen)
|
||||
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
|
||||
}
|
||||
|
|
@ -46,7 +45,7 @@ func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error) {
|
|||
reader := hkdf.New(sha256.New, secret, salt, info)
|
||||
key := make([]byte, keyLen)
|
||||
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
|
||||
}
|
||||
|
|
@ -55,7 +54,7 @@ func HKDF(secret, salt, info []byte, keyLen int) ([]byte, error) {
|
|||
func generateSalt(length int) ([]byte, error) {
|
||||
salt := make([]byte, length)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,15 +6,15 @@ import (
|
|||
goio "io"
|
||||
"strings"
|
||||
|
||||
framework "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"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 {
|
||||
core *framework.Core
|
||||
}
|
||||
|
|
@ -36,19 +36,19 @@ func (s *Service) CreateKeyPair(name, passphrase string) (string, error) {
|
|||
|
||||
entity, err := openpgp.NewEntity(name, "Workspace Key", "", config)
|
||||
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
|
||||
if passphrase != "" {
|
||||
err = entity.PrivateKey.Encrypt([]byte(passphrase))
|
||||
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 {
|
||||
err = subkey.PrivateKey.Encrypt([]byte(passphrase))
|
||||
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
|
||||
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, 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
|
||||
err = serializeEntity(w, entity)
|
||||
// Manual serialization to avoid panic from re-signing encrypted keys
|
||||
err = s.serializeEntity(w, entity)
|
||||
if err != nil {
|
||||
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()
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// serializeEntity manually serialises an OpenPGP entity to avoid re-signing.
|
||||
func serializeEntity(w goio.Writer, e *openpgp.Entity) error {
|
||||
// serializeEntity manually serializes an OpenPGP entity to avoid re-signing.
|
||||
func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error {
|
||||
err := e.PrivateKey.Serialize(w)
|
||||
if err != nil {
|
||||
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) {
|
||||
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath))
|
||||
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
|
||||
armoredWriter, err := armor.Encode(&armoredBuf, "PGP MESSAGE", 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
|
||||
|
|
@ -119,14 +119,14 @@ func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opt
|
|||
w, err := openpgp.Encrypt(mw, entityList, nil, nil, nil)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
w.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()
|
||||
|
|
@ -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) {
|
||||
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey))
|
||||
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]
|
||||
if entity.PrivateKey.Encrypted {
|
||||
err = entity.PrivateKey.Decrypt([]byte(passphrase))
|
||||
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 {
|
||||
if err := subkey.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
|
||||
return "", coreerr.E("openpgp.DecryptPGP", "failed to decrypt subkey", err)
|
||||
}
|
||||
_ = subkey.PrivateKey.Decrypt([]byte(passphrase))
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt armored message
|
||||
block, err := armor.Decode(strings.NewReader(message))
|
||||
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)
|
||||
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
|
||||
_, err = goio.Copy(&buf, md.UnverifiedBody)
|
||||
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
|
||||
|
|
@ -190,3 +188,6 @@ func (s *Service) HandleIPCEvents(c *framework.Core, msg framework.Message) erro
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure Service implements framework.Crypt.
|
||||
var _ framework.Crypt = (*Service)(nil)
|
||||
|
|
|
|||
|
|
@ -4,40 +4,40 @@ import (
|
|||
"bytes"
|
||||
"testing"
|
||||
|
||||
framework "dappco.re/go/core"
|
||||
framework "forge.lthn.ai/core/go/pkg/core"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateKeyPair(t *testing.T) {
|
||||
c := framework.New()
|
||||
c, _ := framework.New()
|
||||
s := &Service{core: c}
|
||||
|
||||
privKey, err := s.CreateKeyPair("test user", "password123")
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, privKey)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, privKey)
|
||||
assert.Contains(t, privKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----")
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
c := framework.New()
|
||||
c, _ := framework.New()
|
||||
s := &Service{core: c}
|
||||
|
||||
passphrase := "secret"
|
||||
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
|
||||
|
||||
data := "hello openpgp"
|
||||
var buf bytes.Buffer
|
||||
armored, err := s.EncryptPGP(&buf, publicKey, data)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, armored)
|
||||
assert.NotEmpty(t, buf.String())
|
||||
|
||||
decrypted, err := s.DecryptPGP(privKey, armored, passphrase)
|
||||
require.NoError(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, data, decrypted)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ package pgp
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
"github.com/ProtonMail/go-crypto/openpgp"
|
||||
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
||||
"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.
|
||||
// Returns a KeyPair with armored public and private keys.
|
||||
func CreateKeyPair(name, email, password string) (*KeyPair, error) {
|
||||
const op = "pgp.CreateKeyPair"
|
||||
|
||||
entity, err := openpgp.NewEntity(name, "", email, 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
|
||||
for _, id := range entity.Identities {
|
||||
if err := id.SelfSignature.SignUserId(id.UserId.Id, entity.PrimaryKey, entity.PrivateKey, nil); err != nil {
|
||||
return nil, coreerr.E(op, "failed to sign identity", err)
|
||||
}
|
||||
_ = id.SelfSignature.SignUserId(id.UserId.Id, entity.PrimaryKey, entity.PrivateKey, nil)
|
||||
}
|
||||
|
||||
// Encrypt private key with password if provided
|
||||
if password != "" {
|
||||
err = entity.PrivateKey.Encrypt([]byte(password))
|
||||
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 {
|
||||
err = subkey.PrivateKey.Encrypt([]byte(password))
|
||||
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)
|
||||
pubKeyWriter, err := armor.Encode(pubKeyBuf, openpgp.PublicKeyType, 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 {
|
||||
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()
|
||||
|
||||
|
|
@ -69,18 +65,18 @@ func CreateKeyPair(name, email, password string) (*KeyPair, error) {
|
|||
privKeyBuf := new(bytes.Buffer)
|
||||
privKeyWriter, err := armor.Encode(privKeyBuf, openpgp.PrivateKeyType, 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 != "" {
|
||||
// Manual serialization to avoid re-signing encrypted keys
|
||||
if err := serializeEncryptedEntity(privKeyWriter, entity); err != nil {
|
||||
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 {
|
||||
if err := entity.SerializePrivate(privKeyWriter, nil); err != nil {
|
||||
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()
|
||||
|
|
@ -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.
|
||||
// Returns the encrypted data as armored PGP output.
|
||||
func Encrypt(data []byte, publicKeyArmor string) ([]byte, error) {
|
||||
const op = "pgp.Encrypt"
|
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor)))
|
||||
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)
|
||||
armoredWriter, err := armor.Encode(buf, "PGP MESSAGE", 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)
|
||||
if err != nil {
|
||||
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 {
|
||||
w.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()
|
||||
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.
|
||||
// If the private key is encrypted, the password is used to decrypt it first.
|
||||
func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
|
||||
const op = "pgp.Decrypt"
|
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor)))
|
||||
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
|
||||
for _, entity := range keyring {
|
||||
if entity.PrivateKey != nil && entity.PrivateKey.Encrypted {
|
||||
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 {
|
||||
if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted {
|
||||
if err := subkey.PrivateKey.Decrypt([]byte(password)); err != nil {
|
||||
return nil, coreerr.E(op, "failed to decrypt subkey", err)
|
||||
}
|
||||
_ = subkey.PrivateKey.Decrypt([]byte(password))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -178,17 +168,17 @@ func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) {
|
|||
// Decode armored message
|
||||
block, err := armor.Decode(bytes.NewReader(data))
|
||||
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)
|
||||
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)
|
||||
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
|
||||
|
|
@ -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
|
||||
// to decrypt it first.
|
||||
func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) {
|
||||
const op = "pgp.Sign"
|
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor)))
|
||||
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]
|
||||
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 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{}
|
||||
err = openpgp.ArmoredDetachSign(buf, signer, bytes.NewReader(data), config)
|
||||
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
|
||||
|
|
@ -229,16 +217,14 @@ func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) {
|
|||
// Verify verifies an armored detached signature against the given data
|
||||
// and armored public key. Returns nil if the signature is valid.
|
||||
func Verify(data, signature []byte, publicKeyArmor string) error {
|
||||
const op = "pgp.Verify"
|
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor)))
|
||||
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)
|
||||
if err != nil {
|
||||
return coreerr.E(op, "signature verification failed", err)
|
||||
return fmt.Errorf("pgp: signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ import (
|
|||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// Service provides RSA functionality.
|
||||
|
|
@ -21,14 +20,12 @@ func NewService() *Service {
|
|||
|
||||
// GenerateKeyPair creates a new RSA key pair.
|
||||
func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err error) {
|
||||
const op = "rsa.GenerateKeyPair"
|
||||
|
||||
if bits < 2048 {
|
||||
return nil, nil, coreerr.E(op, fmt.Sprintf("key size too small: %d (minimum 2048)", bits), nil)
|
||||
return nil, nil, fmt.Errorf("rsa: key size too small: %d (minimum 2048)", bits)
|
||||
}
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
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)
|
||||
|
|
@ -39,7 +36,7 @@ func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err e
|
|||
|
||||
pubKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey)
|
||||
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{
|
||||
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.
|
||||
func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
|
||||
const op = "rsa.Encrypt"
|
||||
|
||||
block, _ := pem.Decode(publicKey)
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
|
|
@ -78,21 +73,19 @@ func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
|
|||
|
||||
// Decrypt decrypts data with a private key.
|
||||
func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error) {
|
||||
const op = "rsa.Decrypt"
|
||||
|
||||
block, _ := pem.Decode(privateKey)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ import (
|
|||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -17,7 +16,7 @@ import (
|
|||
type mockReader struct{}
|
||||
|
||||
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) {
|
||||
|
|
@ -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)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Key generation with broken reader — Go 1.26+ rsa.GenerateKey may
|
||||
// recover from reader errors internally, so we only verify it doesn't panic.
|
||||
// Key generation failure
|
||||
oldReader := rand.Reader
|
||||
rand.Reader = &mockReader{}
|
||||
t.Cleanup(func() { rand.Reader = oldReader })
|
||||
_, _, _ = s.GenerateKeyPair(2048)
|
||||
_, _, err = s.GenerateKeyPair(2048)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Encrypt with non-RSA key
|
||||
rand.Reader = oldReader // Restore reader for this test
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@ import (
|
|||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
core "forge.lthn.ai/core/go-log"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
|
|
@ -16,12 +15,12 @@ import (
|
|||
func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) {
|
||||
aead, err := chacha20poly1305.NewX(key)
|
||||
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())
|
||||
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)
|
||||
|
|
@ -33,18 +32,18 @@ func ChaCha20Encrypt(plaintext, key []byte) ([]byte, error) {
|
|||
func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) {
|
||||
aead, err := chacha20poly1305.NewX(key)
|
||||
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()
|
||||
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:]
|
||||
plaintext, err := aead.Open(nil, nonce, encrypted, 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
|
||||
|
|
@ -56,17 +55,17 @@ func ChaCha20Decrypt(ciphertext, key []byte) ([]byte, error) {
|
|||
func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
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)
|
||||
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())
|
||||
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)
|
||||
|
|
@ -78,23 +77,23 @@ func AESGCMEncrypt(plaintext, key []byte) ([]byte, error) {
|
|||
func AESGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
|
||||
block, err := aes.NewCipher(key)
|
||||
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)
|
||||
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()
|
||||
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:]
|
||||
plaintext, err := aead.Open(nil, nonce, encrypted, 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
`len(agent.ScopedRepos) == 0`.
|
||||
A Tier 2 agent with empty `ScopedRepos` (nil or `[]string{}`) was previously treated as
|
||||
unrestricted rather than as having no access.
|
||||
In `policy.go`, the repo scope check is conditioned on `len(agent.ScopedRepos) > 0`.
|
||||
A Tier 2 agent with empty `ScopedRepos` (nil or `[]string{}`) is treated as
|
||||
unrestricted rather than as having no access. If an admin registers a Tier 2
|
||||
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:
|
||||
- `[]string{}` / `nil` now denies all repo-scoped access by default.
|
||||
- `[]string{"*"}` grants unrestricted repo access.
|
||||
- Pattern matching with `host-uk/*` and `host-uk/**` still applies as before.
|
||||
Potential remediation: treat empty `ScopedRepos` as no access for Tier 2 agents,
|
||||
requiring explicit `["*"]` or `["org/**"]` for unrestricted access. This is a
|
||||
design decision with backward-compatibility implications.
|
||||
|
||||
### 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.
|
||||
- **Hardware key backends**: implement `HardwareKey` for PKCS#11 (via
|
||||
`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
|
||||
`slog.Logger` option on `Authenticator`.
|
||||
- **Rate limiting enforcement**: the `Agent.RateLimit` field is stored in the
|
||||
|
|
|
|||
24
go.mod
24
go.mod
|
|
@ -1,24 +1,22 @@
|
|||
module dappco.re/go/core/crypt
|
||||
module forge.lthn.ai/core/go-crypt
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.5.0
|
||||
dappco.re/go/core/i18n v0.2.0
|
||||
dappco.re/go/core/io v0.2.0
|
||||
dappco.re/go/core/log v0.1.0
|
||||
forge.lthn.ai/core/cli v0.3.7
|
||||
forge.lthn.ai/core/go-store v0.1.10
|
||||
forge.lthn.ai/core/cli v0.3.1
|
||||
forge.lthn.ai/core/go v0.3.1
|
||||
forge.lthn.ai/core/go-i18n v0.1.4
|
||||
forge.lthn.ai/core/go-io v0.1.2
|
||||
forge.lthn.ai/core/go-log v0.0.4
|
||||
forge.lthn.ai/core/go-store v0.1.6
|
||||
github.com/ProtonMail/go-crypto v1.4.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
)
|
||||
|
||||
require (
|
||||
forge.lthn.ai/core/go v0.3.2 // indirect
|
||||
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.7 // indirect
|
||||
forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
forge.lthn.ai/core/go-inference v0.1.4 // indirect
|
||||
forge.lthn.ai/core/go-process v0.2.2 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10 // 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/pflag v1.0.10 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
|
@ -56,5 +54,5 @@ require (
|
|||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // 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
36
go.sum
|
|
@ -1,23 +1,19 @@
|
|||
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
|
||||
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
|
||||
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
|
||||
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
|
||||
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
|
||||
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/cli v0.3.1 h1:ZpHhaDrdbaV98JDxj/f0E5nytYk9tTMRu3qohGyK4M0=
|
||||
forge.lthn.ai/core/cli v0.3.1/go.mod h1:28cOl9eK0H033Otkjrv9f/QCmtHcJl+IIx4om8JskOg=
|
||||
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM=
|
||||
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
|
||||
forge.lthn.ai/core/go-i18n v0.1.4 h1:zOHUUJDgRo88/3tj++kN+VELg/buyZ4T2OSdG3HBbLQ=
|
||||
forge.lthn.ai/core/go-i18n v0.1.4/go.mod h1:aDyAfz7MMgWYgLkZCptfFmZ7jJg3ocwjEJ1WkJSvv4U=
|
||||
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0=
|
||||
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
|
||||
forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA=
|
||||
forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g=
|
||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
||||
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||
forge.lthn.ai/core/go-store v0.1.10 h1:JLyf8xMR3V6PfBAW1kv6SJeHsYY93LacEBpTFW657qE=
|
||||
forge.lthn.ai/core/go-store v0.1.10/go.mod h1:VNnHh94TMD3+L+sSgvxn0GHtDKhJR8FD6JiuIuRtjuk=
|
||||
forge.lthn.ai/core/go-process v0.2.2 h1:bnHFtzg92udochDDB6bD2luzzmr9ETKWmGzSsGjFFYE=
|
||||
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/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||
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/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
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.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
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/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (string, error) {
|
||||
if agent == "" {
|
||||
return "", coreerr.E("trust.ApprovalQueue.Submit", "agent name is required", nil)
|
||||
return "", errors.New("trust.ApprovalQueue.Submit: agent name is required")
|
||||
}
|
||||
if cap == "" {
|
||||
return "", coreerr.E("trust.ApprovalQueue.Submit", "capability is required", nil)
|
||||
return "", errors.New("trust.ApprovalQueue.Submit: capability is required")
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
|
|
@ -107,10 +106,10 @@ func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) err
|
|||
|
||||
req, ok := q.requests[id]
|
||||
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 {
|
||||
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
|
||||
|
|
@ -128,10 +127,10 @@ func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error
|
|||
|
||||
req, ok := q.requests[id]
|
||||
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 {
|
||||
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
|
||||
|
|
@ -151,8 +150,8 @@ func (q *ApprovalQueue) Get(id string) *ApprovalRequest {
|
|||
return nil
|
||||
}
|
||||
// Return a copy to prevent mutation.
|
||||
snapshot := *req
|
||||
return &snapshot
|
||||
copy := *req
|
||||
return ©
|
||||
}
|
||||
|
||||
// Pending returns all requests with ApprovalPending status.
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@ package trust
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"iter"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// AuditEntry records a single policy evaluation for compliance.
|
||||
|
|
@ -45,7 +44,7 @@ func (d *Decision) UnmarshalJSON(data []byte) error {
|
|||
case "needs_approval":
|
||||
*d = NeedsApproval
|
||||
default:
|
||||
return coreerr.E("trust.Decision.UnmarshalJSON", "unknown decision: "+s, nil)
|
||||
return fmt.Errorf("trust: unknown decision %q", s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -84,11 +83,11 @@ func (l *AuditLog) Record(result EvalResult, repo string) error {
|
|||
if l.writer != nil {
|
||||
data, err := json.Marshal(entry)
|
||||
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')
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// PolicyConfig is the JSON-serialisable representation of a trust policy.
|
||||
|
|
@ -26,7 +24,7 @@ type PoliciesConfig struct {
|
|||
func LoadPoliciesFromFile(path string) ([]Policy, error) {
|
||||
f, err := os.Open(path)
|
||||
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()
|
||||
return LoadPolicies(f)
|
||||
|
|
@ -34,21 +32,12 @@ func LoadPoliciesFromFile(path string) ([]Policy, error) {
|
|||
|
||||
// LoadPolicies reads JSON from a reader and returns parsed policies.
|
||||
func LoadPolicies(r io.Reader) ([]Policy, error) {
|
||||
const op = "trust.LoadPolicies"
|
||||
|
||||
var cfg PoliciesConfig
|
||||
dec := json.NewDecoder(r)
|
||||
dec.DisallowUnknownFields()
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +48,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
|
|||
for i, pc := range cfg.Policies {
|
||||
tier := Tier(pc.Tier)
|
||||
if !tier.Valid() {
|
||||
return nil, coreerr.E("trust.LoadPolicies", fmt.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil)
|
||||
return nil, fmt.Errorf("trust.LoadPolicies: invalid tier %d at index %d", pc.Tier, i)
|
||||
}
|
||||
|
||||
p := Policy{
|
||||
|
|
@ -83,7 +72,7 @@ func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
|
|||
}
|
||||
for _, p := range policies {
|
||||
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
|
||||
|
|
@ -93,7 +82,7 @@ func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
|
|||
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return coreerr.E("trust.ApplyPoliciesFromFile", "failed to open file", err)
|
||||
return fmt.Errorf("trust.ApplyPoliciesFromFile: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
return pe.ApplyPolicies(f)
|
||||
|
|
@ -118,7 +107,7 @@ func (pe *PolicyEngine) ExportPolicies(w io.Writer) error {
|
|||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,6 @@ import (
|
|||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// 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.
|
||||
for _, allowed := range policy.Allowed {
|
||||
if allowed == cap {
|
||||
// For repo-scoped capabilities, verify repo access for restricted tiers.
|
||||
if isRepoScoped(cap) && agent.Tier != TierFull {
|
||||
if len(agent.ScopedRepos) == 0 || !repoAllowed(agent.ScopedRepos, repo) {
|
||||
// For repo-scoped capabilities, verify repo access.
|
||||
if isRepoScoped(cap) && len(agent.ScopedRepos) > 0 {
|
||||
if !repoAllowed(agent.ScopedRepos, repo) {
|
||||
return EvalResult{
|
||||
Decision: Deny,
|
||||
Agent: agentName,
|
||||
|
|
@ -148,7 +146,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
|
|||
// SetPolicy replaces the policy for a given tier.
|
||||
func (pe *PolicyEngine) SetPolicy(p Policy) error {
|
||||
if !p.Tier.Valid() {
|
||||
return coreerr.E("trust.SetPolicy", fmt.Sprintf("invalid tier %d", p.Tier), nil)
|
||||
return fmt.Errorf("trust.SetPolicy: invalid tier %d", p.Tier)
|
||||
}
|
||||
pe.policies[p.Tier] = &p
|
||||
return nil
|
||||
|
|
@ -247,11 +245,6 @@ func matchScope(pattern, repo string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
// Star means unrestricted access for all repos.
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for wildcard patterns.
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return false
|
||||
|
|
|
|||
|
|
@ -270,49 +270,34 @@ func TestDefaultRateLimit(t *testing.T) {
|
|||
|
||||
// --- Phase 0 Additions ---
|
||||
|
||||
// TestEvaluate_Bad_Tier2EmptyScopedReposDeniesAll verifies that an empty
|
||||
// scoped-repo list blocks repo-scoped capabilities by default.
|
||||
func TestEvaluate_Bad_Tier2EmptyScopedReposDeniesAll(t *testing.T) {
|
||||
// TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll verifies that a Tier 2
|
||||
// agent with empty ScopedRepos is treated as "unrestricted" for repo-scoped
|
||||
// capabilities. NOTE: This is a potential security concern documented in
|
||||
// FINDINGS.md — empty ScopedRepos bypasses the repo scope check entirely.
|
||||
func TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
require.NoError(t, r.Register(Agent{
|
||||
Name: "Hypnos",
|
||||
Tier: TierVerified,
|
||||
ScopedRepos: []string{},
|
||||
ScopedRepos: []string{}, // empty — currently means "unrestricted"
|
||||
}))
|
||||
pe := NewPolicyEngine(r)
|
||||
|
||||
// Current behaviour: empty ScopedRepos skips scope check (len == 0)
|
||||
result := pe.Evaluate("Hypnos", CapPushRepo, "host-uk/core")
|
||||
assert.Equal(t, Deny, result.Decision,
|
||||
"empty ScopedRepos should deny repo-scoped operations by default")
|
||||
assert.Equal(t, Allow, result.Decision,
|
||||
"empty ScopedRepos currently allows all repos (potential security finding)")
|
||||
|
||||
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")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
|
||||
// Non-repo-scoped capabilities should still work
|
||||
result = pe.Evaluate("Hypnos", CapCreateIssue, "")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
}
|
||||
|
||||
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, "")
|
||||
result = pe.Evaluate("Hypnos", CapCommentIssue, "")
|
||||
assert.Equal(t, Allow, result.Decision)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,11 +13,6 @@ func TestMatchScope_Good_ExactMatch(t *testing.T) {
|
|||
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) {
|
||||
assert.True(t, matchScope("core/*", "core/php"))
|
||||
assert.True(t, matchScope("core/*", "core/go-crypt"))
|
||||
|
|
|
|||
|
|
@ -11,12 +11,11 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"iter"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// Tier represents an agent's trust level in the system.
|
||||
|
|
@ -71,9 +70,7 @@ type Agent struct {
|
|||
Name string
|
||||
// Tier is the agent's trust level.
|
||||
Tier Tier
|
||||
// ScopedRepos limits repo access for Tier 2 agents.
|
||||
// Empty means no repo access.
|
||||
// Use ["*"] for unrestricted repo scope.
|
||||
// ScopedRepos limits repo access for Tier 2 agents. Empty means no repo access.
|
||||
// Tier 3 agents ignore this field (they have access to all repos).
|
||||
ScopedRepos []string
|
||||
// RateLimit is the maximum requests per minute. 0 means unlimited.
|
||||
|
|
@ -101,10 +98,10 @@ func NewRegistry() *Registry {
|
|||
// Returns an error if the agent name is empty or the tier is invalid.
|
||||
func (r *Registry) Register(agent Agent) error {
|
||||
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() {
|
||||
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() {
|
||||
agent.CreatedAt = time.Now()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue