Compare commits

...

16 commits
v0.1.7 ... dev

Author SHA1 Message Date
Virgil
c9a7a6fb4b fix(trust): enforce scoped repository defaults
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Failing after 33s
2026-03-30 10:42:05 +00:00
86c68ad1c9 Merge pull request '[agent/codex:gpt-5.3-codex-spark] Read .core/reference/RFC-CORE-008-AGENT-EXPERIENCE.md (the A...' (#14) from main into dev
Some checks failed
Security Scan / security (push) Failing after 15s
Test / test (push) Successful in 10m52s
2026-03-29 15:26:33 +00:00
Virgil
e80ef94552 fix(crypt): align AX error handling and cleanup checks
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 9m7s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-29 15:25:12 +00:00
f37f5b3a14 Merge pull request 'Fix CodeRabbit findings' (#12) from agent/fix-coderabbit-findings--verify-each-aga into dev
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 8m44s
Reviewed-on: #12
2026-03-24 11:33:05 +00:00
12281f9e76 Merge pull request '[agent/claude] Update go.mod require lines from forge.lthn.ai to dappco.re ...' (#6) from agent/update-go-mod-require-lines-from-forge-l into main
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 10m27s
2026-03-22 01:44:22 +00:00
Snider
62482c7dc9 refactor: migrate imports to dappco.re/go/core/* paths
Some checks failed
Security Scan / security (pull_request) Failing after 9s
Test / test (pull_request) Failing after 8m29s
Update module path from forge.lthn.ai/core/go-crypt to
dappco.re/go/core/crypt. Migrate go-log, go-io, go-i18n imports to
their new dappco.re/go/core/* paths with updated versions (core v0.5.0,
log v0.1.0, io v0.2.0, i18n v0.2.0). Un-migrated modules (cli,
go-store, go-inference) remain at forge.lthn.ai paths.

Also fixes merge conflict marker and duplicate imports in
crypt/openpgp/service.go, and updates CLAUDE.md to reflect new paths.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-22 01:43:27 +00:00
Snider
69464fe503 refactor: migrate core import to dappco.re/go/core
Some checks failed
Security Scan / security (push) Failing after 10s
Test / test (push) Failing after 7m16s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:56:26 +00:00
Snider
b85319ae6b chore: sync dependencies for v0.1.12
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:54:33 +00:00
Snider
f5b4c971a2 chore: sync dependencies for v0.1.11
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-21 19:54:33 +00:00
Snider
36bf16b06e fix(coderabbit): address review findings
Some checks failed
Security Scan / security (pull_request) Failing after 8s
Test / test (pull_request) Failing after 4m46s
- auth: prevent legacy .lthn fallback when .hash file exists but is
  unreadable or has unexpected format (security fix in verifyPassword
  and Login)
- chachapoly: wrap raw error returns in Decrypt with coreerr.E()
- trust: reject trailing data in LoadPolicies JSON decoder

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 13:32:21 +00:00
e691a9ce51 Merge pull request '[agent/claude:opus] DX audit and fix. 1) Review CLAUDE.md — update any outdate...' (#3) from agent/dx-audit-and-fix--1--review-claude-md into main
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Successful in 1m15s
2026-03-17 08:03:20 +00:00
Snider
703dd4588c refactor: standardise coreerr import alias and fix shortenPackageName
Some checks failed
Security Scan / security (pull_request) Failing after 7s
Test / test (pull_request) Successful in 11m55s
- CLAUDE.md: update error convention from core.E() to coreerr.E() to
  match actual codebase usage
- Standardise go-log import alias from `core` to `coreerr` across 6
  files (crypt/symmetric.go, crypt/kdf.go, crypt/crypt.go, crypt/hash.go,
  crypt/checksum.go, crypt/openpgp/service.go) for consistency with the
  11 files already using `coreerr`
- Fix shortenPackageName to handle all forge.lthn.ai/core/* module
  prefixes instead of only cli/ and gui/, fixing TestShortenPackageName

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 07:22:34 +00:00
Snider
f4a219816a chore: sync dependencies for v0.1.10
Some checks failed
Security Scan / security (push) Failing after 13s
Test / test (push) Failing after 8m11s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 22:19:33 +00:00
Snider
60de3e1943 refactor: replace remaining fmt.Errorf/os.* with go-io/go-log conventions
Some checks failed
Security Scan / security (push) Failing after 6s
Test / test (push) Failing after 6m13s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 20:50:38 +00:00
Snider
eacbb025b3 fix(rsa): update test for Go 1.26 GenerateKey resilience
Some checks failed
Security Scan / security (push) Failing after 7s
Test / test (push) Failing after 6m11s
Go 1.26 rsa.GenerateKey recovers from reader errors internally.
Test now verifies no panic instead of expecting an error.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 18:41:47 +00:00
Snider
39643ddba0 fix: replace fmt.Errorf and errors.New with coreerr.E()
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 6m20s
Replace all fmt.Errorf/errors.New calls in auth/auth.go,
crypt/pgp/pgp.go, crypt/rsa/rsa.go, crypt/chachapoly/chachapoly.go,
and trust/trust.go with coreerr.E(op, msg, err) from go-log.
No stale pkg/framework imports found.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 18:19:51 +00:00
34 changed files with 388 additions and 286 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 `forge.lthn.ai/core/go-crypt`. Virgil (in You are a dedicated domain expert for `dappco.re/go/core/crypt`. Virgil (in
core/go) orchestrates your work. Pick up tasks in phase order, mark `[x]` when core/go) orchestrates your work. Pick up tasks in phase order, mark `[x]` when
done, commit and push. done, commit and push.
@ -39,16 +39,16 @@ go test -bench=. -benchmem ./crypt/... # Benchmarks
## Local Dependencies ## Local Dependencies
All `forge.lthn.ai/core/*` modules are resolved through the Go workspace All `dappco.re/go/core/*` and remaining `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 |
|--------|-----------|---------| |--------|-----------|---------|
| `forge.lthn.ai/core/go` | `../go` | Framework: `core.Crypt` interface, `io.Medium` | | `dappco.re/go/core` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
| `dappco.re/go/core/log` | `../go-log` | `coreerr.E()` contextual error wrapping |
| `dappco.re/go/core/io` | `../go-io` | `io.Medium` storage abstraction |
| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store (session persistence) | | `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store (session persistence) |
| `forge.lthn.ai/core/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 → forge.lthn.ai → third-party, separated by blank lines - **Imports**: stdlib → dappco.re/forge.lthn.ai → third-party, separated by blank lines
- **Errors**: use `core.E("package.Function", "lowercase message", err)` (imported - **Errors**: use `coreerr.E("package.Function", "lowercase message", err)` (imported
from `forge.lthn.ai/core/go-log`); never include secrets in error strings as `coreerr "dappco.re/go/core/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**: `forge.lthn.ai/core/go-crypt` - **Repo**: `dappco.re/go/core/crypt`
- **Push via SSH**: `git push forge main` - **Push via SSH**: `git push forge main`
(remote: `ssh://git@forge.lthn.ai:2223/core/go-crypt.git`) (remote: `ssh://git@forge.lthn.ai:2223/core/go-crypt.git`)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,8 +3,9 @@ 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"
"forge.lthn.ai/core/go-i18n" "dappco.re/go/core/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 forge.lthn.ai/core/go-crypt/crypt 0.015s coverage: 91.2% of statements // Example: ok dappco.re/go/core/crypt/crypt 0.015s coverage: 91.2% of statements
// Example: ok forge.lthn.ai/core/go-crypt/crypt (cached) coverage: 91.2% of statements // Example: ok dappco.re/go/core/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 {
// Remove common prefixes const modulePrefix = "dappco.re/go/"
prefixes := []string{ if strings.HasPrefix(name, modulePrefix) {
"forge.lthn.ai/core/cli/", remainder := strings.TrimPrefix(name, modulePrefix)
"forge.lthn.ai/core/gui/", // If there's a sub-path (e.g. "go/pkg/foo"), strip the module name
} if idx := strings.Index(remainder, "/"); idx >= 0 {
for _, prefix := range prefixes { return remainder[idx+1:]
if strings.HasPrefix(name, prefix) {
return strings.TrimPrefix(name, prefix)
} }
// Module root (e.g. "cli-php") — return as-is
return remainder
} }
return filepath.Base(name) return filepath.Base(name)
} }

View file

@ -2,7 +2,6 @@ package testcmd
import ( import (
"bufio" "bufio"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -10,13 +9,14 @@ import (
"runtime" "runtime"
"strings" "strings"
"forge.lthn.ai/core/go-i18n" "dappco.re/go/core/i18n"
coreerr "dappco.re/go/core/log"
) )
func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error { func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bool) error {
// Detect if we're in a Go project // Detect if we're in a Go project
if _, err := os.Stat("go.mod"); os.IsNotExist(err) { if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
return errors.New(i18n.T("cmd.test.error.no_go_mod")) return coreerr.E("cmd.test", i18n.T("cmd.test.error.no_go_mod"), nil)
} }
// Build command arguments // Build command arguments
@ -49,7 +49,11 @@ 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...)
cmd.Dir, _ = os.Getwd() cwd, err := 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())
@ -76,7 +80,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 {
@ -94,7 +98,7 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
// JSON output for CI/agents // JSON output for CI/agents
printJSONResults(results, exitCode) printJSONResults(results, exitCode)
if exitCode != 0 { if exitCode != 0 {
return errors.New(i18n.T("i18n.fail.run", "tests")) return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), nil)
} }
return nil return nil
} }
@ -110,7 +114,7 @@ func runTest(verbose, coverage, short bool, pkg, run string, race, jsonOutput bo
if exitCode != 0 { if exitCode != 0 {
fmt.Printf("\n%s %s\n", testFailStyle.Render(i18n.T("cli.fail")), i18n.T("cmd.test.tests_failed")) fmt.Printf("\n%s %s\n", testFailStyle.Render(i18n.T("cli.fail")), i18n.T("cmd.test.tests_failed"))
return errors.New(i18n.T("i18n.fail.run", "tests")) return coreerr.E("cmd.test", i18n.T("i18n.fail.run", "tests"), nil)
} }
fmt.Printf("\n%s %s\n", testPassStyle.Render(i18n.T("cli.pass")), i18n.T("common.result.all_passed")) fmt.Printf("\n%s %s\n", testPassStyle.Render(i18n.T("cli.pass")), i18n.T("common.result.all_passed"))

View file

@ -7,7 +7,7 @@ import (
) )
func TestShortenPackageName(t *testing.T) { func TestShortenPackageName(t *testing.T) {
assert.Equal(t, "pkg/foo", shortenPackageName("forge.lthn.ai/core/go/pkg/foo")) assert.Equal(t, "pkg/foo", shortenPackageName("dappco.re/go/core/pkg/foo"))
assert.Equal(t, "cli-php", shortenPackageName("forge.lthn.ai/core/cli-php")) assert.Equal(t, "cli-php", shortenPackageName("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 forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements output := `ok dappco.re/go/core/pkg/foo 0.100s coverage: 50.0% of statements
FAIL forge.lthn.ai/core/go/pkg/bar FAIL dappco.re/go/core/pkg/bar
? forge.lthn.ai/core/go/pkg/baz [no test files] ? dappco.re/go/core/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, "forge.lthn.ai/core/go/pkg/bar", results.failedPkgs[0]) assert.Equal(t, "dappco.re/go/core/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: "forge.lthn.ai/core/go/pkg/short", coverage: 100, hasCov: true}, {name: "dappco.re/go/core/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}, {name: "dappco.re/go/core/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,6 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
@ -25,21 +27,23 @@ 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, err return nil, coreerr.E(op, "failed to create cipher", err)
} }
minLen := aead.NonceSize() + aead.Overhead() minLen := aead.NonceSize() + aead.Overhead()
if len(ciphertext) < minLen { if len(ciphertext) < minLen {
return nil, fmt.Errorf("ciphertext too short: got %d bytes, need at least %d bytes", 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)
} }
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, err return nil, coreerr.E(op, "decryption failed", err)
} }
if len(decrypted) == 0 { if len(decrypted) == 0 {

View file

@ -2,9 +2,10 @@ 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"
) )
@ -12,7 +13,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, errors.New("read error") return 0, coreerr.E("chachapoly.mockReader.Read", "read error", nil)
} }
func TestEncryptDecrypt(t *testing.T) { func TestEncryptDecrypt(t *testing.T) {

View file

@ -7,20 +7,20 @@ import (
"io" "io"
"os" "os"
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/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 "", core.E("crypt.SHA256File", "failed to open file", err) return "", coreerr.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 "", core.E("crypt.SHA256File", "failed to read file", err) return "", coreerr.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 "", core.E("crypt.SHA512File", "failed to open file", err) return "", coreerr.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 "", core.E("crypt.SHA512File", "failed to read file", err) return "", coreerr.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 (
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/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, core.E("crypt.Encrypt", "failed to generate salt", err) return nil, coreerr.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, core.E("crypt.Encrypt", "failed to encrypt", err) return nil, coreerr.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, core.E("crypt.Decrypt", "ciphertext too short", nil) return nil, coreerr.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, core.E("crypt.Decrypt", "failed to decrypt", err) return nil, coreerr.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, core.E("crypt.EncryptAES", "failed to generate salt", err) return nil, coreerr.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, core.E("crypt.EncryptAES", "failed to encrypt", err) return nil, coreerr.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, core.E("crypt.DecryptAES", "ciphertext too short", nil) return nil, coreerr.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, core.E("crypt.DecryptAES", "failed to decrypt", err) return nil, coreerr.E("crypt.DecryptAES", "failed to decrypt", err)
} }
return plaintext, nil return plaintext, nil

View file

@ -6,7 +6,8 @@ import (
"fmt" "fmt"
"strings" "strings"
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -16,7 +17,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 "", core.E("crypt.HashPassword", "failed to generate salt", err) return "", coreerr.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)
@ -36,29 +37,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, core.E("crypt.VerifyPassword", "invalid hash format", nil) return false, coreerr.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, core.E("crypt.VerifyPassword", "failed to parse version", err) return false, coreerr.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, core.E("crypt.VerifyPassword", "failed to parse parameters", err) return false, coreerr.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, core.E("crypt.VerifyPassword", "failed to decode salt", err) return false, coreerr.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, core.E("crypt.VerifyPassword", "failed to decode hash", err) return false, coreerr.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)))
@ -71,7 +72,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 "", core.E("crypt.HashBcrypt", "failed to hash password", err) return "", coreerr.E("crypt.HashBcrypt", "failed to hash password", err)
} }
return string(hash), nil return string(hash), nil
} }
@ -83,7 +84,7 @@ func VerifyBcrypt(password, hash string) (bool, error) {
return false, nil return false, nil
} }
if err != nil { if err != nil {
return false, core.E("crypt.VerifyBcrypt", "failed to verify password", err) return false, coreerr.E("crypt.VerifyBcrypt", "failed to verify password", err)
} }
return true, nil return true, nil
} }

View file

@ -7,7 +7,8 @@ import (
"crypto/sha256" "crypto/sha256"
"io" "io"
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/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"
@ -33,7 +34,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, core.E("crypt.DeriveKeyScrypt", "failed to derive key", err) return nil, coreerr.E("crypt.DeriveKeyScrypt", "failed to derive key", err)
} }
return key, nil return key, nil
} }
@ -45,7 +46,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, core.E("crypt.HKDF", "failed to derive key", err) return nil, coreerr.E("crypt.HKDF", "failed to derive key", err)
} }
return key, nil return key, nil
} }
@ -54,7 +55,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, core.E("crypt.generateSalt", "failed to generate random salt", err) return nil, coreerr.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 implements the framework.Crypt interface using OpenPGP. // Service provides OpenPGP cryptographic operations.
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 "", core.E("openpgp.CreateKeyPair", "failed to create entity", err) return "", coreerr.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 "", core.E("openpgp.CreateKeyPair", "failed to encrypt private key", err) return "", coreerr.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 "", core.E("openpgp.CreateKeyPair", "failed to encrypt subkey", err) return "", coreerr.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 "", core.E("openpgp.CreateKeyPair", "failed to create armor encoder", err) return "", coreerr.E("openpgp.CreateKeyPair", "failed to create armor encoder", err)
} }
// Manual serialization to avoid panic from re-signing encrypted keys // Manual serialisation to avoid panic from re-signing encrypted keys
err = s.serializeEntity(w, entity) err = serializeEntity(w, entity)
if err != nil { if err != nil {
w.Close() w.Close()
return "", core.E("openpgp.CreateKeyPair", "failed to serialize private key", err) return "", coreerr.E("openpgp.CreateKeyPair", "failed to serialise private key", err)
} }
w.Close() w.Close()
return buf.String(), nil return buf.String(), nil
} }
// serializeEntity manually serializes an OpenPGP entity to avoid re-signing. // serializeEntity manually serialises an OpenPGP entity to avoid re-signing.
func (s *Service) serializeEntity(w goio.Writer, e *openpgp.Entity) error { func 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 (s *Service) 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 "", core.E("openpgp.EncryptPGP", "failed to read recipient key", err) return "", coreerr.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 "", core.E("openpgp.EncryptPGP", "failed to create armor encoder", err) return "", coreerr.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 "", core.E("openpgp.EncryptPGP", "failed to start encryption", err) return "", coreerr.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 "", core.E("openpgp.EncryptPGP", "failed to write data", err) return "", coreerr.E("openpgp.EncryptPGP", "failed to write data", err)
} }
w.Close() w.Close()
@ -139,35 +139,37 @@ 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 "", core.E("openpgp.DecryptPGP", "failed to read private key", err) return "", coreerr.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 "", core.E("openpgp.DecryptPGP", "failed to decrypt private key", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to decrypt private key", err)
} }
for _, subkey := range entity.Subkeys { for _, subkey := range entity.Subkeys {
_ = subkey.PrivateKey.Decrypt([]byte(passphrase)) if err := subkey.PrivateKey.Decrypt([]byte(passphrase)); err != nil {
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 "", core.E("openpgp.DecryptPGP", "failed to decode armored message", err) return "", coreerr.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 "", core.E("openpgp.DecryptPGP", "failed to read message", err) return "", coreerr.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 "", core.E("openpgp.DecryptPGP", "failed to read decrypted body", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to read decrypted body", err)
} }
return buf.String(), nil return buf.String(), nil
@ -188,6 +190,3 @@ 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 "forge.lthn.ai/core/go/pkg/core" framework "dappco.re/go/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")
assert.NoError(t, err) require.NoError(t, err)
assert.NotEmpty(t, privKey) require.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)
assert.NoError(t, err) require.NoError(t, err)
// In this simple test, the public key is also in the armored private key string // ReadArmoredKeyRing extracts public keys from armored private key blocks
// (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)
assert.NoError(t, err) require.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)
assert.NoError(t, err) require.NoError(t, err)
assert.Equal(t, data, decrypted) assert.Equal(t, data, decrypted)
} }

View file

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

View file

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

View file

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

View file

@ -5,7 +5,8 @@ import (
"crypto/cipher" "crypto/cipher"
"crypto/rand" "crypto/rand"
core "forge.lthn.ai/core/go-log" coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
@ -15,12 +16,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, core.E("crypt.ChaCha20Encrypt", "failed to create cipher", err) return nil, coreerr.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, core.E("crypt.ChaCha20Encrypt", "failed to generate nonce", err) return nil, coreerr.E("crypt.ChaCha20Encrypt", "failed to generate nonce", err)
} }
ciphertext := aead.Seal(nonce, nonce, plaintext, nil) ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
@ -32,18 +33,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, core.E("crypt.ChaCha20Decrypt", "failed to create cipher", err) return nil, coreerr.E("crypt.ChaCha20Decrypt", "failed to create cipher", err)
} }
nonceSize := aead.NonceSize() nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize { if len(ciphertext) < nonceSize {
return nil, core.E("crypt.ChaCha20Decrypt", "ciphertext too short", nil) return nil, coreerr.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, core.E("crypt.ChaCha20Decrypt", "failed to decrypt", err) return nil, coreerr.E("crypt.ChaCha20Decrypt", "failed to decrypt", err)
} }
return plaintext, nil return plaintext, nil
@ -55,17 +56,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, core.E("crypt.AESGCMEncrypt", "failed to create cipher", err) return nil, coreerr.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, core.E("crypt.AESGCMEncrypt", "failed to create GCM", err) return nil, coreerr.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, core.E("crypt.AESGCMEncrypt", "failed to generate nonce", err) return nil, coreerr.E("crypt.AESGCMEncrypt", "failed to generate nonce", err)
} }
ciphertext := aead.Seal(nonce, nonce, plaintext, nil) ciphertext := aead.Seal(nonce, nonce, plaintext, nil)
@ -77,23 +78,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, core.E("crypt.AESGCMDecrypt", "failed to create cipher", err) return nil, coreerr.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, core.E("crypt.AESGCMDecrypt", "failed to create GCM", err) return nil, coreerr.E("crypt.AESGCMDecrypt", "failed to create GCM", err)
} }
nonceSize := aead.NonceSize() nonceSize := aead.NonceSize()
if len(ciphertext) < nonceSize { if len(ciphertext) < nonceSize {
return nil, core.E("crypt.AESGCMDecrypt", "ciphertext too short", nil) return nil, coreerr.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, core.E("crypt.AESGCMDecrypt", "failed to decrypt", err) return nil, coreerr.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) — Open ### Finding F3: Empty ScopedRepos Bypasses Scope Check on Tier 2 (Medium) — RESOLVED
In `policy.go`, the repo scope check is conditioned on `len(agent.ScopedRepos) > 0`. In `policy.go`, repo-scoped capability access previously skipped checks when
A Tier 2 agent with empty `ScopedRepos` (nil or `[]string{}`) is treated as `len(agent.ScopedRepos) == 0`.
unrestricted rather than as having no access. If an admin registers a Tier 2 A Tier 2 agent with empty `ScopedRepos` (nil or `[]string{}`) was previously treated as
agent without explicitly setting `ScopedRepos`, it gets access to all repositories unrestricted rather than as having no access.
for repo-scoped capabilities (`repo.push`, `pr.create`, `pr.merge`, `secrets.read`).
Potential remediation: treat empty `ScopedRepos` as no access for Tier 2 agents, Resolved by requiring an explicit scope for repo-scoped capabilities:
requiring explicit `["*"]` or `["org/**"]` for unrestricted access. This is a - `[]string{}` / `nil` now denies all repo-scoped access by default.
design decision with backward-compatibility implications. - `[]string{"*"}` grants unrestricted repo access.
- 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,8 +224,6 @@ 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,22 +1,24 @@
module forge.lthn.ai/core/go-crypt module dappco.re/go/core/crypt
go 1.26.0 go 1.26.0
require ( require (
forge.lthn.ai/core/cli v0.3.1 dappco.re/go/core v0.5.0
forge.lthn.ai/core/go v0.3.1 dappco.re/go/core/i18n v0.2.0
forge.lthn.ai/core/go-i18n v0.1.4 dappco.re/go/core/io v0.2.0
forge.lthn.ai/core/go-io v0.1.2 dappco.re/go/core/log v0.1.0
forge.lthn.ai/core/go-log v0.0.4 forge.lthn.ai/core/cli v0.3.7
forge.lthn.ai/core/go-store v0.1.6 forge.lthn.ai/core/go-store v0.1.10
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-inference v0.1.4 // indirect forge.lthn.ai/core/go v0.3.2 // indirect
forge.lthn.ai/core/go-process v0.2.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
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
@ -46,7 +48,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/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/mod v0.34.0 // 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
@ -54,5 +56,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.46.1 // indirect modernc.org/sqlite v1.47.0 // indirect
) )

36
go.sum
View file

@ -1,19 +1,23 @@
forge.lthn.ai/core/cli v0.3.1 h1:ZpHhaDrdbaV98JDxj/f0E5nytYk9tTMRu3qohGyK4M0= dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
forge.lthn.ai/core/cli v0.3.1/go.mod h1:28cOl9eK0H033Otkjrv9f/QCmtHcJl+IIx4om8JskOg= dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
forge.lthn.ai/core/go v0.3.1 h1:5FMTsUhLcxSr07F9q3uG0Goy4zq4eLivoqi8shSY4UM= dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
forge.lthn.ai/core/go v0.3.1/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc= dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
forge.lthn.ai/core/go-i18n v0.1.4 h1:zOHUUJDgRo88/3tj++kN+VELg/buyZ4T2OSdG3HBbLQ= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
forge.lthn.ai/core/go-i18n v0.1.4/go.mod h1:aDyAfz7MMgWYgLkZCptfFmZ7jJg3ocwjEJ1WkJSvv4U= dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
forge.lthn.ai/core/go-inference v0.1.4 h1:fuAgWbqsEDajHniqAKyvHYbRcBrkGEiGSqR2pfTMRY0= dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
forge.lthn.ai/core/go-inference v0.1.4/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw= dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
forge.lthn.ai/core/go-io v0.1.2 h1:q8hj2jtOFqAgHlBr5wsUAOXtaFkxy9gqGrQT/il0WYA= forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
forge.lthn.ai/core/go-io v0.1.2/go.mod h1:PbNKW1Q25ywSOoQXeGdQHbV5aiIrTXvHIQ5uhplA//g= 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/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-process v0.2.2 h1:bnHFtzg92udochDDB6bD2luzzmr9ETKWmGzSsGjFFYE= forge.lthn.ai/core/go-store v0.1.10 h1:JLyf8xMR3V6PfBAW1kv6SJeHsYY93LacEBpTFW657qE=
forge.lthn.ai/core/go-process v0.2.2/go.mod h1:gVTbxL16ccUIexlFcyDtCy7LfYvD8Rtyzfo8bnXAXrU= forge.lthn.ai/core/go-store v0.1.10/go.mod h1:VNnHh94TMD3+L+sSgvxn0GHtDKhJR8FD6JiuIuRtjuk=
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=
@ -135,8 +139,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.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View file

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

View file

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

View file

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"io" "io"
"os" "os"
coreerr "dappco.re/go/core/log"
) )
// PolicyConfig is the JSON-serialisable representation of a trust policy. // PolicyConfig is the JSON-serialisable representation of a trust policy.
@ -24,7 +26,7 @@ type PoliciesConfig struct {
func LoadPoliciesFromFile(path string) ([]Policy, error) { func LoadPoliciesFromFile(path string) ([]Policy, error) {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return nil, fmt.Errorf("trust.LoadPoliciesFromFile: %w", err) return nil, coreerr.E("trust.LoadPoliciesFromFile", "failed to open file", err)
} }
defer f.Close() defer f.Close()
return LoadPolicies(f) return LoadPolicies(f)
@ -32,12 +34,21 @@ 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, fmt.Errorf("trust.LoadPolicies: %w", err) return nil, coreerr.E(op, "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)
} }
@ -48,7 +59,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
for i, pc := range cfg.Policies { for i, pc := range cfg.Policies {
tier := Tier(pc.Tier) tier := Tier(pc.Tier)
if !tier.Valid() { if !tier.Valid() {
return nil, fmt.Errorf("trust.LoadPolicies: invalid tier %d at index %d", pc.Tier, i) return nil, coreerr.E("trust.LoadPolicies", fmt.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil)
} }
p := Policy{ p := Policy{
@ -72,7 +83,7 @@ func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
} }
for _, p := range policies { for _, p := range policies {
if err := pe.SetPolicy(p); err != nil { if err := pe.SetPolicy(p); err != nil {
return fmt.Errorf("trust.ApplyPolicies: %w", err) return coreerr.E("trust.ApplyPolicies", "failed to set policy", err)
} }
} }
return nil return nil
@ -82,7 +93,7 @@ func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error { func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error {
f, err := os.Open(path) f, err := os.Open(path)
if err != nil { if err != nil {
return fmt.Errorf("trust.ApplyPoliciesFromFile: %w", err) return coreerr.E("trust.ApplyPoliciesFromFile", "failed to open file", err)
} }
defer f.Close() defer f.Close()
return pe.ApplyPolicies(f) return pe.ApplyPolicies(f)
@ -107,7 +118,7 @@ func (pe *PolicyEngine) ExportPolicies(w io.Writer) error {
enc := json.NewEncoder(w) enc := json.NewEncoder(w)
enc.SetIndent("", " ") enc.SetIndent("", " ")
if err := enc.Encode(cfg); err != nil { if err := enc.Encode(cfg); err != nil {
return fmt.Errorf("trust.ExportPolicies: %w", err) return coreerr.E("trust.ExportPolicies", "failed to encode JSON", err)
} }
return nil return nil
} }

View file

@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"slices" "slices"
"strings" "strings"
coreerr "dappco.re/go/core/log"
) )
// Policy defines the access rules for a given trust tier. // Policy defines the access rules for a given trust tier.
@ -115,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 repo-scoped capabilities, verify repo access for restricted tiers.
if isRepoScoped(cap) && len(agent.ScopedRepos) > 0 { if isRepoScoped(cap) && agent.Tier != TierFull {
if !repoAllowed(agent.ScopedRepos, repo) { if len(agent.ScopedRepos) == 0 || !repoAllowed(agent.ScopedRepos, repo) {
return EvalResult{ return EvalResult{
Decision: Deny, Decision: Deny,
Agent: agentName, Agent: agentName,
@ -146,7 +148,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
// SetPolicy replaces the policy for a given tier. // SetPolicy replaces the policy for a given tier.
func (pe *PolicyEngine) SetPolicy(p Policy) error { func (pe *PolicyEngine) SetPolicy(p Policy) error {
if !p.Tier.Valid() { if !p.Tier.Valid() {
return fmt.Errorf("trust.SetPolicy: invalid tier %d", p.Tier) return coreerr.E("trust.SetPolicy", fmt.Sprintf("invalid tier %d", p.Tier), nil)
} }
pe.policies[p.Tier] = &p pe.policies[p.Tier] = &p
return nil return nil
@ -245,6 +247,11 @@ 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,34 +270,49 @@ func TestDefaultRateLimit(t *testing.T) {
// --- Phase 0 Additions --- // --- Phase 0 Additions ---
// TestEvaluate_Good_Tier2EmptyScopedReposAllowsAll verifies that a Tier 2 // TestEvaluate_Bad_Tier2EmptyScopedReposDeniesAll verifies that an empty
// agent with empty ScopedRepos is treated as "unrestricted" for repo-scoped // scoped-repo list blocks repo-scoped capabilities by default.
// capabilities. NOTE: This is a potential security concern documented in func TestEvaluate_Bad_Tier2EmptyScopedReposDeniesAll(t *testing.T) {
// 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{}, // empty — currently means "unrestricted" ScopedRepos: []string{},
})) }))
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, Allow, result.Decision, assert.Equal(t, Deny, result.Decision,
"empty ScopedRepos currently allows all repos (potential security finding)") "empty ScopedRepos should deny repo-scoped operations by default")
result = pe.Evaluate("Hypnos", CapReadSecrets, "host-uk/core") result = pe.Evaluate("Hypnos", CapReadSecrets, "host-uk/core")
assert.Equal(t, Allow, result.Decision) assert.Equal(t, Deny, 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,6 +13,11 @@ func TestMatchScope_Good_ExactMatch(t *testing.T) {
assert.True(t, matchScope("host-uk/core", "host-uk/core")) assert.True(t, matchScope("host-uk/core", "host-uk/core"))
} }
func TestMatchScope_Good_StarWildcard(t *testing.T) {
assert.True(t, matchScope("*", "host-uk/core"))
assert.True(t, matchScope("*", "core/php/sub"))
}
func TestMatchScope_Good_SingleWildcard(t *testing.T) { func TestMatchScope_Good_SingleWildcard(t *testing.T) {
assert.True(t, matchScope("core/*", "core/php")) assert.True(t, matchScope("core/*", "core/php"))
assert.True(t, matchScope("core/*", "core/go-crypt")) assert.True(t, matchScope("core/*", "core/go-crypt"))

View file

@ -11,11 +11,12 @@
package trust package trust
import ( import (
"errors"
"fmt" "fmt"
"iter" "iter"
"sync" "sync"
"time" "time"
coreerr "dappco.re/go/core/log"
) )
// Tier represents an agent's trust level in the system. // Tier represents an agent's trust level in the system.
@ -70,7 +71,9 @@ 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. Empty means no repo access. // ScopedRepos limits repo access for Tier 2 agents.
// 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.
@ -98,10 +101,10 @@ func NewRegistry() *Registry {
// Returns an error if the agent name is empty or the tier is invalid. // Returns an error if the agent name is empty or the tier is invalid.
func (r *Registry) Register(agent Agent) error { func (r *Registry) Register(agent Agent) error {
if agent.Name == "" { if agent.Name == "" {
return errors.New("trust.Register: agent name is required") return coreerr.E("trust.Register", "agent name is required", nil)
} }
if !agent.Tier.Valid() { if !agent.Tier.Valid() {
return fmt.Errorf("trust.Register: invalid tier %d for agent %q", agent.Tier, agent.Name) return coreerr.E("trust.Register", fmt.Sprintf("invalid tier %d for agent %q", agent.Tier, agent.Name), nil)
} }
if agent.CreatedAt.IsZero() { if agent.CreatedAt.IsZero() {
agent.CreatedAt = time.Now() agent.CreatedAt = time.Now()