Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9a7a6fb4b | ||
| 86c68ad1c9 | |||
|
|
e80ef94552 | ||
| f37f5b3a14 | |||
| 12281f9e76 | |||
|
|
62482c7dc9 | ||
|
|
69464fe503 | ||
|
|
b85319ae6b | ||
|
|
f5b4c971a2 | ||
|
|
36bf16b06e | ||
| e691a9ce51 | |||
|
|
703dd4588c | ||
|
|
f4a219816a | ||
|
|
60de3e1943 | ||
|
|
eacbb025b3 | ||
|
|
39643ddba0 | ||
|
|
fb55abc52e | ||
|
|
70ebe68cc9 | ||
|
|
13b459a361 | ||
|
|
55ffb09c84 | ||
|
|
a009a8d1eb | ||
|
|
e561e1ee1f | ||
|
|
999b115315 |
42 changed files with 1216 additions and 698 deletions
24
.core/build.yaml
Normal file
24
.core/build.yaml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
project:
|
||||||
|
name: go-crypt
|
||||||
|
description: Cryptography utilities
|
||||||
|
binary: ""
|
||||||
|
|
||||||
|
build:
|
||||||
|
cgo: false
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
ldflags:
|
||||||
|
- -s
|
||||||
|
- -w
|
||||||
|
|
||||||
|
targets:
|
||||||
|
- os: linux
|
||||||
|
arch: amd64
|
||||||
|
- os: linux
|
||||||
|
arch: arm64
|
||||||
|
- os: darwin
|
||||||
|
arch: arm64
|
||||||
|
- os: windows
|
||||||
|
arch: amd64
|
||||||
20
.core/release.yaml
Normal file
20
.core/release.yaml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
version: 1
|
||||||
|
|
||||||
|
project:
|
||||||
|
name: go-crypt
|
||||||
|
repository: core/go-crypt
|
||||||
|
|
||||||
|
publishers: []
|
||||||
|
|
||||||
|
changelog:
|
||||||
|
include:
|
||||||
|
- feat
|
||||||
|
- fix
|
||||||
|
- perf
|
||||||
|
- refactor
|
||||||
|
exclude:
|
||||||
|
- chore
|
||||||
|
- docs
|
||||||
|
- style
|
||||||
|
- test
|
||||||
|
- ci
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
.core/
|
||||||
|
.idea/
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/go-crypt.iml" filepath="$PROJECT_DIR$/.idea/go-crypt.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
63
CLAUDE.md
63
CLAUDE.md
|
|
@ -1,20 +1,27 @@
|
||||||
# CLAUDE.md — go-crypt
|
# CLAUDE.md
|
||||||
|
|
||||||
You are a dedicated domain expert for `forge.lthn.ai/core/go-crypt`. Virgil (in
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
You are a dedicated domain expert for `dappco.re/go/core/crypt`. Virgil (in
|
||||||
core/go) orchestrates your work. Pick up tasks in phase order, mark `[x]` when
|
core/go) orchestrates your work. Pick up tasks in phase order, mark `[x]` when
|
||||||
done, commit and push.
|
done, commit and push.
|
||||||
|
|
||||||
## What This Package Does
|
## What This Package Does
|
||||||
|
|
||||||
Cryptographic primitives, authentication, and trust policy engine for the
|
Cryptographic primitives, authentication, and trust policy engine for the
|
||||||
Lethean agent platform. Provides:
|
Lethean agent platform. Three independent top-level packages:
|
||||||
|
|
||||||
- Symmetric encryption — ChaCha20-Poly1305 and AES-256-GCM with Argon2id KDF
|
- **`crypt/`** — Symmetric encryption (ChaCha20-Poly1305, AES-256-GCM), Argon2id
|
||||||
- OpenPGP authentication — challenge-response (online + air-gapped courier mode)
|
KDF, password hashing, HMAC, checksums. Sub-packages: `chachapoly/`, `lthn/`,
|
||||||
- Password hashing — Argon2id (primary) + Bcrypt (fallback)
|
`pgp/`, `rsa/`, `openpgp/`.
|
||||||
- Trust policy engine — 3-tier agent access control with capability evaluation
|
- **`auth/`** — OpenPGP challenge-response authentication (online + air-gapped
|
||||||
- RSA — OAEP-SHA256 key generation and encryption (2048+ bit)
|
courier mode), password-based login with Argon2id→LTHN migration, session
|
||||||
- LTHN hash — RFC-0004 quasi-salted deterministic hash (content IDs, NOT passwords)
|
management via `SessionStore` interface, key rotation and revocation.
|
||||||
|
- **`trust/`** — 3-tier agent access control (`Registry`, `PolicyEngine`,
|
||||||
|
`ApprovalQueue`, `AuditLog`), capability evaluation with repo scope matching.
|
||||||
|
|
||||||
|
Each package can be imported independently. Only `crypt/openpgp/` integrates
|
||||||
|
with the Core framework's IPC system (`core.Crypt` interface).
|
||||||
|
|
||||||
For architecture details see `docs/architecture.md`. For history and findings
|
For architecture details see `docs/architecture.md`. For history and findings
|
||||||
see `docs/history.md`.
|
see `docs/history.md`.
|
||||||
|
|
@ -22,37 +29,47 @@ see `docs/history.md`.
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test ./... # Run all tests
|
go test ./... # Run all tests
|
||||||
go test -race ./... # Race detector (required before committing)
|
go test -race ./... # Race detector (required before committing)
|
||||||
go test -v -run TestName ./... # Single test
|
go test -v -run TestName ./... # Single test
|
||||||
go vet ./... # Static analysis (must be clean)
|
go test ./auth/... # Single package
|
||||||
|
go vet ./... # Static analysis (must be clean)
|
||||||
|
go test -bench=. -benchmem ./crypt/... # Benchmarks
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local Dependencies
|
## Local Dependencies
|
||||||
|
|
||||||
| Module | Local Path | Notes |
|
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
|
||||||
| `forge.lthn.ai/core/go` | `../go` | Framework (core.E, core.Crypt, io.Medium) |
|
workspace file instead.
|
||||||
| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store (session persistence) |
|
|
||||||
|
|
||||||
Do not change the replace directive paths. Use a `go.work` for local resolution
|
| Module | Local Path | Purpose |
|
||||||
if working outside the full monorepo.
|
|--------|-----------|---------|
|
||||||
|
| `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/cli` | `../cli` | CLI framework for `cmd/crypt` commands |
|
||||||
|
|
||||||
|
No C toolchain or CGo required — all crypto uses pure Go implementations.
|
||||||
|
|
||||||
## Coding Standards
|
## Coding Standards
|
||||||
|
|
||||||
- **UK English**: colour, organisation, centre, artefact, licence, serialise
|
- **UK English**: colour, organisation, centre, artefact, licence, serialise
|
||||||
- **Tests**: testify assert/require, `_Good`/`_Bad`/`_Ugly` naming convention
|
- **Tests**: testify assert/require, `_Good`/`_Bad`/`_Ugly` naming convention
|
||||||
- **Concurrency tests**: 10 goroutines via WaitGroup; must pass `-race`
|
- **Concurrency tests**: 10 goroutines via WaitGroup; must pass `-race`
|
||||||
- **Imports**: stdlib → 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)`; never
|
- **Errors**: use `coreerr.E("package.Function", "lowercase message", err)` (imported
|
||||||
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`,
|
||||||
|
`openpgp`, `chachapoly`
|
||||||
- **Co-Author**: `Co-Authored-By: Virgil <virgil@lethean.io>`
|
- **Co-Author**: `Co-Authored-By: Virgil <virgil@lethean.io>`
|
||||||
- **Licence**: EUPL-1.2
|
- **Licence**: EUPL-1.2
|
||||||
|
|
||||||
## Forge
|
## Forge
|
||||||
|
|
||||||
- **Repo**: `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`)
|
||||||
|
|
|
||||||
88
auth/auth.go
88
auth/auth.go
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,9 +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"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Style aliases from shared
|
// Style aliases from shared
|
||||||
|
|
@ -32,11 +31,11 @@ var (
|
||||||
testJSON bool
|
testJSON bool
|
||||||
)
|
)
|
||||||
|
|
||||||
var testCmd = &cobra.Command{
|
var testCmd = &cli.Command{
|
||||||
Use: "test",
|
Use: "test",
|
||||||
Short: i18n.T("cmd.test.short"),
|
Short: i18n.T("cmd.test.short"),
|
||||||
Long: i18n.T("cmd.test.long"),
|
Long: i18n.T("cmd.test.long"),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
return runTest(testVerbose, testCoverage, testShort, testPkg, testRun, testRace, testJSON)
|
return runTest(testVerbose, testCoverage, testShort, testPkg, testRun, testRace, testJSON)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +51,7 @@ func initTestFlags() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddTestCommands registers the 'test' command and all subcommands.
|
// AddTestCommands registers the 'test' command and all subcommands.
|
||||||
func AddTestCommands(root *cobra.Command) {
|
func AddTestCommands(root *cli.Command) {
|
||||||
initTestFlags()
|
initTestFlags()
|
||||||
root.AddCommand(testCmd)
|
root.AddCommand(testCmd)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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, ¶llelism); err != nil {
|
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, ¶llelism); 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ package lthn
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -87,8 +88,8 @@ func createSalt(input string) string {
|
||||||
|
|
||||||
// Verify checks if an input string produces the given hash.
|
// Verify checks if an input string produces the given hash.
|
||||||
// Returns true if Hash(input) equals the provided hash value.
|
// Returns true if Hash(input) equals the provided hash value.
|
||||||
// Uses direct string comparison - for security-critical applications,
|
// Uses constant-time comparison to prevent timing attacks.
|
||||||
// consider using constant-time comparison.
|
|
||||||
func Verify(input string, hash string) bool {
|
func Verify(input string, hash string) bool {
|
||||||
return Hash(input) == hash
|
computed := Hash(input)
|
||||||
|
return subtle.ConstantTimeCompare([]byte(computed), []byte(hash)) == 1
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,207 +1,299 @@
|
||||||
# Architecture — go-crypt
|
---
|
||||||
|
title: Architecture
|
||||||
`forge.lthn.ai/core/go-crypt` provides cryptographic primitives, authentication,
|
description: Internal design, key types, data flow, and algorithm reference for go-crypt.
|
||||||
and a trust policy engine for the Lethean agent platform. The module is ~1,938
|
|
||||||
source LOC across three top-level packages (`auth`, `crypt`, `trust`) and five
|
|
||||||
sub-packages (`crypt/chachapoly`, `crypt/lthn`, `crypt/pgp`, `crypt/rsa`,
|
|
||||||
`crypt/openpgp`).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Package Map
|
# Architecture
|
||||||
|
|
||||||
|
`forge.lthn.ai/core/go-crypt` is organised into three top-level packages
|
||||||
|
(`crypt`, `auth`, `trust`) and five sub-packages under `crypt/`. Each
|
||||||
|
package is self-contained and can be imported independently.
|
||||||
|
|
||||||
```
|
```
|
||||||
go-crypt/
|
go-crypt/
|
||||||
├── auth/ OpenPGP challenge-response authentication, sessions, key management
|
├── auth/ OpenPGP challenge-response authentication
|
||||||
│ ├── auth.go Authenticator struct, registration, login, key rotation/revocation
|
│ ├── auth.go Authenticator: registration, login, key rotation, revocation
|
||||||
│ ├── session_store.go SessionStore interface + MemorySessionStore
|
│ ├── session_store.go SessionStore interface + MemorySessionStore
|
||||||
│ ├── session_store_sqlite.go SQLiteSessionStore (persistent via go-store)
|
│ ├── session_store_sqlite.go SQLiteSessionStore (persistent via go-store)
|
||||||
│ └── hardware.go HardwareKey interface (contract only, no implementations)
|
│ └── hardware.go HardwareKey interface (contract only, no implementations yet)
|
||||||
├── crypt/ Symmetric encryption, key derivation, hashing
|
├── crypt/ Symmetric encryption, hashing, key derivation
|
||||||
│ ├── crypt.go High-level Encrypt/Decrypt (ChaCha20) and EncryptAES/DecryptAES
|
│ ├── crypt.go High-level Encrypt/Decrypt and EncryptAES/DecryptAES
|
||||||
│ ├── kdf.go DeriveKey (Argon2id), DeriveKeyScrypt, HKDF
|
│ ├── kdf.go Key derivation: Argon2id, scrypt, HKDF-SHA256
|
||||||
│ ├── symmetric.go ChaCha20Encrypt/Decrypt, AESGCMEncrypt/Decrypt
|
│ ├── symmetric.go Low-level ChaCha20-Poly1305 and AES-256-GCM
|
||||||
│ ├── hash.go HashPassword/VerifyPassword (Argon2id), HashBcrypt/VerifyBcrypt
|
│ ├── hash.go Password hashing: Argon2id and bcrypt
|
||||||
│ ├── hmac.go HMACSHA256, HMACSHA512, VerifyHMAC
|
│ ├── hmac.go HMAC-SHA256, HMAC-SHA512, constant-time verify
|
||||||
│ ├── checksum.go SHA256File, SHA512File, SHA256Sum, SHA512Sum
|
│ ├── checksum.go SHA-256 and SHA-512 file/data checksums
|
||||||
│ ├── chachapoly/ Standalone ChaCha20-Poly1305 AEAD wrapper
|
│ ├── chachapoly/ Standalone ChaCha20-Poly1305 AEAD wrapper
|
||||||
│ ├── lthn/ RFC-0004 quasi-salted deterministic hash
|
│ ├── lthn/ RFC-0004 quasi-salted deterministic hash
|
||||||
│ ├── pgp/ OpenPGP primitives (ProtonMail go-crypto)
|
│ ├── pgp/ OpenPGP primitives (ProtonMail go-crypto)
|
||||||
│ ├── rsa/ RSA OAEP-SHA256 key generation and encryption
|
│ ├── rsa/ RSA-OAEP-SHA256 key generation and encryption
|
||||||
│ └── openpgp/ Service wrapper implementing core.Crypt interface
|
│ └── openpgp/ Service wrapper implementing core.Crypt interface
|
||||||
└── trust/ Agent trust model and policy engine
|
├── trust/ Agent trust model and policy engine
|
||||||
├── trust.go Registry, Agent struct, Tier enum
|
│ ├── trust.go Registry, Agent struct, Tier enum
|
||||||
├── policy.go PolicyEngine, 9 capabilities, Evaluate
|
│ ├── policy.go PolicyEngine, capabilities, Evaluate()
|
||||||
├── approval.go ApprovalQueue for NeedsApproval workflow
|
│ ├── approval.go ApprovalQueue for NeedsApproval decisions
|
||||||
├── audit.go AuditLog — append-only policy evaluation log
|
│ ├── audit.go AuditLog: append-only policy evaluation log
|
||||||
├── config.go LoadPolicies/ExportPolicies — JSON config round-trip
|
│ └── config.go JSON policy configuration: load, apply, export
|
||||||
└── scope.go matchScope — wildcard pattern matching for repo scopes
|
└── cmd/
|
||||||
|
└── crypt/ CLI commands registered with core CLI
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## crypt/ — Symmetric Encryption and Hashing
|
## crypt/ -- Symmetric Encryption and Hashing
|
||||||
|
|
||||||
### High-Level API (`crypt.go`)
|
### High-Level API
|
||||||
|
|
||||||
The entry point for most callers. `Encrypt`/`Decrypt` chain Argon2id key
|
The `crypt.Encrypt` and `crypt.Decrypt` functions are the primary entry
|
||||||
derivation with ChaCha20-Poly1305 AEAD:
|
points. They chain Argon2id key derivation with XChaCha20-Poly1305 AEAD
|
||||||
|
encryption. A random salt is generated and prepended to the output so that
|
||||||
|
callers need only track the passphrase.
|
||||||
|
|
||||||
```
|
```
|
||||||
Encrypt(plaintext, passphrase):
|
Encrypt(plaintext, passphrase) -> salt || nonce || ciphertext
|
||||||
|
|
||||||
1. Generate 16-byte random salt (crypto/rand)
|
1. Generate 16-byte random salt (crypto/rand)
|
||||||
2. DeriveKey(passphrase, salt) → 32-byte key via Argon2id
|
2. DeriveKey(passphrase, salt) -> 32-byte key via Argon2id
|
||||||
3. ChaCha20Encrypt(plaintext, key) → 24-byte nonce || ciphertext
|
3. ChaCha20Encrypt(plaintext, key) -> 24-byte nonce || ciphertext
|
||||||
4. Output: salt || nonce || ciphertext
|
4. Prepend salt to the result
|
||||||
```
|
```
|
||||||
|
|
||||||
`EncryptAES`/`DecryptAES` follow the same structure but use AES-256-GCM
|
`EncryptAES` and `DecryptAES` follow the same pattern but use AES-256-GCM
|
||||||
with a 12-byte nonce instead of the 24-byte XChaCha20 nonce.
|
with a 12-byte nonce instead of the 24-byte XChaCha20 nonce.
|
||||||
|
|
||||||
### Key Derivation (`kdf.go`)
|
Both ciphers produce self-describing byte layouts. Callers must not alter
|
||||||
|
the layout between encrypt and decrypt.
|
||||||
|
|
||||||
Three KDF functions are provided:
|
| Function | Cipher | Nonce | Wire Format |
|
||||||
|
|----------|--------|-------|-------------|
|
||||||
|
| `Encrypt` / `Decrypt` | XChaCha20-Poly1305 | 24 bytes | salt(16) + nonce(24) + ciphertext |
|
||||||
|
| `EncryptAES` / `DecryptAES` | AES-256-GCM | 12 bytes | salt(16) + nonce(12) + ciphertext |
|
||||||
|
|
||||||
| Function | Algorithm | Parameters |
|
### Key Derivation (kdf.go)
|
||||||
|----------|-----------|------------|
|
|
||||||
| `DeriveKey` | Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 |
|
|
||||||
| `DeriveKeyScrypt` | scrypt | N=32768, r=8, p=1 |
|
|
||||||
| `HKDF` | HKDF-SHA256 | Variable key length, optional salt and info |
|
|
||||||
|
|
||||||
Argon2id parameters are within the OWASP recommended range for interactive
|
Three key derivation functions serve different use cases:
|
||||||
logins. `HKDF` is used for key expansion when a high-entropy secret is already
|
|
||||||
available (e.g. deriving sub-keys from a master key).
|
|
||||||
|
|
||||||
### Low-Level Symmetric (`symmetric.go`)
|
| Function | Algorithm | Parameters | Use Case |
|
||||||
|
|----------|-----------|------------|----------|
|
||||||
|
| `DeriveKey` | Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 | Primary KDF for passphrase-based encryption |
|
||||||
|
| `DeriveKeyScrypt` | scrypt | N=32768, r=8, p=1 | Alternative KDF where Argon2id is unavailable |
|
||||||
|
| `HKDF` | HKDF-SHA256 | Variable key length, optional salt/info | Key expansion from high-entropy secrets |
|
||||||
|
|
||||||
`ChaCha20Encrypt` prepends the 24-byte nonce to the ciphertext and returns a
|
The Argon2id parameters sit within the OWASP-recommended range for
|
||||||
single byte slice. `AESGCMEncrypt` prepends the 12-byte nonce. Both use
|
interactive logins. `HKDF` is intended for deriving sub-keys from a master
|
||||||
`crypto/rand` for nonce generation. The ciphertext format self-describes the
|
key that already has high entropy; it should not be used directly with
|
||||||
nonce position; callers must not alter the layout between encrypt and decrypt.
|
low-entropy passphrases.
|
||||||
|
|
||||||
### Password Hashing (`hash.go`)
|
### Low-Level Symmetric Ciphers (symmetric.go)
|
||||||
|
|
||||||
`HashPassword` produces an Argon2id format string:
|
`ChaCha20Encrypt` and `AESGCMEncrypt` each generate a random nonce via
|
||||||
|
`crypto/rand` and prepend it to the ciphertext. The corresponding decrypt
|
||||||
|
functions extract the nonce from the front of the byte slice.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ChaCha20-Poly1305: 32-byte key required
|
||||||
|
ciphertext, err := crypt.ChaCha20Encrypt(plaintext, key)
|
||||||
|
plaintext, err := crypt.ChaCha20Decrypt(ciphertext, key)
|
||||||
|
|
||||||
|
// AES-256-GCM: 32-byte key required
|
||||||
|
ciphertext, err := crypt.AESGCMEncrypt(plaintext, key)
|
||||||
|
plaintext, err := crypt.AESGCMDecrypt(ciphertext, key)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Password Hashing (hash.go)
|
||||||
|
|
||||||
|
`HashPassword` produces a self-describing Argon2id hash string:
|
||||||
|
|
||||||
```
|
```
|
||||||
$argon2id$v=19$m=65536,t=3,p=4$<base64-salt>$<base64-hash>
|
$argon2id$v=19$m=65536,t=3,p=4$<base64-salt>$<base64-hash>
|
||||||
```
|
```
|
||||||
|
|
||||||
`VerifyPassword` re-derives the hash from the stored parameters and uses
|
`VerifyPassword` re-derives the hash from the parameters encoded in the
|
||||||
`crypto/subtle.ConstantTimeCompare` for the final comparison. This avoids
|
string and uses `crypto/subtle.ConstantTimeCompare` for the final
|
||||||
timing side-channels during password verification.
|
comparison. This prevents timing side-channels during password verification.
|
||||||
|
|
||||||
`HashBcrypt`/`VerifyBcrypt` wrap `golang.org/x/crypto/bcrypt` as a fallback
|
`HashBcrypt` and `VerifyBcrypt` wrap `golang.org/x/crypto/bcrypt` as a
|
||||||
for systems where bcrypt is required by policy.
|
fallback for environments where bcrypt is mandated by policy.
|
||||||
|
|
||||||
### HMAC (`hmac.go`)
|
### HMAC (hmac.go)
|
||||||
|
|
||||||
`HMACSHA256`/`HMACSHA512` return raw MAC bytes. `VerifyHMAC` uses
|
Three functions for message authentication codes:
|
||||||
`crypto/hmac.Equal` (constant-time) to compare a computed MAC against an
|
|
||||||
expected value.
|
|
||||||
|
|
||||||
### Checksums (`checksum.go`)
|
- `HMACSHA256(message, key)` -- returns raw 32-byte MAC.
|
||||||
|
- `HMACSHA512(message, key)` -- returns raw 64-byte MAC.
|
||||||
|
- `VerifyHMAC(message, key, mac, hashFunc)` -- constant-time verification
|
||||||
|
using `crypto/hmac.Equal`.
|
||||||
|
|
||||||
`SHA256File`/`SHA512File` compute checksums of files via streaming reads.
|
### Checksums (checksum.go)
|
||||||
`SHA256Sum`/`SHA512Sum` operate on byte slices. All return lowercase hex strings.
|
|
||||||
|
File checksums use streaming reads to handle arbitrarily large files without
|
||||||
|
loading them entirely into memory:
|
||||||
|
|
||||||
|
```go
|
||||||
|
hash, err := crypt.SHA256File("/path/to/file") // hex string
|
||||||
|
hash, err := crypt.SHA512File("/path/to/file") // hex string
|
||||||
|
|
||||||
|
// In-memory checksums
|
||||||
|
hash := crypt.SHA256Sum(data) // hex string
|
||||||
|
hash := crypt.SHA512Sum(data) // hex string
|
||||||
|
```
|
||||||
|
|
||||||
### crypt/chachapoly/
|
### crypt/chachapoly/
|
||||||
|
|
||||||
A standalone AEAD wrapper with slightly different capacity pre-allocation. The
|
A standalone ChaCha20-Poly1305 package that can be imported independently.
|
||||||
nonce (24 bytes) is prepended to the ciphertext on encrypt and stripped on
|
It pre-allocates `cap(nonce) + len(plaintext) + overhead` before appending,
|
||||||
decrypt. This package exists separately from `crypt/symmetric.go` for callers
|
which reduces allocations for small payloads.
|
||||||
that import only ChaCha20-Poly1305 without the full `crypt` package.
|
|
||||||
|
|
||||||
Note: the two implementations are nearly identical. The main difference is that
|
```go
|
||||||
`chachapoly` pre-allocates `cap(nonce) + len(plaintext) + overhead` before
|
import "forge.lthn.ai/core/go-crypt/crypt/chachapoly"
|
||||||
appending, which can reduce allocations for small payloads.
|
|
||||||
|
|
||||||
### crypt/lthn/
|
ciphertext, err := chachapoly.Encrypt(plaintext, key) // key must be 32 bytes
|
||||||
|
plaintext, err := chachapoly.Decrypt(ciphertext, key)
|
||||||
|
```
|
||||||
|
|
||||||
RFC-0004 quasi-salted deterministic hash. The algorithm:
|
This is functionally identical to `crypt.ChaCha20Encrypt` and exists as a
|
||||||
|
separate import path for callers that only need the AEAD primitive.
|
||||||
|
|
||||||
|
### crypt/lthn/ -- RFC-0004 Deterministic Hash
|
||||||
|
|
||||||
|
The LTHN hash produces a deterministic, verifiable identifier from any
|
||||||
|
input string. The algorithm:
|
||||||
|
|
||||||
1. Reverse the input string.
|
1. Reverse the input string.
|
||||||
2. Apply leet-speak character substitutions (`o`→`0`, `l`→`1`, `e`→`3`,
|
2. Apply "leet speak" character substitutions (`o` to `0`, `l` to `1`,
|
||||||
`a`→`4`, `s`→`z`, `t`→`7`, and inverses).
|
`e` to `3`, `a` to `4`, `s` to `z`, `t` to `7`, and their inverses).
|
||||||
3. Concatenate original input with the derived quasi-salt.
|
3. Concatenate the original input with the derived quasi-salt.
|
||||||
4. Return SHA-256 of the concatenation, hex-encoded.
|
4. Return the SHA-256 digest, hex-encoded (64 characters).
|
||||||
|
|
||||||
This is deterministic — the same input always produces the same output. It is
|
```go
|
||||||
designed for content identifiers, cache keys, and deduplication. It is **not**
|
import "forge.lthn.ai/core/go-crypt/crypt/lthn"
|
||||||
suitable for password hashing because there is no random salt and the
|
|
||||||
comparison in `Verify` is not constant-time.
|
|
||||||
|
|
||||||
### crypt/pgp/
|
hash := lthn.Hash("hello")
|
||||||
|
valid := lthn.Verify("hello", hash) // true
|
||||||
|
```
|
||||||
|
|
||||||
OpenPGP primitives via `github.com/ProtonMail/go-crypto`:
|
The substitution map can be customised via `lthn.SetKeyMap()` for
|
||||||
|
application-specific derivation.
|
||||||
|
|
||||||
- `CreateKeyPair(name, email, password)` — generates a DSA primary key with an
|
**Important**: LTHN is designed for content identifiers, cache keys, and
|
||||||
RSA encryption subkey; returns armored public and private keys.
|
deduplication. It is not suitable for password hashing because it uses no
|
||||||
- `Encrypt(plaintext, publicKey)` — produces an armored PGP message.
|
random salt and the comparison in `Verify` uses `subtle.ConstantTimeCompare`
|
||||||
- `Decrypt(ciphertext, privateKey, password)` — decrypts an armored message.
|
but the hash itself is deterministic and fast.
|
||||||
- `Sign(data, privateKey, password)` — creates a detached armored signature.
|
|
||||||
- `Verify(data, signature, publicKey)` — verifies a detached signature.
|
|
||||||
|
|
||||||
PGP output is Base64-armored, which adds approximately 33% overhead relative
|
### crypt/pgp/ -- OpenPGP Primitives
|
||||||
to raw binary. For large payloads consider compression before encryption.
|
|
||||||
|
|
||||||
### crypt/rsa/
|
Full OpenPGP support via `github.com/ProtonMail/go-crypto`:
|
||||||
|
|
||||||
RSA OAEP-SHA256. `GenerateKeyPair(bits)` generates an RSA keypair (minimum
|
```go
|
||||||
2048 bit is enforced at the call site). `Encrypt`/`Decrypt` use
|
import "forge.lthn.ai/core/go-crypt/crypt/pgp"
|
||||||
`crypto/rsa.EncryptOAEP` with SHA-256. Keys are serialised as PEM blocks.
|
|
||||||
|
|
||||||
### crypt/openpgp/
|
// Generate a keypair (private key optionally password-protected)
|
||||||
|
kp, err := pgp.CreateKeyPair("Alice", "alice@example.com", "password")
|
||||||
|
// kp.PublicKey -- armored PGP public key
|
||||||
|
// kp.PrivateKey -- armored PGP private key
|
||||||
|
|
||||||
Service wrapper that implements the `core.Crypt` interface from `forge.lthn.ai/core/go`.
|
// Encrypt data for a recipient
|
||||||
Uses RSA-4096 with SHA-256 and AES-256. This is the only IPC-aware component
|
ciphertext, err := pgp.Encrypt(data, kp.PublicKey)
|
||||||
in go-crypt: `HandleIPCEvents` dispatches the `"openpgp.create_key_pair"` action
|
|
||||||
when registered with a Core instance.
|
// Decrypt with private key
|
||||||
|
plaintext, err := pgp.Decrypt(ciphertext, kp.PrivateKey, "password")
|
||||||
|
|
||||||
|
// Sign data (detached armored signature)
|
||||||
|
signature, err := pgp.Sign(data, kp.PrivateKey, "password")
|
||||||
|
|
||||||
|
// Verify a detached signature
|
||||||
|
err := pgp.Verify(data, signature, kp.PublicKey)
|
||||||
|
```
|
||||||
|
|
||||||
|
All PGP output is Base64-armored, adding approximately 33% overhead
|
||||||
|
relative to raw binary. For large payloads, consider compression before
|
||||||
|
encryption.
|
||||||
|
|
||||||
|
### crypt/rsa/ -- RSA-OAEP
|
||||||
|
|
||||||
|
RSA encryption with OAEP-SHA256 padding. A minimum key size of 2048 bits
|
||||||
|
is enforced at the API level.
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "forge.lthn.ai/core/go-crypt/crypt/rsa"
|
||||||
|
|
||||||
|
svc := rsa.NewService()
|
||||||
|
|
||||||
|
// Generate a keypair (PEM-encoded)
|
||||||
|
pubKey, privKey, err := svc.GenerateKeyPair(4096)
|
||||||
|
|
||||||
|
// Encrypt / Decrypt with optional label
|
||||||
|
ciphertext, err := svc.Encrypt(pubKey, plaintext, label)
|
||||||
|
plaintext, err := svc.Decrypt(privKey, ciphertext, label)
|
||||||
|
```
|
||||||
|
|
||||||
|
### crypt/openpgp/ -- Core Service Integration
|
||||||
|
|
||||||
|
A service wrapper that implements the `core.Crypt` interface from
|
||||||
|
`forge.lthn.ai/core/go`. This is the only component in go-crypt that
|
||||||
|
integrates with the Core framework's IPC system.
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "forge.lthn.ai/core/go-crypt/crypt/openpgp"
|
||||||
|
|
||||||
|
// Register as a Core service
|
||||||
|
core.New(core.WithService(openpgp.New))
|
||||||
|
|
||||||
|
// Handles the "openpgp.create_key_pair" IPC action
|
||||||
|
```
|
||||||
|
|
||||||
|
The service generates RSA-4096 keypairs with SHA-256 hashing and AES-256
|
||||||
|
encryption for private key protection.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## auth/ — OpenPGP Authentication
|
## auth/ -- OpenPGP Authentication
|
||||||
|
|
||||||
### Authenticator
|
### Authenticator
|
||||||
|
|
||||||
The `Authenticator` struct manages all user identity operations. It takes an
|
The `Authenticator` struct is the central type for user identity operations.
|
||||||
`io.Medium` (from `forge.lthn.ai/core/go`) for storage and an optional
|
It takes an `io.Medium` for storage and supports functional options for
|
||||||
`SessionStore` for session persistence.
|
configuration.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
a := auth.New(medium,
|
a := auth.New(medium,
|
||||||
auth.WithSessionStore(auth.NewSQLiteSessionStore("/var/lib/app/sessions.db")),
|
auth.WithSessionStore(sqliteStore),
|
||||||
auth.WithSessionTTL(8*time.Hour),
|
auth.WithSessionTTL(8 * time.Hour),
|
||||||
auth.WithChallengeTTL(2*time.Minute),
|
auth.WithChallengeTTL(2 * time.Minute),
|
||||||
|
auth.WithHardwareKey(yubikey), // future: hardware-backed crypto
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Storage Layout
|
### Storage Layout
|
||||||
|
|
||||||
All user artefacts are stored under `users/` on the Medium, keyed by a userID
|
All user artefacts are stored under `users/` on the Medium, keyed by a
|
||||||
derived from `lthn.Hash(username)`:
|
userID derived from `lthn.Hash(username)`:
|
||||||
|
|
||||||
| File | Content |
|
| File | Content |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `users/{userID}.pub` | Armored PGP public key |
|
| `users/{userID}.pub` | Armored PGP public key |
|
||||||
| `users/{userID}.key` | Armored PGP private key (password-encrypted) |
|
| `users/{userID}.key` | Armored PGP private key (password-encrypted) |
|
||||||
| `users/{userID}.rev` | JSON revocation record, or legacy placeholder string |
|
| `users/{userID}.rev` | JSON revocation record, or legacy placeholder |
|
||||||
| `users/{userID}.json` | User metadata, PGP-encrypted with the user's public key |
|
| `users/{userID}.json` | User metadata (PGP-encrypted with user's public key) |
|
||||||
| `users/{userID}.hash` | Argon2id password hash (new registrations and migrated accounts) |
|
| `users/{userID}.hash` | Argon2id password hash |
|
||||||
| `users/{userID}.lthn` | Legacy LTHN hash (pre-Phase-2 registrations only) |
|
| `users/{userID}.lthn` | Legacy LTHN hash (migrated transparently on login) |
|
||||||
|
|
||||||
### Registration
|
### Registration Flow
|
||||||
|
|
||||||
`Register(username, password)`:
|
`Register(username, password)`:
|
||||||
|
|
||||||
1. Derive `userID = lthn.Hash(username)`.
|
1. Derive `userID = lthn.Hash(username)`.
|
||||||
2. Check `users/{userID}.pub` does not exist.
|
2. Check that `users/{userID}.pub` does not already exist.
|
||||||
3. `pgp.CreateKeyPair(userID, ...)` → armored keypair.
|
3. Generate a PGP keypair via `pgp.CreateKeyPair`.
|
||||||
4. Write `.pub`, `.key`, `.rev` (placeholder).
|
4. Store `.pub`, `.key`, `.rev` (placeholder).
|
||||||
5. `crypt.HashPassword(password)` → Argon2id hash string → write `.hash`.
|
5. Hash the password with Argon2id and store as `.hash`.
|
||||||
6. JSON-marshal `User` metadata, PGP-encrypt with public key, write `.json`.
|
6. Marshal user metadata as JSON, encrypt with the user's PGP public key,
|
||||||
|
store as `.json`.
|
||||||
|
|
||||||
### Online Challenge-Response
|
### Online Challenge-Response Flow
|
||||||
|
|
||||||
|
This is the primary authentication mechanism. It proves that the client
|
||||||
|
holds the private key corresponding to a registered public key.
|
||||||
|
|
||||||
```
|
```
|
||||||
Client Server
|
Client Server
|
||||||
|
|
@ -209,55 +301,58 @@ Client Server
|
||||||
|-- CreateChallenge(userID) -------> |
|
|-- CreateChallenge(userID) -------> |
|
||||||
| | 1. Generate 32-byte nonce (crypto/rand)
|
| | 1. Generate 32-byte nonce (crypto/rand)
|
||||||
| | 2. PGP-encrypt nonce with user's public key
|
| | 2. PGP-encrypt nonce with user's public key
|
||||||
| | 3. Store pending challenge (TTL: 5 min)
|
| | 3. Store pending challenge (default TTL: 5 min)
|
||||||
| <-- Challenge{Encrypted} --------- |
|
| <-- Challenge{Encrypted} --------- |
|
||||||
| |
|
| |
|
||||||
| (client decrypts nonce, signs it) |
|
| (decrypt nonce, sign with privkey) |
|
||||||
| |
|
| |
|
||||||
|-- ValidateResponse(signedNonce) -> |
|
|-- ValidateResponse(signedNonce) -> |
|
||||||
| | 4. Verify detached PGP signature
|
| | 4. Verify detached PGP signature
|
||||||
| | 5. Create session (32-byte token, 24h TTL)
|
| | 5. Create session (32-byte token, default 24h TTL)
|
||||||
| <-- Session{Token} --------------- |
|
| <-- Session{Token} --------------- |
|
||||||
```
|
```
|
||||||
|
|
||||||
### Air-Gapped (Courier) Mode
|
### Air-Gapped (Courier) Mode
|
||||||
|
|
||||||
`WriteChallengeFile(userID, path)` writes the encrypted challenge as JSON to
|
For agents that cannot receive live HTTP responses:
|
||||||
the Medium. The client signs the nonce offline. `ReadResponseFile(userID, path)`
|
|
||||||
reads the armored signature and calls `ValidateResponse` to complete authentication.
|
- `WriteChallengeFile(userID, path)` writes the encrypted challenge as JSON
|
||||||
This mode supports agents or users who cannot receive live HTTP responses.
|
to the Medium.
|
||||||
|
- The client signs the nonce offline.
|
||||||
|
- `ReadResponseFile(userID, path)` reads the armored signature and validates
|
||||||
|
it, completing the authentication.
|
||||||
|
|
||||||
### Password-Based Login
|
### Password-Based Login
|
||||||
|
|
||||||
`Login(userID, password)` bypasses the PGP challenge-response flow and verifies
|
`Login(userID, password)` bypasses the PGP challenge-response flow. It
|
||||||
the password directly. It supports both hash formats via a dual-path strategy:
|
supports both hash formats with automatic migration:
|
||||||
|
|
||||||
1. If `users/{userID}.hash` exists and starts with `$argon2id$`: verify with
|
1. If `.hash` exists and starts with `$argon2id$`: verify with
|
||||||
`crypt.VerifyPassword` (constant-time Argon2id comparison).
|
constant-time Argon2id comparison.
|
||||||
2. Otherwise fall back to `users/{userID}.lthn`: verify with `lthn.Verify`.
|
2. Otherwise, fall back to `.lthn` and verify with `lthn.Verify`.
|
||||||
On success, transparently re-hash the password with Argon2id and write a
|
On success, re-hash with Argon2id and write a `.hash` file
|
||||||
`.hash` file (best-effort, does not fail the login if the write fails).
|
(best-effort -- login succeeds even if the migration write fails).
|
||||||
|
|
||||||
### Key Management
|
### Key Management
|
||||||
|
|
||||||
**Rotation** (`RotateKeyPair(userID, oldPassword, newPassword)`):
|
**Rotation** via `RotateKeyPair(userID, oldPassword, newPassword)`:
|
||||||
- Load and decrypt current metadata using the old private key and password.
|
|
||||||
- Generate a new PGP keypair.
|
- Decrypt current metadata with the old private key and password.
|
||||||
|
- Generate a new PGP keypair protected by the new password.
|
||||||
- Re-encrypt metadata with the new public key.
|
- Re-encrypt metadata with the new public key.
|
||||||
- Overwrite `.pub`, `.key`, `.json`, `.hash`.
|
- Overwrite `.pub`, `.key`, `.json`, `.hash`.
|
||||||
- Invalidate all active sessions for the user via `store.DeleteByUser`.
|
- Invalidate all active sessions for the user.
|
||||||
|
|
||||||
**Revocation** (`RevokeKey(userID, password, reason)`):
|
**Revocation** via `RevokeKey(userID, password, reason)`:
|
||||||
- Verify password (dual-path, same as Login).
|
|
||||||
|
- Verify the password (tries Argon2id first, then LTHN).
|
||||||
- Write a `Revocation{UserID, Reason, RevokedAt}` JSON record to `.rev`.
|
- Write a `Revocation{UserID, Reason, RevokedAt}` JSON record to `.rev`.
|
||||||
- Invalidate all sessions.
|
- Invalidate all sessions.
|
||||||
- `IsRevoked` returns true only when the `.rev` file contains valid JSON with a
|
- Both `Login` and `CreateChallenge` immediately reject revoked users.
|
||||||
non-zero `RevokedAt`. The legacy `"REVOCATION_PLACEHOLDER"` string is treated
|
|
||||||
as non-revoked for backward compatibility.
|
|
||||||
- Both `Login` and `CreateChallenge` reject revoked users immediately.
|
|
||||||
|
|
||||||
**Protected users**: The `"server"` userID cannot be deleted. It holds the
|
**Protected users**: The `"server"` userID cannot be deleted. It holds the
|
||||||
server keypair; deletion would permanently destroy the server's joining data.
|
server keypair; deleting it would permanently destroy the server's joining
|
||||||
|
data.
|
||||||
|
|
||||||
### Session Management
|
### Session Management
|
||||||
|
|
||||||
|
|
@ -275,20 +370,19 @@ type SessionStore interface {
|
||||||
|
|
||||||
Two implementations are provided:
|
Two implementations are provided:
|
||||||
|
|
||||||
| Implementation | Persistence | Concurrency |
|
| Store | Persistence | Concurrency Model |
|
||||||
|----------------|-------------|-------------|
|
|-------|-------------|-------------------|
|
||||||
| `MemorySessionStore` | None (lost on restart) | `sync.RWMutex` |
|
| `MemorySessionStore` | None (lost on restart) | `sync.RWMutex` with defensive copies |
|
||||||
| `SQLiteSessionStore` | SQLite via go-store | Single mutex (SQLite single-writer) |
|
| `SQLiteSessionStore` | SQLite via go-store | Single `sync.Mutex` (SQLite single-writer) |
|
||||||
|
|
||||||
Session tokens are 32 bytes from `crypto/rand`, hex-encoded to 64 characters
|
Session tokens are 32 bytes from `crypto/rand`, hex-encoded to 64
|
||||||
(256-bit entropy). Expiry is checked on every `ValidateSession` and
|
characters (256-bit entropy). Expired sessions are cleaned up either on
|
||||||
`RefreshSession` call; expired sessions are deleted on access. Background
|
access or via the `StartCleanup(ctx, interval)` background goroutine.
|
||||||
cleanup runs via `StartCleanup(ctx, interval)`.
|
|
||||||
|
|
||||||
### Hardware Key Interface
|
### Hardware Key Interface
|
||||||
|
|
||||||
`hardware.go` defines a `HardwareKey` interface for future PKCS#11, YubiKey,
|
`hardware.go` defines a `HardwareKey` interface for future PKCS#11,
|
||||||
or TPM integration:
|
YubiKey, or TPM integration:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type HardwareKey interface {
|
type HardwareKey interface {
|
||||||
|
|
@ -299,110 +393,166 @@ type HardwareKey interface {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Configured via `WithHardwareKey(hk)`. Integration points are documented in
|
Configured via `WithHardwareKey(hk)`. No concrete implementations exist
|
||||||
`auth.go` but not yet wired — there are no concrete implementations in this
|
yet -- this is a contract-only definition.
|
||||||
module.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## trust/ — Agent Trust and Policy Engine
|
## trust/ -- Agent Trust and Policy Engine
|
||||||
|
|
||||||
### Registry
|
|
||||||
|
|
||||||
`Registry` is a thread-safe map of agent names to `Agent` structs, protected by
|
|
||||||
`sync.RWMutex`. An `Agent` carries:
|
|
||||||
|
|
||||||
- `Name` — unique identifier (e.g. `"Athena"`, `"BugSETI-42"`).
|
|
||||||
- `Tier` — trust level (1, 2, or 3).
|
|
||||||
- `ScopedRepos` — repository patterns constraining Tier 2 repo access.
|
|
||||||
- `RateLimit` — requests per minute (0 = unlimited for Tier 3).
|
|
||||||
- `TokenExpiresAt` — optional token expiry.
|
|
||||||
|
|
||||||
Default rate limits by tier: Tier 1 = 10/min, Tier 2 = 60/min, Tier 3 = unlimited.
|
|
||||||
|
|
||||||
### Trust Tiers
|
### Trust Tiers
|
||||||
|
|
||||||
| Tier | Name | Default Rate Limit | Typical Agents |
|
Agents are assigned one of three trust tiers:
|
||||||
|------|------|-------------------|----------------|
|
|
||||||
| 3 | Full | Unlimited | Athena, Virgil, Charon |
|
| Tier | Name | Value | Default Rate Limit | Typical Agents |
|
||||||
| 2 | Verified | 60/min | Clotho, Hypnos (scoped repos) |
|
|------|------|-------|-------------------|----------------|
|
||||||
| 1 | Untrusted | 10/min | BugSETI community instances |
|
| Full | `TierFull` | 3 | Unlimited | Athena, Virgil, Charon |
|
||||||
|
| Verified | `TierVerified` | 2 | 60 req/min | Clotho, Hypnos |
|
||||||
|
| Untrusted | `TierUntrusted` | 1 | 10 req/min | Community instances |
|
||||||
|
|
||||||
|
### Registry
|
||||||
|
|
||||||
|
`Registry` is a thread-safe map of agent names to `Agent` structs:
|
||||||
|
|
||||||
|
```go
|
||||||
|
registry := trust.NewRegistry()
|
||||||
|
err := registry.Register(trust.Agent{
|
||||||
|
Name: "Clotho",
|
||||||
|
Tier: trust.TierVerified,
|
||||||
|
ScopedRepos: []string{"core/*"},
|
||||||
|
RateLimit: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
agent := registry.Get("Clotho")
|
||||||
|
agents := registry.List() // snapshot slice
|
||||||
|
for a := range registry.ListSeq() { ... } // iterator
|
||||||
|
```
|
||||||
|
|
||||||
### Capabilities
|
### Capabilities
|
||||||
|
|
||||||
Nine capabilities are defined:
|
Nine capabilities are defined as typed constants:
|
||||||
|
|
||||||
| Capability | Description |
|
| Capability | Constant | Description |
|
||||||
|------------|-------------|
|
|------------|----------|-------------|
|
||||||
| `repo.push` | Push commits to a repository |
|
| `repo.push` | `CapPushRepo` | Push commits to a repository |
|
||||||
| `pr.create` | Open a pull request |
|
| `pr.create` | `CapCreatePR` | Open a pull request |
|
||||||
| `pr.merge` | Merge a pull request |
|
| `pr.merge` | `CapMergePR` | Merge a pull request |
|
||||||
| `issue.create` | Create an issue |
|
| `issue.create` | `CapCreateIssue` | Create an issue |
|
||||||
| `issue.comment` | Comment on an issue |
|
| `issue.comment` | `CapCommentIssue` | Comment on an issue |
|
||||||
| `secrets.read` | Read repository secrets |
|
| `secrets.read` | `CapReadSecrets` | Read repository secrets |
|
||||||
| `cmd.privileged` | Run privileged shell commands |
|
| `cmd.privileged` | `CapRunPrivileged` | Run privileged shell commands |
|
||||||
| `workspace.access` | Access another agent's workspace |
|
| `workspace.access` | `CapAccessWorkspace` | Access another agent's workspace |
|
||||||
| `flows.modify` | Modify CI/CD flow definitions |
|
| `flows.modify` | `CapModifyFlows` | Modify CI/CD flow definitions |
|
||||||
|
|
||||||
### Policy Engine
|
### Policy Engine
|
||||||
|
|
||||||
`NewPolicyEngine(registry)` loads default policies. Evaluation order in
|
`NewPolicyEngine(registry)` creates an engine with default policies.
|
||||||
`Evaluate(agentName, cap, repo)`:
|
`Evaluate` returns one of three decisions:
|
||||||
|
|
||||||
1. Agent not in registry → Deny.
|
```go
|
||||||
2. No policy for agent's tier → Deny.
|
engine := trust.NewPolicyEngine(registry)
|
||||||
3. Capability in `Denied` list → Deny.
|
result := engine.Evaluate("Clotho", trust.CapPushRepo, "core/go-crypt")
|
||||||
4. Capability in `RequiresApproval` list → NeedsApproval.
|
|
||||||
5. Capability in `Allowed` list:
|
|
||||||
- If repo-scoped capability and `len(agent.ScopedRepos) > 0`: check repo
|
|
||||||
against scope patterns → Deny if no match.
|
|
||||||
- Otherwise → Allow.
|
|
||||||
6. Capability not in any list → Deny.
|
|
||||||
|
|
||||||
Default policies by tier:
|
switch result.Decision {
|
||||||
|
case trust.Allow:
|
||||||
|
// Proceed
|
||||||
|
case trust.Deny:
|
||||||
|
// Reject with result.Reason
|
||||||
|
case trust.NeedsApproval:
|
||||||
|
// Submit to ApprovalQueue
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
| Tier | Allowed | RequiresApproval | Denied |
|
**Evaluation order**:
|
||||||
|------|---------|-----------------|--------|
|
|
||||||
| Full (3) | All 9 capabilities | — | — |
|
1. Agent not in registry -- `Deny`.
|
||||||
|
2. No policy for the agent's tier -- `Deny`.
|
||||||
|
3. Capability in the `Denied` list -- `Deny`.
|
||||||
|
4. Capability in the `RequiresApproval` list -- `NeedsApproval`.
|
||||||
|
5. Capability in the `Allowed` list:
|
||||||
|
- If the capability is repo-scoped and the agent has `ScopedRepos`:
|
||||||
|
check the repo against scope patterns. No match -- `Deny`.
|
||||||
|
- Otherwise -- `Allow`.
|
||||||
|
6. Capability not in any list -- `Deny`.
|
||||||
|
|
||||||
|
**Default policies by tier**:
|
||||||
|
|
||||||
|
| Tier | Allowed | Requires Approval | Denied |
|
||||||
|
|------|---------|-------------------|--------|
|
||||||
|
| Full (3) | All 9 capabilities | -- | -- |
|
||||||
| Verified (2) | repo.push, pr.create, issue.create, issue.comment, secrets.read | pr.merge | workspace.access, flows.modify, cmd.privileged |
|
| Verified (2) | repo.push, pr.create, issue.create, issue.comment, secrets.read | pr.merge | workspace.access, flows.modify, cmd.privileged |
|
||||||
| Untrusted (1) | pr.create, issue.comment | — | repo.push, pr.merge, issue.create, secrets.read, cmd.privileged, workspace.access, flows.modify |
|
| Untrusted (1) | pr.create, issue.comment | -- | repo.push, pr.merge, issue.create, secrets.read, cmd.privileged, workspace.access, flows.modify |
|
||||||
|
|
||||||
### Repo Scope Matching
|
### Repo Scope Matching
|
||||||
|
|
||||||
`matchScope(pattern, repo)` supports three forms:
|
Tier 2 agents can have their repository access restricted via `ScopedRepos`.
|
||||||
|
Three pattern types are supported:
|
||||||
|
|
||||||
| Pattern | Matches | Does Not Match |
|
| Pattern | Matches | Does Not Match |
|
||||||
|---------|---------|----------------|
|
|---------|---------|----------------|
|
||||||
| `core/go-crypt` | `core/go-crypt` | `core/go-crypt/sub` |
|
| `core/go-crypt` | `core/go-crypt` (exact) | `core/go-crypt/sub` |
|
||||||
| `core/*` | `core/go-crypt` | `core/go-crypt/sub` |
|
| `core/*` | `core/go-crypt`, `core/php` | `core/go-crypt/sub`, `other/repo` |
|
||||||
| `core/**` | `core/go-crypt`, `core/go-crypt/sub` | `other/repo` |
|
| `core/**` | `core/go-crypt`, `core/php/sub`, `core/a/b/c` | `other/repo` |
|
||||||
|
|
||||||
Empty `ScopedRepos` on a Tier 2 agent is treated as unrestricted (no scope
|
Wildcards are only supported at the end of patterns.
|
||||||
check is applied). See known limitations in `docs/history.md` (Finding F3).
|
|
||||||
|
|
||||||
### Approval Queue
|
### Approval Queue
|
||||||
|
|
||||||
`ApprovalQueue` is a thread-safe queue for `NeedsApproval` decisions. It is
|
When the policy engine returns `NeedsApproval`, the caller is responsible
|
||||||
separate from the `PolicyEngine` — the engine returns `NeedsApproval` as a
|
for submitting the request to an `ApprovalQueue`:
|
||||||
decision, and the caller is responsible for submitting to the queue and polling
|
|
||||||
for resolution. The queue tracks: submitting agent, capability, repo context,
|
```go
|
||||||
status (pending/approved/denied), reviewer identity, and timestamps.
|
queue := trust.NewApprovalQueue()
|
||||||
|
|
||||||
|
// Submit a request
|
||||||
|
id, err := queue.Submit("Clotho", trust.CapMergePR, "core/go-crypt")
|
||||||
|
|
||||||
|
// Review pending requests
|
||||||
|
for _, req := range queue.Pending() { ... }
|
||||||
|
|
||||||
|
// Approve or deny
|
||||||
|
queue.Approve(id, "admin", "Looks good")
|
||||||
|
queue.Deny(id, "admin", "Not yet authorised")
|
||||||
|
|
||||||
|
// Check status
|
||||||
|
req := queue.Get(id) // req.Status == trust.ApprovalApproved
|
||||||
|
```
|
||||||
|
|
||||||
|
The queue is thread-safe and tracks timestamps, reviewer identity, and
|
||||||
|
reasons for each decision.
|
||||||
|
|
||||||
### Audit Log
|
### Audit Log
|
||||||
|
|
||||||
`AuditLog` records every policy evaluation as an `AuditEntry`. Entries are
|
Every policy evaluation can be recorded in an append-only audit log:
|
||||||
stored in-memory and optionally streamed as JSON lines to an `io.Writer` for
|
|
||||||
persistence. `Decision` marshals to/from string (`"allow"`, `"deny"`,
|
```go
|
||||||
`"needs_approval"`). `EntriesFor(agent)` filters by agent name.
|
log := trust.NewAuditLog(os.Stdout) // or nil for in-memory only
|
||||||
|
|
||||||
|
result := engine.Evaluate("Clotho", trust.CapPushRepo, "core/php")
|
||||||
|
log.Record(result, "core/php")
|
||||||
|
|
||||||
|
// Query entries
|
||||||
|
entries := log.Entries()
|
||||||
|
agentEntries := log.EntriesFor("Clotho")
|
||||||
|
for e := range log.EntriesForSeq("Clotho") { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
When an `io.Writer` is provided, each entry is serialised as a JSON line
|
||||||
|
for persistent storage. The `Decision` type marshals to and from string
|
||||||
|
values (`"allow"`, `"deny"`, `"needs_approval"`).
|
||||||
|
|
||||||
### Dynamic Policy Configuration
|
### Dynamic Policy Configuration
|
||||||
|
|
||||||
Policies can be loaded from JSON and applied at runtime:
|
Policies can be loaded from JSON at runtime:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
// Load from file
|
||||||
engine.ApplyPoliciesFromFile("/etc/agent/policies.json")
|
engine.ApplyPoliciesFromFile("/etc/agent/policies.json")
|
||||||
|
|
||||||
// Export current state
|
// Load from reader
|
||||||
|
engine.ApplyPolicies(reader)
|
||||||
|
|
||||||
|
// Export current policies
|
||||||
engine.ExportPolicies(os.Stdout)
|
engine.ExportPolicies(os.Stdout)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -412,16 +562,17 @@ JSON format:
|
||||||
{
|
{
|
||||||
"policies": [
|
"policies": [
|
||||||
{
|
{
|
||||||
"tier": 1,
|
"tier": 2,
|
||||||
"allowed": ["pr.create", "issue.comment"],
|
"allowed": ["repo.push", "pr.create", "issue.create"],
|
||||||
"denied": ["repo.push", "pr.merge"]
|
"requires_approval": ["pr.merge"],
|
||||||
|
"denied": ["cmd.privileged", "workspace.access"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`json.Decoder.DisallowUnknownFields()` is set during load to catch
|
The JSON decoder uses `DisallowUnknownFields()` to catch configuration
|
||||||
configuration errors early.
|
errors early.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -432,56 +583,41 @@ configuration errors early.
|
||||||
| KDF (primary) | Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 |
|
| KDF (primary) | Argon2id | Memory=64MB, Time=3, Parallelism=4, KeyLen=32 |
|
||||||
| KDF (alternative) | scrypt | N=32768, r=8, p=1 |
|
| KDF (alternative) | scrypt | N=32768, r=8, p=1 |
|
||||||
| KDF (expansion) | HKDF-SHA256 | Variable key length |
|
| KDF (expansion) | HKDF-SHA256 | Variable key length |
|
||||||
| Symmetric (primary) | ChaCha20-Poly1305 | 24-byte nonce (XChaCha20), 32-byte key |
|
| Symmetric (primary) | XChaCha20-Poly1305 | 24-byte nonce, 32-byte key |
|
||||||
| Symmetric (alternative) | AES-256-GCM | 12-byte nonce, 32-byte key |
|
| Symmetric (alternative) | AES-256-GCM | 12-byte nonce, 32-byte key |
|
||||||
| Password hash | Argon2id | Custom `$argon2id$` format string with random salt |
|
| Password hash (primary) | Argon2id | `$argon2id$` format with random 16-byte salt |
|
||||||
| Password hash (legacy) | LTHN quasi-salted SHA-256 | RFC-0004 (deterministic, no random salt) |
|
| Password hash (fallback) | bcrypt | Configurable cost |
|
||||||
| Password hash (fallback) | Bcrypt | Configurable cost |
|
| Content ID | LTHN quasi-salted SHA-256 | RFC-0004 (deterministic, no random salt) |
|
||||||
| Content ID | LTHN quasi-salted SHA-256 | RFC-0004 |
|
| Asymmetric | RSA-OAEP-SHA256 | 2048+ bit keys |
|
||||||
| Asymmetric | RSA-OAEP-SHA256 | 2048+ bit |
|
| PGP (pgp/) | DSA primary + RSA subkey | ProtonMail go-crypto |
|
||||||
| PGP keypair | DSA primary + RSA subkey | ProtonMail go-crypto |
|
| PGP (openpgp/) | RSA-4096 + AES-256 + SHA-256 | core.Crypt interface |
|
||||||
| PGP service | RSA-4096 + AES-256 + SHA-256 | core.Crypt interface |
|
| HMAC | HMAC-SHA256 / HMAC-SHA512 | Constant-time verification |
|
||||||
| HMAC | HMAC-SHA256 / HMAC-SHA512 | Constant-time verify |
|
| Challenge nonce | crypto/rand | 32 bytes (256-bit entropy) |
|
||||||
| Challenge nonce | crypto/rand | 32 bytes (256-bit) |
|
|
||||||
| Session token | crypto/rand | 32 bytes, hex-encoded (64 chars) |
|
| Session token | crypto/rand | 32 bytes, hex-encoded (64 chars) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
| Module | Version | Role |
|
|
||||||
|--------|---------|------|
|
|
||||||
| `forge.lthn.ai/core/go` | local | `core.E` error helper, `core.Crypt` interface, `io.Medium` storage |
|
|
||||||
| `forge.lthn.ai/core/go-store` | local | SQLite KV store for session persistence |
|
|
||||||
| `github.com/ProtonMail/go-crypto` | v1.3.0 | OpenPGP (actively maintained fork, post-quantum research) |
|
|
||||||
| `golang.org/x/crypto` | v0.48.0 | Argon2, ChaCha20-Poly1305, scrypt, HKDF, bcrypt |
|
|
||||||
| `github.com/cloudflare/circl` | v1.6.3 | Indirect; elliptic curves via ProtonMail |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Points
|
|
||||||
|
|
||||||
| Consumer | Package Used | Purpose |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| go-p2p | `crypt/` | UEPS consent-gated encryption |
|
|
||||||
| go-scm / AgentCI | `trust/` | Agent capability evaluation before CI operations |
|
|
||||||
| go-agentic | `auth/` | Agent session management |
|
|
||||||
| core/go | `crypt/openpgp/` | Service registered via `core.Crypt` interface |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
1. The LTHN hash (`crypt/lthn`) is **not** suitable for password hashing. It
|
1. The LTHN hash (`crypt/lthn`) is **not** suitable for password hashing.
|
||||||
is deterministic with no random salt. Use `crypt.HashPassword` (Argon2id).
|
It is deterministic with no random salt. Use `crypt.HashPassword`
|
||||||
2. PGP private keys are not zeroed after use. The ProtonMail `go-crypto`
|
(Argon2id) for passwords.
|
||||||
|
|
||||||
|
2. PGP private keys are not zeroed after use. The ProtonMail go-crypto
|
||||||
library does not expose a `Wipe` method. This is a known upstream
|
library does not expose a `Wipe` method. This is a known upstream
|
||||||
limitation; mitigating it would require forking the library.
|
limitation.
|
||||||
3. Empty `ScopedRepos` on a Tier 2 agent currently bypasses the repo scope
|
|
||||||
check (treated as unrestricted). Explicit `["*"]` or `["org/**"]` should be
|
3. Empty `ScopedRepos` on a Tier 2 agent currently bypasses the repo
|
||||||
required for unrestricted Tier 2 access if this design is revisited.
|
scope check (treated as unrestricted). Explicit `["*"]` or
|
||||||
|
`["org/**"]` should be required for unrestricted Tier 2 access if
|
||||||
|
this design is revisited.
|
||||||
|
|
||||||
4. The `PolicyEngine` returns decisions but does not enforce the approval
|
4. The `PolicyEngine` returns decisions but does not enforce the approval
|
||||||
workflow. A higher-level layer (go-agentic, go-scm) must handle the
|
workflow. A higher-level layer must handle `NeedsApproval` by routing
|
||||||
`NeedsApproval` case by routing through the `ApprovalQueue`.
|
through the `ApprovalQueue`.
|
||||||
5. The `MemorySessionStore` is the default. Use `WithSessionStore(NewSQLiteSessionStore(path))`
|
|
||||||
for persistence across restarts.
|
5. All randomness uses `crypto/rand`. Never use `math/rand` for
|
||||||
|
cryptographic purposes in this codebase.
|
||||||
|
|
||||||
|
6. Error messages never include secret material. Strings are kept generic:
|
||||||
|
`"invalid password"`, `"session not found"`, `"failed to decrypt"`.
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,29 @@
|
||||||
# Development Guide — go-crypt
|
---
|
||||||
|
title: Development Guide
|
||||||
|
description: How to build, test, and contribute to go-crypt.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development Guide
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Go 1.25 or later (the module declares `go 1.25.5`).
|
- **Go 1.26** or later (the module declares `go 1.26.0`).
|
||||||
- A Go workspace (`go.work`) that resolves the local replace directives for
|
- A Go workspace (`go.work`) that resolves the local dependencies:
|
||||||
`forge.lthn.ai/core/go` (at `../go`) and `forge.lthn.ai/core/go-store`
|
`forge.lthn.ai/core/go`, `forge.lthn.ai/core/go-store`,
|
||||||
(at `../go-store`). If you are working outside the full monorepo, edit
|
`forge.lthn.ai/core/go-io`, `forge.lthn.ai/core/go-log`, and
|
||||||
`go.mod` replace directives to point to your local checkouts.
|
`forge.lthn.ai/core/cli`. If you are working outside the full monorepo,
|
||||||
- No C toolchain, CGo, or system libraries are required.
|
create a `go.work` at the parent directory pointing to your local
|
||||||
|
checkouts.
|
||||||
|
- No C toolchain, CGo, or system libraries are required. All cryptographic
|
||||||
|
operations use pure Go implementations.
|
||||||
|
|
||||||
## Build and Test Commands
|
## Build and Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
# Run with race detector (always use before committing)
|
# Run with race detector (required before committing)
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
|
|
||||||
# Run a single test by name
|
# Run a single test by name
|
||||||
|
|
@ -26,79 +34,96 @@ go test ./auth/...
|
||||||
go test ./crypt/...
|
go test ./crypt/...
|
||||||
go test ./trust/...
|
go test ./trust/...
|
||||||
|
|
||||||
# Static analysis
|
# Static analysis (must be clean before committing)
|
||||||
go vet ./...
|
go vet ./...
|
||||||
|
|
||||||
# Run benchmarks
|
# Run benchmarks
|
||||||
go test -bench=. -benchmem ./crypt/...
|
go test -bench=. -benchmem ./crypt/...
|
||||||
go test -bench=. -benchmem ./trust/...
|
go test -bench=. -benchmem ./trust/...
|
||||||
|
|
||||||
|
# Extended benchmark run
|
||||||
|
go test -bench=. -benchmem -benchtime=3s ./crypt/...
|
||||||
```
|
```
|
||||||
|
|
||||||
There is no build step — this is a library module with no binaries. The
|
If using the `core` CLI:
|
||||||
`go vet ./...` check must pass cleanly before any commit.
|
|
||||||
|
```bash
|
||||||
|
core go test
|
||||||
|
core go test --run TestName
|
||||||
|
core go qa # fmt + vet + lint + test
|
||||||
|
core go qa full # + race, vuln, security
|
||||||
|
```
|
||||||
|
|
||||||
## Repository Layout
|
## Repository Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
go-crypt/
|
go-crypt/
|
||||||
├── auth/ Authentication package
|
├── auth/ Authentication: Authenticator, sessions, key management
|
||||||
├── crypt/ Cryptographic utilities
|
├── cmd/
|
||||||
│ ├── chachapoly/ Standalone ChaCha20-Poly1305 sub-package
|
│ ├── crypt/ CLI commands: encrypt, decrypt, hash, keygen, checksum
|
||||||
│ ├── lthn/ RFC-0004 quasi-salted hash
|
│ └── testcmd/ Test runner commands
|
||||||
│ ├── openpgp/ Service wrapper (core.Crypt interface)
|
├── crypt/ Symmetric encryption, hashing, key derivation
|
||||||
|
│ ├── chachapoly/ Standalone ChaCha20-Poly1305 AEAD
|
||||||
|
│ ├── lthn/ RFC-0004 quasi-salted deterministic hash
|
||||||
|
│ ├── openpgp/ core.Crypt service wrapper
|
||||||
│ ├── pgp/ OpenPGP primitives
|
│ ├── pgp/ OpenPGP primitives
|
||||||
│ └── rsa/ RSA OAEP-SHA256
|
│ └── rsa/ RSA-OAEP-SHA256
|
||||||
├── docs/ Architecture, development, and history docs
|
├── docs/ Documentation
|
||||||
├── trust/ Agent trust model and policy engine
|
├── trust/ Agent trust model, policy engine, audit log
|
||||||
├── go.mod
|
├── go.mod
|
||||||
└── go.sum
|
└── go.sum
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test Patterns
|
## Test Patterns
|
||||||
|
|
||||||
Tests use the `github.com/stretchr/testify` library (`assert` and `require`).
|
Tests use `github.com/stretchr/testify` (`assert` and `require`). The
|
||||||
The naming convention follows three suffixes:
|
naming convention uses three suffixes to categorise test intent:
|
||||||
|
|
||||||
| Suffix | Purpose |
|
| Suffix | Purpose |
|
||||||
|--------|---------|
|
|--------|---------|
|
||||||
| `_Good` | Happy path — expected success |
|
| `_Good` | Happy path -- expected success |
|
||||||
| `_Bad` | Expected failure — invalid input, wrong credentials, not-found errors |
|
| `_Bad` | Expected failure -- invalid input, wrong credentials, not-found errors |
|
||||||
| `_Ugly` | Edge cases — panics, zero values, empty inputs, extreme lengths |
|
| `_Ugly` | Edge cases -- panics, zero values, empty inputs, extreme lengths |
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestLogin_Good(t *testing.T) { ... }
|
func TestLogin_Good(t *testing.T) {
|
||||||
func TestLogin_Bad(t *testing.T) { ... }
|
// Register a user, log in with correct password, verify session
|
||||||
func TestLogin_Ugly(t *testing.T) { ... }
|
}
|
||||||
|
|
||||||
|
func TestLogin_Bad(t *testing.T) {
|
||||||
|
// Attempt login with wrong password, verify rejection
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogin_Ugly(t *testing.T) {
|
||||||
|
// Empty password, very long input, Unicode edge cases
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Concurrency tests use `t.Parallel()` and typically spawn 10 goroutines via a
|
### Concurrency Tests
|
||||||
`sync.WaitGroup`. The race detector (`-race`) must pass for all concurrent tests.
|
|
||||||
|
|
||||||
## Benchmark Structure
|
Concurrent tests spawn 10 goroutines via a `sync.WaitGroup` and use
|
||||||
|
`t.Parallel()`. The race detector (`go test -race`) must pass for all
|
||||||
|
concurrent tests. Examples include concurrent session creation, concurrent
|
||||||
|
registry access, and concurrent policy evaluation.
|
||||||
|
|
||||||
Benchmarks live in `bench_test.go` files alongside the packages they cover.
|
### Benchmarks
|
||||||
Benchmark names follow the `BenchmarkFuncName_Context` pattern:
|
|
||||||
|
|
||||||
```go
|
Benchmarks live in `bench_test.go` files alongside the packages they cover:
|
||||||
func BenchmarkArgon2Derive(b *testing.B) { ... }
|
|
||||||
func BenchmarkChaCha20_1KB(b *testing.B) { ... }
|
|
||||||
func BenchmarkChaCha20_1MB(b *testing.B) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
Run benchmarks with:
|
- `crypt/bench_test.go`: Argon2id derivation, ChaCha20 and AES-GCM at
|
||||||
|
1KB and 1MB payloads, HMAC-SHA256, HMAC verification.
|
||||||
|
- `trust/bench_test.go`: policy evaluation with 100 agents, registry
|
||||||
|
get, registry register.
|
||||||
|
|
||||||
```bash
|
**Note**: The Argon2id KDF is intentionally slow (~200ms on typical
|
||||||
go test -bench=. -benchmem -benchtime=3s ./crypt/...
|
hardware). This is a security property, not a performance defect. Do not
|
||||||
```
|
optimise KDF parameters without understanding the security implications.
|
||||||
|
|
||||||
Do not optimise without measuring first. The Argon2id KDF is intentionally slow
|
|
||||||
(~200ms on typical hardware) — this is a security property, not a defect.
|
|
||||||
|
|
||||||
## Adding a New Cryptographic Primitive
|
## Adding a New Cryptographic Primitive
|
||||||
|
|
||||||
1. Add the implementation in the appropriate sub-package.
|
1. Add the implementation in the appropriate sub-package under `crypt/`.
|
||||||
2. Write tests covering `_Good`, `_Bad`, and `_Ugly` cases.
|
2. Write tests covering `_Good`, `_Bad`, and `_Ugly` cases.
|
||||||
3. Add a benchmark if the function is called on hot paths.
|
3. Add a benchmark if the function is called on hot paths.
|
||||||
4. Update `docs/architecture.md` with the algorithm reference entry.
|
4. Update `docs/architecture.md` with the algorithm reference entry.
|
||||||
|
|
@ -107,8 +132,8 @@ Do not optimise without measuring first. The Argon2id KDF is intentionally slow
|
||||||
## Adding a New Trust Capability
|
## Adding a New Trust Capability
|
||||||
|
|
||||||
1. Add the `Capability` constant in `trust/trust.go`.
|
1. Add the `Capability` constant in `trust/trust.go`.
|
||||||
2. Update `isRepoScoped()` in `trust/policy.go` if the capability is
|
2. If the capability is repository-scoped, update `isRepoScoped()` in
|
||||||
repository-scoped.
|
`trust/policy.go`.
|
||||||
3. Update the default policies in `loadDefaults()` in `trust/policy.go`.
|
3. Update the default policies in `loadDefaults()` in `trust/policy.go`.
|
||||||
4. Add tests covering all three tiers.
|
4. Add tests covering all three tiers.
|
||||||
5. Update the capability table in `docs/architecture.md`.
|
5. Update the capability table in `docs/architecture.md`.
|
||||||
|
|
@ -122,29 +147,30 @@ _licence_ (noun), _license_ (verb), _behaviour_, _initialise_, _serialise_.
|
||||||
|
|
||||||
### Go Style
|
### Go Style
|
||||||
|
|
||||||
- `declare(strict_types=1)` is a PHP convention; Go has no equivalent. Use
|
|
||||||
explicit type assertions and avoid `any` except at interface boundaries.
|
|
||||||
- Every exported function and type must have a doc comment.
|
- Every exported function and type must have a doc comment.
|
||||||
- Error strings are lowercase and do not end with a full stop, per Go convention.
|
- Error strings are lowercase and do not end with a full stop, per Go
|
||||||
- Use the `core.E(op, msg, err)` helper from `forge.lthn.ai/core/go` for
|
convention.
|
||||||
contextual error wrapping: `op` is `"package.Function"`, `msg` is a brief
|
- Use the `core.E(op, msg, err)` helper for contextual error wrapping:
|
||||||
lowercase description.
|
`op` is `"package.Function"`, `msg` is a brief lowercase description.
|
||||||
- Import groups: stdlib → `forge.lthn.ai/core` → third-party. Separate each
|
- Import groups, separated by blank lines: stdlib, then `forge.lthn.ai/core`,
|
||||||
group with a blank line.
|
then third-party.
|
||||||
|
- Avoid `any` except at interface boundaries. Prefer explicit type
|
||||||
|
assertions.
|
||||||
|
|
||||||
### Cryptography
|
### Cryptographic Safety
|
||||||
|
|
||||||
- All randomness from `crypto/rand`. Never use `math/rand` for cryptographic
|
- All randomness from `crypto/rand`. Never use `math/rand` for
|
||||||
purposes.
|
cryptographic purposes.
|
||||||
- Use `crypto/subtle.ConstantTimeCompare` for any comparison of secret material
|
- Use `crypto/subtle.ConstantTimeCompare` for any comparison of secret
|
||||||
(MACs, hashes). The one exception is `lthn.Verify`, which compares content
|
material (MACs, password hashes, session tokens).
|
||||||
identifiers (not secrets) and documents this explicitly.
|
- Never log or return secrets in error messages. Keep error strings
|
||||||
- Never log or return secrets in error messages. Error strings should be generic:
|
generic: `"invalid password"`, `"session not found"`, `"failed to
|
||||||
`"invalid password"`, `"session not found"`, `"failed to decrypt"`.
|
decrypt"`.
|
||||||
|
|
||||||
### Licence
|
### Licence
|
||||||
|
|
||||||
All files are licenced under EUPL-1.2. Do not add files under a different licence.
|
All files are licenced under EUPL-1.2. Do not add files under a different
|
||||||
|
licence.
|
||||||
|
|
||||||
## Commit Convention
|
## Commit Convention
|
||||||
|
|
||||||
|
|
@ -158,10 +184,10 @@ Optional body explaining motivation and context.
|
||||||
Co-Authored-By: Virgil <virgil@lethean.io>
|
Co-Authored-By: Virgil <virgil@lethean.io>
|
||||||
```
|
```
|
||||||
|
|
||||||
Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`.
|
**Types**: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`.
|
||||||
|
|
||||||
Scopes match package names: `auth`, `crypt`, `trust`, `pgp`, `lthn`, `rsa`,
|
**Scopes** match package names: `auth`, `crypt`, `trust`, `pgp`, `lthn`,
|
||||||
`openpgp`, `chachapoly`.
|
`rsa`, `openpgp`, `chachapoly`.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
|
|
@ -169,29 +195,46 @@ Examples:
|
||||||
feat(auth): add SQLite session store for crash recovery
|
feat(auth): add SQLite session store for crash recovery
|
||||||
fix(trust): reject empty ScopedRepos as no-access for Tier 2
|
fix(trust): reject empty ScopedRepos as no-access for Tier 2
|
||||||
test(crypt): add benchmark suite for Argon2 and ChaCha20
|
test(crypt): add benchmark suite for Argon2 and ChaCha20
|
||||||
|
docs(trust): document approval queue workflow
|
||||||
```
|
```
|
||||||
|
|
||||||
## Forge Push
|
## Pushing to Forge
|
||||||
|
|
||||||
The canonical remote is `forge.lthn.ai`. Push via SSH only; HTTPS authentication
|
The canonical remote is `forge.lthn.ai`. Push via SSH only:
|
||||||
is not configured:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git push forge main
|
git push forge main
|
||||||
# remote: ssh://git@forge.lthn.ai:2223/core/go-crypt.git
|
# remote: ssh://git@forge.lthn.ai:2223/core/go-crypt.git
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local Replace Directives
|
HTTPS authentication is not configured for this repository.
|
||||||
|
|
||||||
The `go.mod` contains:
|
## Local Dependencies
|
||||||
|
|
||||||
```
|
The `go.mod` depends on several `forge.lthn.ai/core/*` modules. These are
|
||||||
replace (
|
resolved through the Go workspace (`~/Code/go.work`). Do not modify the
|
||||||
forge.lthn.ai/core/go => ../go
|
replace directives in `go.mod` directly -- use the workspace file instead.
|
||||||
forge.lthn.ai/core/go-store => ../go-store
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not modify these paths. If you need to work with a different local checkout,
|
| Module | Local Path | Purpose |
|
||||||
use a Go workspace (`go.work`) at the parent directory level rather than editing
|
|--------|-----------|---------|
|
||||||
the replace directives directly.
|
| `forge.lthn.ai/core/go` | `../go` | Framework: `core.Crypt` interface, `io.Medium` |
|
||||||
|
| `forge.lthn.ai/core/go-store` | `../go-store` | SQLite KV store for session persistence |
|
||||||
|
| `forge.lthn.ai/core/go-io` | `../go-io` | `io.Medium` storage abstraction |
|
||||||
|
| `forge.lthn.ai/core/go-log` | `../go-log` | `core.E()` contextual error wrapping |
|
||||||
|
| `forge.lthn.ai/core/cli` | `../cli` | CLI framework for `cmd/crypt` commands |
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
For a full list of known limitations and open security findings, see
|
||||||
|
[history.md](history.md).
|
||||||
|
|
||||||
|
Key items:
|
||||||
|
|
||||||
|
- **Dual ChaCha20 implementations**: `crypt/symmetric.go` and
|
||||||
|
`crypt/chachapoly/` are nearly identical. Consolidation would reduce
|
||||||
|
duplication but requires updating all importers.
|
||||||
|
- **Hardware key interface**: contract-only, no concrete implementations.
|
||||||
|
- **Session cleanup logging**: uses `fmt.Printf` rather than a structured
|
||||||
|
logger. Callers needing structured logs should wrap the cleanup goroutine.
|
||||||
|
- **Rate limiting**: the `Agent.RateLimit` field is stored but never
|
||||||
|
enforced. Enforcement belongs in a higher-level middleware layer.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
163
docs/index.md
Normal file
163
docs/index.md
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
---
|
||||||
|
title: go-crypt
|
||||||
|
description: Cryptographic primitives, authentication, and trust policy engine for the Lethean agent platform.
|
||||||
|
---
|
||||||
|
|
||||||
|
# go-crypt
|
||||||
|
|
||||||
|
**Module**: `forge.lthn.ai/core/go-crypt`
|
||||||
|
**Licence**: EUPL-1.2
|
||||||
|
**Language**: Go 1.26
|
||||||
|
|
||||||
|
Cryptographic primitives, authentication, and trust policy engine for the
|
||||||
|
Lethean agent platform. Provides symmetric encryption, password hashing,
|
||||||
|
OpenPGP authentication with both online and air-gapped modes, RSA key
|
||||||
|
management, deterministic content hashing, and a three-tier agent access
|
||||||
|
control system with an audit log and approval queue.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```go
|
||||||
|
import (
|
||||||
|
"forge.lthn.ai/core/go-crypt/crypt"
|
||||||
|
"forge.lthn.ai/core/go-crypt/auth"
|
||||||
|
"forge.lthn.ai/core/go-crypt/trust"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Encrypt and Decrypt Data
|
||||||
|
|
||||||
|
The default cipher is XChaCha20-Poly1305 with Argon2id key derivation. A
|
||||||
|
random salt and nonce are generated automatically and prepended to the
|
||||||
|
ciphertext.
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Encrypt with ChaCha20-Poly1305 + Argon2id KDF
|
||||||
|
ciphertext, err := crypt.Encrypt(plaintext, []byte("my passphrase"))
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
plaintext, err := crypt.Decrypt(ciphertext, []byte("my passphrase"))
|
||||||
|
|
||||||
|
// Or use AES-256-GCM instead
|
||||||
|
ciphertext, err := crypt.EncryptAES(plaintext, []byte("my passphrase"))
|
||||||
|
plaintext, err := crypt.DecryptAES(ciphertext, []byte("my passphrase"))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hash and Verify Passwords
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Hash with Argon2id (recommended)
|
||||||
|
hash, err := crypt.HashPassword("hunter2")
|
||||||
|
// Returns: $argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>
|
||||||
|
|
||||||
|
// Verify (constant-time comparison)
|
||||||
|
match, err := crypt.VerifyPassword("hunter2", hash)
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenPGP Authentication
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Create an authenticator backed by a storage medium
|
||||||
|
a := auth.New(medium,
|
||||||
|
auth.WithSessionStore(sqliteStore),
|
||||||
|
auth.WithSessionTTL(8 * time.Hour),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register a user (generates PGP keypair, stores credentials)
|
||||||
|
user, err := a.Register("alice", "password123")
|
||||||
|
|
||||||
|
// Password-based login (bypasses PGP challenge-response)
|
||||||
|
session, err := a.Login(userID, "password123")
|
||||||
|
|
||||||
|
// Validate a session token
|
||||||
|
session, err := a.ValidateSession(token)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trust Policy Evaluation
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Set up a registry and register agents
|
||||||
|
registry := trust.NewRegistry()
|
||||||
|
registry.Register(trust.Agent{
|
||||||
|
Name: "Athena",
|
||||||
|
Tier: trust.TierFull,
|
||||||
|
})
|
||||||
|
registry.Register(trust.Agent{
|
||||||
|
Name: "Clotho",
|
||||||
|
Tier: trust.TierVerified,
|
||||||
|
ScopedRepos: []string{"core/*"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Evaluate capabilities
|
||||||
|
engine := trust.NewPolicyEngine(registry)
|
||||||
|
result := engine.Evaluate("Athena", trust.CapPushRepo, "core/go-crypt")
|
||||||
|
// result.Decision == trust.Allow
|
||||||
|
|
||||||
|
result = engine.Evaluate("Clotho", trust.CapMergePR, "core/go-crypt")
|
||||||
|
// result.Decision == trust.NeedsApproval
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Layout
|
||||||
|
|
||||||
|
| Package | Import Path | Description |
|
||||||
|
|---------|-------------|-------------|
|
||||||
|
| `crypt` | `go-crypt/crypt` | High-level encrypt/decrypt (ChaCha20 + AES), password hashing, HMAC, checksums, key derivation |
|
||||||
|
| `crypt/chachapoly` | `go-crypt/crypt/chachapoly` | Standalone ChaCha20-Poly1305 AEAD wrapper |
|
||||||
|
| `crypt/lthn` | `go-crypt/crypt/lthn` | RFC-0004 quasi-salted deterministic hash for content identifiers |
|
||||||
|
| `crypt/pgp` | `go-crypt/crypt/pgp` | OpenPGP key generation, encryption, decryption, signing, verification |
|
||||||
|
| `crypt/rsa` | `go-crypt/crypt/rsa` | RSA-OAEP-SHA256 key generation and encryption (2048+ bit) |
|
||||||
|
| `crypt/openpgp` | `go-crypt/crypt/openpgp` | Service wrapper implementing the `core.Crypt` interface with IPC support |
|
||||||
|
| `auth` | `go-crypt/auth` | OpenPGP challenge-response authentication, session management, key rotation/revocation |
|
||||||
|
| `trust` | `go-crypt/trust` | Agent trust model, policy engine, approval queue, audit log |
|
||||||
|
| `cmd/crypt` | `go-crypt/cmd/crypt` | CLI commands: `crypt encrypt`, `crypt decrypt`, `crypt hash`, `crypt keygen`, `crypt checksum` |
|
||||||
|
|
||||||
|
## CLI Commands
|
||||||
|
|
||||||
|
The `cmd/crypt` package registers a `crypt` command group with the `core` CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Encrypt a file (ChaCha20-Poly1305 by default)
|
||||||
|
core crypt encrypt myfile.txt -p "passphrase"
|
||||||
|
core crypt encrypt myfile.txt --aes -p "passphrase"
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
core crypt decrypt myfile.txt.enc -p "passphrase"
|
||||||
|
|
||||||
|
# Hash a password
|
||||||
|
core crypt hash "my password" # Argon2id
|
||||||
|
core crypt hash "my password" --bcrypt # Bcrypt
|
||||||
|
|
||||||
|
# Verify a password against a hash
|
||||||
|
core crypt hash "my password" --verify '$argon2id$v=19$...'
|
||||||
|
|
||||||
|
# Generate a random key
|
||||||
|
core crypt keygen # 32 bytes, hex
|
||||||
|
core crypt keygen -l 64 --base64 # 64 bytes, base64
|
||||||
|
|
||||||
|
# Compute file checksums
|
||||||
|
core crypt checksum myfile.txt # SHA-256
|
||||||
|
core crypt checksum myfile.txt --sha512
|
||||||
|
core crypt checksum myfile.txt --verify "abc123..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
| Module | Role |
|
||||||
|
|--------|------|
|
||||||
|
| `forge.lthn.ai/core/go` | Framework: `core.E` error helper, `core.Crypt` interface, `io.Medium` storage abstraction |
|
||||||
|
| `forge.lthn.ai/core/go-store` | SQLite KV store for persistent session storage |
|
||||||
|
| `forge.lthn.ai/core/go-io` | `io.Medium` interface used by the auth package |
|
||||||
|
| `forge.lthn.ai/core/go-log` | Contextual error wrapping via `core.E()` |
|
||||||
|
| `forge.lthn.ai/core/cli` | CLI framework for the `cmd/crypt` commands |
|
||||||
|
| `github.com/ProtonMail/go-crypto` | OpenPGP implementation (actively maintained, post-quantum research) |
|
||||||
|
| `golang.org/x/crypto` | Argon2id, ChaCha20-Poly1305, scrypt, HKDF, bcrypt |
|
||||||
|
| `github.com/stretchr/testify` | Test assertions (`assert`, `require`) |
|
||||||
|
|
||||||
|
No C toolchain or CGo is required. All cryptographic operations use pure Go
|
||||||
|
implementations.
|
||||||
|
|
||||||
|
## Further Reading
|
||||||
|
|
||||||
|
- [Architecture](architecture.md) -- internals, data flow, algorithm reference
|
||||||
|
- [Development](development.md) -- building, testing, contributing
|
||||||
|
- [History](history.md) -- completed phases, security audit findings, known limitations
|
||||||
41
go.mod
41
go.mod
|
|
@ -1,25 +1,27 @@
|
||||||
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.1.0
|
dappco.re/go/core v0.5.0
|
||||||
forge.lthn.ai/core/go-i18n v0.0.1
|
dappco.re/go/core/i18n v0.2.0
|
||||||
forge.lthn.ai/core/go-io v0.0.1
|
dappco.re/go/core/io v0.2.0
|
||||||
forge.lthn.ai/core/go-log v0.0.1
|
dappco.re/go/core/log v0.1.0
|
||||||
forge.lthn.ai/core/go-store v0.1.3
|
forge.lthn.ai/core/cli v0.3.7
|
||||||
github.com/ProtonMail/go-crypto v1.3.0
|
forge.lthn.ai/core/go-store v0.1.10
|
||||||
github.com/spf13/cobra v1.10.2
|
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.48.0
|
golang.org/x/crypto v0.49.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/go v0.1.0 // indirect
|
forge.lthn.ai/core/go v0.3.2 // indirect
|
||||||
forge.lthn.ai/core/go-inference v0.0.1 // 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.2 // indirect
|
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||||
|
|
@ -35,7 +37,7 @@ require (
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
|
@ -43,15 +45,16 @@ require (
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
github.com/spf13/pflag v1.0.10 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
golang.org/x/term v0.40.0 // indirect
|
golang.org/x/term v0.41.0 // indirect
|
||||||
golang.org/x/text v0.34.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.68.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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
94
go.sum
94
go.sum
|
|
@ -1,25 +1,31 @@
|
||||||
forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM=
|
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
|
||||||
forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc=
|
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||||
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
|
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
|
||||||
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
|
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
||||||
forge.lthn.ai/core/go-i18n v0.0.1 h1:7I2cOv3GCc7MssLny/CAnwz3L7/Y4iqwzrCRQMQ+teA=
|
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||||
forge.lthn.ai/core/go-i18n v0.0.1/go.mod h1:nyiGwZ3jV4h9Yge6mSrKVTo7CI1LI/p3ydI+9jUnMtk=
|
dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E=
|
||||||
forge.lthn.ai/core/go-inference v0.0.1 h1:hf5eOzm5sNDifhb0BscMTyKEkB44r2Tv58wakHGvtz4=
|
dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc=
|
||||||
forge.lthn.ai/core/go-inference v0.0.1/go.mod h1:pq2JCmbWLHgik0QdAflGb3raJcCGC44xt8rCUtDjFys=
|
dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs=
|
||||||
forge.lthn.ai/core/go-io v0.0.1 h1:N/GCl6Asusfr4gs53JZixJVtqcnerQ6GcxSN8F8iJXY=
|
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
|
||||||
forge.lthn.ai/core/go-io v0.0.1/go.mod h1:l+gG/G5TMIOTG8G7y0dg4fh1a7Suy8wCYVwsz4duV7M=
|
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
|
||||||
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
|
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
|
||||||
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
|
||||||
forge.lthn.ai/core/go-store v0.1.3 h1:CSVTRdsOXm2pl+FCs12fHOc9eM88DcZRY6HghN98w/I=
|
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
|
||||||
forge.lthn.ai/core/go-store v0.1.3/go.mod h1:op+ftjAqYskPv4OGvHZQf7/DLiRnFIdT0XCQTKR/GjE=
|
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
|
||||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
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/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
|
||||||
|
forge.lthn.ai/core/go-store v0.1.10 h1:JLyf8xMR3V6PfBAW1kv6SJeHsYY93LacEBpTFW657qE=
|
||||||
|
forge.lthn.ai/core/go-store v0.1.10/go.mod h1:VNnHh94TMD3+L+sSgvxn0GHtDKhJR8FD6JiuIuRtjuk=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
|
||||||
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
|
||||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||||
|
|
@ -59,8 +65,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
|
@ -88,24 +94,24 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|
@ -113,18 +119,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
|
@ -133,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=
|
||||||
|
|
|
||||||
|
|
@ -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 ©
|
return &snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending returns all requests with ApprovalPending status.
|
// Pending returns all requests with ApprovalPending status.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue