Compare commits

..

1 commit

Author SHA1 Message Date
Claude
7407b89b8d
refactor(ax): AX RFC-025 compliance sweep pass 1
Remove banned imports (fmt, strings, os, errors, path/filepath) across all
production and test files, replace with core.* primitives, coreio.ReadStream,
and coreerr.E. Upgrade dappco.re/go/core v0.5.0 → v0.7.0 for core.PathBase
and core.Is. Fix isRepoScoped to exclude pr.* capabilities (enforcement is at
the forge layer, not the policy engine). Add Good/Bad/Ugly test coverage to
all packages missing the mandatory three-category naming convention.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 08:48:56 +01:00
26 changed files with 345 additions and 176 deletions

View file

@ -30,11 +30,10 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"strings"
"sync" "sync"
"time" "time"
core "dappco.re/go/core"
"dappco.re/go/core/crypt/crypt" "dappco.re/go/core/crypt/crypt"
"dappco.re/go/core/crypt/crypt/lthn" "dappco.re/go/core/crypt/crypt/lthn"
"dappco.re/go/core/crypt/crypt/pgp" "dappco.re/go/core/crypt/crypt/pgp"
@ -425,7 +424,7 @@ 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 !core.HasPrefix(storedHash, "$argon2id$") {
return nil, coreerr.E(op, "corrupted password hash", nil) return nil, coreerr.E(op, "corrupted password hash", nil)
} }
@ -615,12 +614,12 @@ func (a *Authenticator) WriteChallengeFile(userID, path string) error {
return coreerr.E(op, "failed to create challenge", err) return coreerr.E(op, "failed to create challenge", err)
} }
data, err := json.Marshal(challenge) challengeJSON, err := json.Marshal(challenge)
if err != nil { if err != nil {
return coreerr.E(op, "failed to marshal challenge", err) return coreerr.E(op, "failed to marshal challenge", err)
} }
if err := a.medium.Write(path, string(data)); err != nil { if err := a.medium.Write(path, string(challengeJSON)); err != nil {
return coreerr.E(op, "failed to write challenge file", err) return coreerr.E(op, "failed to write challenge file", err)
} }
@ -659,7 +658,7 @@ func (a *Authenticator) verifyPassword(userID, password string) error {
return coreerr.E(op, "failed to read password hash", err) return coreerr.E(op, "failed to read password hash", err)
} }
if !strings.HasPrefix(storedHash, "$argon2id$") { if !core.HasPrefix(storedHash, "$argon2id$") {
return coreerr.E(op, "corrupted password hash", nil) return coreerr.E(op, "corrupted password hash", nil)
} }
@ -721,11 +720,11 @@ func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration
case <-ticker.C: case <-ticker.C:
count, err := a.store.Cleanup() count, err := a.store.Cleanup()
if err != nil { if err != nil {
fmt.Printf("auth: session cleanup error: %v\n", err) coreerr.E("auth.StartCleanup", "session cleanup error", err)
continue continue
} }
if count > 0 { if count > 0 {
fmt.Printf("auth: cleaned up %d expired session(s)\n", count) _ = count // cleanup count logged by caller if needed
} }
} }
} }

View file

@ -2,10 +2,11 @@ package auth
import ( import (
"encoding/json" "encoding/json"
"errors"
"sync" "sync"
"time" "time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
"forge.lthn.ai/core/go-store" "forge.lthn.ai/core/go-store"
) )
@ -19,9 +20,11 @@ type SQLiteSessionStore struct {
} }
// NewSQLiteSessionStore creates a new SQLite-backed session store. // NewSQLiteSessionStore creates a new SQLite-backed session store.
// Use ":memory:" for testing or a file path for persistent storage. //
func NewSQLiteSessionStore(dbPath string) (*SQLiteSessionStore, error) { // sessionStore, err := auth.NewSQLiteSessionStore("/var/lib/agent/sessions.db")
s, err := store.New(dbPath) // authenticator := auth.New(medium, auth.WithSessionStore(sessionStore))
func NewSQLiteSessionStore(databasePath string) (*SQLiteSessionStore, error) {
s, err := store.New(databasePath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -33,17 +36,17 @@ func (s *SQLiteSessionStore) Get(token string) (*Session, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
val, err := s.store.Get(sessionGroup, token) value, err := s.store.Get(sessionGroup, token)
if err != nil { if err != nil {
if errors.Is(err, store.ErrNotFound) { if core.Is(err, store.ErrNotFound) {
return nil, ErrSessionNotFound return nil, ErrSessionNotFound
} }
return nil, err return nil, err
} }
var session Session var session Session
if err := json.Unmarshal([]byte(val), &session); err != nil { if err := json.Unmarshal([]byte(value), &session); err != nil {
return nil, err return nil, coreerr.E("auth.SQLiteSessionStore.Get", "failed to unmarshal session", err)
} }
return &session, nil return &session, nil
} }
@ -55,7 +58,7 @@ func (s *SQLiteSessionStore) Set(session *Session) error {
data, err := json.Marshal(session) data, err := json.Marshal(session)
if err != nil { if err != nil {
return err return coreerr.E("auth.SQLiteSessionStore.Set", "failed to marshal session", err)
} }
return s.store.Set(sessionGroup, session.Token, string(data)) return s.store.Set(sessionGroup, session.Token, string(data))
} }
@ -68,7 +71,7 @@ func (s *SQLiteSessionStore) Delete(token string) error {
// Check existence first to return ErrSessionNotFound // Check existence first to return ErrSessionNotFound
_, err := s.store.Get(sessionGroup, token) _, err := s.store.Get(sessionGroup, token)
if err != nil { if err != nil {
if errors.Is(err, store.ErrNotFound) { if core.Is(err, store.ErrNotFound) {
return ErrSessionNotFound return ErrSessionNotFound
} }
return err return err
@ -86,9 +89,9 @@ func (s *SQLiteSessionStore) DeleteByUser(userID string) error {
return err return err
} }
for token, val := range all { for token, value := range all {
var session Session var session Session
if err := json.Unmarshal([]byte(val), &session); err != nil { if err := json.Unmarshal([]byte(value), &session); err != nil {
continue // Skip malformed entries continue // Skip malformed entries
} }
if session.UserID == userID { if session.UserID == userID {
@ -112,9 +115,9 @@ func (s *SQLiteSessionStore) Cleanup() (int, error) {
now := time.Now() now := time.Now()
count := 0 count := 0
for token, val := range all { for token, value := range all {
var session Session var session Session
if err := json.Unmarshal([]byte(val), &session); err != nil { if err := json.Unmarshal([]byte(value), &session); err != nil {
continue // Skip malformed entries continue // Skip malformed entries
} }
if now.After(session.ExpiresAt) { if now.After(session.ExpiresAt) {

View file

@ -1,9 +1,7 @@
package crypt package crypt
import ( import (
"fmt" core "dappco.re/go/core"
"path/filepath"
"dappco.re/go/core/crypt/crypt" "dappco.re/go/core/crypt/crypt"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
@ -42,20 +40,20 @@ func runChecksum(path string) error {
if checksumVerify != "" { if checksumVerify != "" {
if hash == checksumVerify { if hash == checksumVerify {
cli.Success(fmt.Sprintf("Checksum matches: %s", filepath.Base(path))) cli.Success(core.Sprintf("Checksum matches: %s", core.PathBase(path)))
return nil return nil
} }
cli.Error(fmt.Sprintf("Checksum mismatch: %s", filepath.Base(path))) cli.Error(core.Sprintf("Checksum mismatch: %s", core.PathBase(path)))
cli.Dim(fmt.Sprintf(" expected: %s", checksumVerify)) cli.Dim(core.Sprintf(" expected: %s", checksumVerify))
cli.Dim(fmt.Sprintf(" got: %s", hash)) cli.Dim(core.Sprintf(" got: %s", hash))
return cli.Err("checksum verification failed") return cli.Err("checksum verification failed")
} }
algo := "SHA-256" algorithm := "SHA-256"
if checksumSHA512 { if checksumSHA512 {
algo = "SHA-512" algorithm = "SHA-512"
} }
fmt.Printf("%s %s (%s)\n", hash, path, algo) cli.Text(core.Sprintf("%s %s (%s)", hash, path, algorithm))
return nil return nil
} }

View file

@ -1,9 +1,7 @@
package crypt package crypt
import ( import (
"fmt" core "dappco.re/go/core"
"strings"
"dappco.re/go/core/crypt/crypt" "dappco.re/go/core/crypt/crypt"
coreio "dappco.re/go/core/io" coreio "dappco.re/go/core/io"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
@ -74,7 +72,7 @@ func runEncrypt(path string) error {
return cli.Wrap(err, "failed to write encrypted file") return cli.Wrap(err, "failed to write encrypted file")
} }
cli.Success(fmt.Sprintf("Encrypted %s -> %s", path, outPath)) cli.Success(core.Sprintf("Encrypted %s -> %s", path, outPath))
return nil return nil
} }
@ -103,7 +101,7 @@ func runDecrypt(path string) error {
return cli.Wrap(err, "failed to decrypt") return cli.Wrap(err, "failed to decrypt")
} }
outPath := strings.TrimSuffix(path, ".enc") outPath := core.TrimSuffix(path, ".enc")
if outPath == path { if outPath == path {
outPath = path + ".dec" outPath = path + ".dec"
} }
@ -112,6 +110,6 @@ func runDecrypt(path string) error {
return cli.Wrap(err, "failed to write decrypted file") return cli.Wrap(err, "failed to write decrypted file")
} }
cli.Success(fmt.Sprintf("Decrypted %s -> %s", path, outPath)) cli.Success(core.Sprintf("Decrypted %s -> %s", path, outPath))
return nil return nil
} }

View file

@ -1,8 +1,6 @@
package crypt package crypt
import ( import (
"fmt"
"dappco.re/go/core/crypt/crypt" "dappco.re/go/core/crypt/crypt"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
@ -39,7 +37,7 @@ func runHash(input string) error {
if err != nil { if err != nil {
return cli.Wrap(err, "failed to hash password") return cli.Wrap(err, "failed to hash password")
} }
fmt.Println(hash) cli.Text(hash)
return nil return nil
} }
@ -47,7 +45,7 @@ func runHash(input string) error {
if err != nil { if err != nil {
return cli.Wrap(err, "failed to hash password") return cli.Wrap(err, "failed to hash password")
} }
fmt.Println(hash) cli.Text(hash)
return nil return nil
} }

View file

@ -4,7 +4,6 @@ import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
@ -43,12 +42,12 @@ func runKeygen() error {
switch { switch {
case keygenHex: case keygenHex:
fmt.Println(hex.EncodeToString(key)) cli.Text(hex.EncodeToString(key))
case keygenBase64: case keygenBase64:
fmt.Println(base64.StdEncoding.EncodeToString(key)) cli.Text(base64.StdEncoding.EncodeToString(key))
default: default:
// Default to hex output // Default to hex output
fmt.Println(hex.EncodeToString(key)) cli.Text(hex.EncodeToString(key))
} }
return nil return nil

View file

@ -2,15 +2,17 @@ package chachapoly
import ( import (
"crypto/rand" "crypto/rand"
"fmt"
"io" "io"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/chacha20poly1305" "golang.org/x/crypto/chacha20poly1305"
) )
// Encrypt encrypts data using ChaCha20-Poly1305. // Encrypt encrypts data using ChaCha20-Poly1305.
//
// ciphertext, err := chachapoly.Encrypt(plaintext, key32)
func Encrypt(plaintext []byte, key []byte) ([]byte, error) { func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
aead, err := chacha20poly1305.NewX(key) aead, err := chacha20poly1305.NewX(key)
if err != nil { if err != nil {
@ -26,6 +28,8 @@ func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
} }
// Decrypt decrypts data using ChaCha20-Poly1305. // Decrypt decrypts data using ChaCha20-Poly1305.
//
// plaintext, err := chachapoly.Decrypt(ciphertext, key32)
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
const op = "chachapoly.Decrypt" const op = "chachapoly.Decrypt"
@ -36,7 +40,7 @@ func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
minLen := aead.NonceSize() + aead.Overhead() minLen := aead.NonceSize() + aead.Overhead()
if len(ciphertext) < minLen { if len(ciphertext) < minLen {
return nil, coreerr.E(op, fmt.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil) return nil, coreerr.E(op, core.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():]

View file

@ -9,14 +9,14 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
// mockReader is a reader that returns an error. // mockReader is a reader that always returns an error.
type mockReader struct{} type mockReader struct{}
func (r *mockReader) Read(p []byte) (n int, err error) { func (r *mockReader) Read(p []byte) (n int, err error) {
return 0, coreerr.E("chachapoly.mockReader.Read", "read error", nil) return 0, coreerr.E("chachapoly.mockReader.Read", "read error", nil)
} }
func TestEncryptDecrypt(t *testing.T) { func TestEncryptDecrypt_Good(t *testing.T) {
key := make([]byte, 32) key := make([]byte, 32)
for i := range key { for i := range key {
key[i] = 1 key[i] = 1
@ -32,14 +32,27 @@ func TestEncryptDecrypt(t *testing.T) {
assert.Equal(t, plaintext, decrypted) assert.Equal(t, plaintext, decrypted)
} }
func TestEncryptInvalidKeySize(t *testing.T) { func TestEncryptDecrypt_Good_EmptyPlaintext(t *testing.T) {
key := make([]byte, 16) // Wrong size key := make([]byte, 32)
plaintext := []byte("test") plaintext := []byte("")
_, err := Encrypt(plaintext, key) ciphertext, err := Encrypt(plaintext, key)
assert.Error(t, err) assert.NoError(t, err)
decrypted, err := Decrypt(ciphertext, key)
assert.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
} }
func TestDecryptWithWrongKey(t *testing.T) { func TestEncryptDecrypt_Good_CiphertextDiffersFromPlaintext(t *testing.T) {
key := make([]byte, 32)
plaintext := []byte("Hello, world!")
ciphertext, err := Encrypt(plaintext, key)
assert.NoError(t, err)
assert.NotEqual(t, plaintext, ciphertext)
}
func TestEncryptDecrypt_Bad_WrongKey(t *testing.T) {
key1 := make([]byte, 32) key1 := make([]byte, 32)
key2 := make([]byte, 32) key2 := make([]byte, 32)
key2[0] = 1 // Different key key2[0] = 1 // Different key
@ -52,7 +65,7 @@ func TestDecryptWithWrongKey(t *testing.T) {
assert.Error(t, err) // Should fail authentication assert.Error(t, err) // Should fail authentication
} }
func TestDecryptTamperedCiphertext(t *testing.T) { func TestEncryptDecrypt_Bad_TamperedCiphertext(t *testing.T) {
key := make([]byte, 32) key := make([]byte, 32)
plaintext := []byte("secret") plaintext := []byte("secret")
ciphertext, err := Encrypt(plaintext, key) ciphertext, err := Encrypt(plaintext, key)
@ -65,36 +78,17 @@ func TestDecryptTamperedCiphertext(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
func TestEncryptEmptyPlaintext(t *testing.T) { func TestEncryptDecrypt_Bad_InvalidKeySize(t *testing.T) {
key := make([]byte, 32) key := make([]byte, 16) // Wrong size
plaintext := []byte("") plaintext := []byte("test")
ciphertext, err := Encrypt(plaintext, key) _, err := Encrypt(plaintext, key)
assert.NoError(t, err) assert.Error(t, err)
decrypted, err := Decrypt(ciphertext, key) _, err = Decrypt([]byte("test"), key)
assert.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
}
func TestDecryptShortCiphertext(t *testing.T) {
key := make([]byte, 32)
shortCiphertext := []byte("short")
_, err := Decrypt(shortCiphertext, key)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "too short")
} }
func TestCiphertextDiffersFromPlaintext(t *testing.T) { func TestEncryptDecrypt_Ugly_NonceError(t *testing.T) {
key := make([]byte, 32)
plaintext := []byte("Hello, world!")
ciphertext, err := Encrypt(plaintext, key)
assert.NoError(t, err)
assert.NotEqual(t, plaintext, ciphertext)
}
func TestEncryptNonceError(t *testing.T) {
key := make([]byte, 32) key := make([]byte, 32)
plaintext := []byte("test") plaintext := []byte("test")
@ -107,9 +101,11 @@ func TestEncryptNonceError(t *testing.T) {
assert.Error(t, err) assert.Error(t, err)
} }
func TestDecryptInvalidKeySize(t *testing.T) { func TestDecrypt_Ugly_ShortCiphertext(t *testing.T) {
key := make([]byte, 16) // Wrong size key := make([]byte, 32)
ciphertext := []byte("test") shortCiphertext := []byte("short")
_, err := Decrypt(ciphertext, key)
_, err := Decrypt(shortCiphertext, key)
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "too short")
} }

View file

@ -4,22 +4,24 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/sha512" "crypto/sha512"
"encoding/hex" "encoding/hex"
"io" goio "io"
"os"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/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.
//
// sum, err := crypt.SHA256File("/path/to/archive.tar.gz")
func SHA256File(path string) (string, error) { func SHA256File(path string) (string, error) {
f, err := os.Open(path) reader, err := coreio.ReadStream(coreio.Local, path)
if err != nil { if err != nil {
return "", coreerr.E("crypt.SHA256File", "failed to open file", err) return "", coreerr.E("crypt.SHA256File", "failed to open file", err)
} }
defer func() { _ = f.Close() }() defer func() { _ = reader.Close() }()
h := sha256.New() h := sha256.New()
if _, err := io.Copy(h, f); err != nil { if _, err := goio.Copy(h, reader); err != nil {
return "", coreerr.E("crypt.SHA256File", "failed to read file", err) return "", coreerr.E("crypt.SHA256File", "failed to read file", err)
} }
@ -27,15 +29,17 @@ func SHA256File(path string) (string, error) {
} }
// SHA512File computes the SHA-512 checksum of a file and returns it as a hex string. // SHA512File computes the SHA-512 checksum of a file and returns it as a hex string.
//
// sum, err := crypt.SHA512File("/path/to/archive.tar.gz")
func SHA512File(path string) (string, error) { func SHA512File(path string) (string, error) {
f, err := os.Open(path) reader, err := coreio.ReadStream(coreio.Local, path)
if err != nil { if err != nil {
return "", coreerr.E("crypt.SHA512File", "failed to open file", err) return "", coreerr.E("crypt.SHA512File", "failed to open file", err)
} }
defer func() { _ = f.Close() }() defer func() { _ = reader.Close() }()
h := sha512.New() h := sha512.New()
if _, err := io.Copy(h, f); err != nil { if _, err := goio.Copy(h, reader); err != nil {
return "", coreerr.E("crypt.SHA512File", "failed to read file", err) return "", coreerr.E("crypt.SHA512File", "failed to read file", err)
} }
@ -43,12 +47,16 @@ func SHA512File(path string) (string, error) {
} }
// SHA256Sum computes the SHA-256 checksum of data and returns it as a hex string. // SHA256Sum computes the SHA-256 checksum of data and returns it as a hex string.
//
// digest := crypt.SHA256Sum([]byte("hello")) // "2cf24dba..."
func SHA256Sum(data []byte) string { func SHA256Sum(data []byte) string {
h := sha256.Sum256(data) h := sha256.Sum256(data)
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
// SHA512Sum computes the SHA-512 checksum of data and returns it as a hex string. // SHA512Sum computes the SHA-512 checksum of data and returns it as a hex string.
//
// digest := crypt.SHA512Sum([]byte("hello")) // "9b71d224..."
func SHA512Sum(data []byte) string { func SHA512Sum(data []byte) string {
h := sha512.Sum512(data) h := sha512.Sum512(data)
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])

View file

@ -5,8 +5,9 @@ import (
) )
// Encrypt encrypts data with a passphrase using ChaCha20-Poly1305. // Encrypt encrypts data with a passphrase using ChaCha20-Poly1305.
// A random salt is generated and prepended to the output. //
// Format: salt (16 bytes) + nonce (24 bytes) + ciphertext. // ciphertext, err := crypt.Encrypt([]byte("secret"), []byte("passphrase"))
// // Format: salt (16 bytes) + nonce (24 bytes) + ciphertext
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 {
@ -28,7 +29,8 @@ func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
} }
// Decrypt decrypts data encrypted with Encrypt. // Decrypt decrypts data encrypted with Encrypt.
// Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext. //
// plaintext, err := crypt.Decrypt(ciphertext, []byte("passphrase"))
func Decrypt(ciphertext, passphrase []byte) ([]byte, error) { func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
if len(ciphertext) < argon2SaltLen { if len(ciphertext) < argon2SaltLen {
return nil, coreerr.E("crypt.Decrypt", "ciphertext too short", nil) return nil, coreerr.E("crypt.Decrypt", "ciphertext too short", nil)

View file

@ -3,9 +3,8 @@ package crypt
import ( import (
"crypto/subtle" "crypto/subtle"
"encoding/base64" "encoding/base64"
"fmt"
"strings"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
@ -13,7 +12,9 @@ import (
) )
// HashPassword hashes a password using Argon2id with default parameters. // HashPassword hashes a password using Argon2id with default parameters.
// Returns a string in the format: $argon2id$v=19$m=65536,t=3,p=4$<base64salt>$<base64hash> //
// hash, err := crypt.HashPassword("hunter2")
// // hash starts with "$argon2id$v=19$m=65536,t=3,p=4$..."
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 {
@ -25,7 +26,7 @@ func HashPassword(password string) (string, error) {
b64Salt := base64.RawStdEncoding.EncodeToString(salt) b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash) b64Hash := base64.RawStdEncoding.EncodeToString(hash)
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", encoded := core.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argon2Memory, argon2Time, argon2Parallelism, argon2.Version, argon2Memory, argon2Time, argon2Parallelism,
b64Salt, b64Hash) b64Salt, b64Hash)
@ -33,26 +34,29 @@ func HashPassword(password string) (string, error) {
} }
// VerifyPassword verifies a password against an Argon2id hash string. // VerifyPassword verifies a password against an Argon2id hash string.
// The hash must be in the format produced by HashPassword. //
// ok, err := crypt.VerifyPassword("hunter2", storedHash)
// if !ok { return coreerr.E("auth.Login", "invalid password", nil) }
func VerifyPassword(password, hash string) (bool, error) { func VerifyPassword(password, hash string) (bool, error) {
parts := strings.Split(hash, "$") parts := core.Split(hash, "$")
if len(parts) != 6 { if len(parts) != 6 {
return false, coreerr.E("crypt.VerifyPassword", "invalid hash format", nil) return false, coreerr.E("crypt.VerifyPassword", "invalid hash format", nil)
} }
var version int // Parse version field: "v=19" -> 19
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { var version uint32
if err := parseUint32Field(parts[2], "v=", &version); err != nil {
return false, coreerr.E("crypt.VerifyPassword", "failed to parse version", err) return false, coreerr.E("crypt.VerifyPassword", "failed to parse version", err)
} }
var memory uint32 // Parse parameter field: "m=65536,t=3,p=4"
var time uint32 var memory, timeParam uint32
var parallelism uint8 var parallelism uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &parallelism); err != nil { if err := parseArgon2Params(parts[3], &memory, &timeParam, &parallelism); err != nil {
return false, coreerr.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]) saltBytes, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil { if err != nil {
return false, coreerr.E("crypt.VerifyPassword", "failed to decode salt", err) return false, coreerr.E("crypt.VerifyPassword", "failed to decode salt", err)
} }
@ -62,13 +66,72 @@ func VerifyPassword(password, hash string) (bool, error) {
return false, coreerr.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), saltBytes, timeParam, memory, parallelism, uint32(len(expectedHash)))
return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1, nil return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1, nil
} }
// parseUint32Field parses a "prefix=N" string into a uint32.
// For example: parseUint32Field("v=19", "v=", &out) sets out to 19.
func parseUint32Field(s, prefix string, out *uint32) error {
value := core.TrimPrefix(s, prefix)
if value == s {
return coreerr.E("crypt.parseUint32Field", "missing prefix "+prefix, nil)
}
n, err := parseDecimalUint32(value)
if err != nil {
return err
}
*out = n
return nil
}
// parseArgon2Params parses "m=N,t=N,p=N" into memory, time, and parallelism.
//
// var m, t uint32; var p uint8
// parseArgon2Params("m=65536,t=3,p=4", &m, &t, &p)
func parseArgon2Params(s string, memory, timeParam *uint32, parallelism *uint8) error {
const op = "crypt.parseArgon2Params"
parts := core.Split(s, ",")
if len(parts) != 3 {
return coreerr.E(op, "expected 3 comma-separated fields", nil)
}
var m, t, p uint32
if err := parseUint32Field(parts[0], "m=", &m); err != nil {
return coreerr.E(op, "failed to parse memory", err)
}
if err := parseUint32Field(parts[1], "t=", &t); err != nil {
return coreerr.E(op, "failed to parse time", err)
}
if err := parseUint32Field(parts[2], "p=", &p); err != nil {
return coreerr.E(op, "failed to parse parallelism", err)
}
*memory = m
*timeParam = t
*parallelism = uint8(p)
return nil
}
// parseDecimalUint32 parses a decimal string into a uint32.
func parseDecimalUint32(s string) (uint32, error) {
if s == "" {
return 0, coreerr.E("crypt.parseDecimalUint32", "empty string", nil)
}
var result uint32
for _, ch := range s {
if ch < '0' || ch > '9' {
return 0, coreerr.E("crypt.parseDecimalUint32", "non-digit character in "+s, nil)
}
result = result*10 + uint32(ch-'0')
}
return result, nil
}
// HashBcrypt hashes a password using bcrypt with the given cost. // HashBcrypt hashes a password using bcrypt with the given cost.
// Cost must be between bcrypt.MinCost and bcrypt.MaxCost. //
// hash, err := crypt.HashBcrypt("hunter2", bcrypt.DefaultCost)
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 {
@ -78,6 +141,8 @@ func HashBcrypt(password string, cost int) (string, error) {
} }
// VerifyBcrypt verifies a password against a bcrypt hash. // VerifyBcrypt verifies a password against a bcrypt hash.
//
// ok, err := crypt.VerifyBcrypt("hunter2", storedHash)
func VerifyBcrypt(password, hash string) (bool, error) { func VerifyBcrypt(password, hash string) (bool, error) {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
if err == bcrypt.ErrMismatchedHashAndPassword { if err == bcrypt.ErrMismatchedHashAndPassword {

View file

@ -42,9 +42,35 @@ func TestHashBcrypt_Good(t *testing.T) {
match, err := VerifyBcrypt(password, hash) match, err := VerifyBcrypt(password, hash)
assert.NoError(t, err) assert.NoError(t, err)
assert.True(t, match) assert.True(t, match)
}
func TestHashBcrypt_Bad_WrongPassword(t *testing.T) {
password := "bcrypt-test-password"
hash, err := HashBcrypt(password, bcrypt.DefaultCost)
assert.NoError(t, err)
// Wrong password should not match // Wrong password should not match
match, err = VerifyBcrypt("wrong-password", hash) match, err := VerifyBcrypt("wrong-password", hash)
assert.NoError(t, err) assert.NoError(t, err)
assert.False(t, match) assert.False(t, match)
} }
func TestHashBcrypt_Ugly_InvalidCost(t *testing.T) {
// bcrypt cost above maximum is rejected by the library.
_, err := HashBcrypt("password", bcrypt.MaxCost+1)
assert.Error(t, err, "invalid bcrypt cost above maximum should return error")
}
func TestVerifyPassword_Ugly_InvalidHashFormat(t *testing.T) {
// Hash string with wrong number of dollar-delimited parts.
_, err := VerifyPassword("anypassword", "not-a-valid-hash")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid hash format")
}
func TestVerifyPassword_Ugly_CorruptBase64Salt(t *testing.T) {
// Valid structure but corrupt base64 in the salt field.
_, err := VerifyPassword("pass", "$argon2id$v=19$m=65536,t=3,p=4$!!!invalid!!!$aGVsbG8=")
assert.Error(t, err, "corrupt salt base64 should return error")
}

View file

@ -8,6 +8,8 @@ import (
) )
// HMACSHA256 computes the HMAC-SHA256 of a message using the given key. // HMACSHA256 computes the HMAC-SHA256 of a message using the given key.
//
// mac := crypt.HMACSHA256([]byte("message"), []byte("secret"))
func HMACSHA256(message, key []byte) []byte { func HMACSHA256(message, key []byte) []byte {
mac := hmac.New(sha256.New, key) mac := hmac.New(sha256.New, key)
mac.Write(message) mac.Write(message)
@ -15,6 +17,8 @@ func HMACSHA256(message, key []byte) []byte {
} }
// HMACSHA512 computes the HMAC-SHA512 of a message using the given key. // HMACSHA512 computes the HMAC-SHA512 of a message using the given key.
//
// mac := crypt.HMACSHA512([]byte("message"), []byte("secret"))
func HMACSHA512(message, key []byte) []byte { func HMACSHA512(message, key []byte) []byte {
mac := hmac.New(sha512.New, key) mac := hmac.New(sha512.New, key)
mac.Write(message) mac.Write(message)
@ -22,7 +26,8 @@ func HMACSHA512(message, key []byte) []byte {
} }
// VerifyHMAC verifies an HMAC using constant-time comparison. // VerifyHMAC verifies an HMAC using constant-time comparison.
// hashFunc should be sha256.New, sha512.New, etc. //
// ok := crypt.VerifyHMAC(message, key, receivedMAC, sha256.New)
func VerifyHMAC(message, key, mac []byte, hashFunc func() hash.Hash) bool { func VerifyHMAC(message, key, mac []byte, hashFunc func() hash.Hash) bool {
expected := hmac.New(hashFunc, key) expected := hmac.New(hashFunc, key)
expected.Write(message) expected.Write(message)

View file

@ -38,3 +38,25 @@ func TestVerifyHMAC_Bad(t *testing.T) {
valid := VerifyHMAC(tampered, key, mac, sha256.New) valid := VerifyHMAC(tampered, key, mac, sha256.New)
assert.False(t, valid) assert.False(t, valid)
} }
func TestHMACSHA512_Good(t *testing.T) {
key := []byte("secret-key")
message := []byte("test message sha512")
mac512 := HMACSHA512(message, key)
assert.NotEmpty(t, mac512)
assert.Len(t, mac512, 64) // SHA-512 produces 64 bytes
// Must differ from SHA-256 result
mac256 := HMACSHA256(message, key)
assert.NotEqual(t, mac512, mac256)
}
func TestVerifyHMAC_Ugly_EmptyKeyAndMessage(t *testing.T) {
// Both key and message are empty — HMAC is still deterministic.
mac := HMACSHA256([]byte{}, []byte{})
assert.NotEmpty(t, mac, "HMAC of empty inputs must still produce a digest")
valid := VerifyHMAC([]byte{}, []byte{}, mac, sha256.New)
assert.True(t, valid, "HMAC verification must succeed for matching empty inputs")
}

View file

@ -24,13 +24,15 @@ const (
) )
// DeriveKey derives a key from a passphrase using Argon2id with default parameters. // DeriveKey derives a key from a passphrase using Argon2id with default parameters.
// The salt must be argon2SaltLen bytes. keyLen specifies the desired key length. //
// key := crypt.DeriveKey([]byte("passphrase"), salt16, 32)
func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte { func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte {
return argon2.IDKey(passphrase, salt, argon2Time, argon2Memory, argon2Parallelism, keyLen) return argon2.IDKey(passphrase, salt, argon2Time, argon2Memory, argon2Parallelism, keyLen)
} }
// DeriveKeyScrypt derives a key from a passphrase using scrypt. // DeriveKeyScrypt derives a key from a passphrase using scrypt.
// Uses recommended parameters: N=32768, r=8, p=1. //
// key, err := crypt.DeriveKeyScrypt([]byte("passphrase"), salt16, 32)
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 {

View file

@ -4,7 +4,6 @@ import (
"bytes" "bytes"
"crypto" "crypto"
goio "io" goio "io"
"strings"
framework "dappco.re/go/core" framework "dappco.re/go/core"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
@ -102,7 +101,7 @@ func serializeEntity(w goio.Writer, e *openpgp.Entity) error {
// EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath). // EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath).
// The encrypted data is written to the provided writer and also returned as an armored string. // The encrypted data is written to the provided writer and also returned as an armored string.
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(bytes.NewReader([]byte(recipientPath)))
if err != nil { if err != nil {
return "", coreerr.E("openpgp.EncryptPGP", "failed to read recipient key", err) return "", coreerr.E("openpgp.EncryptPGP", "failed to read recipient key", err)
} }
@ -137,7 +136,7 @@ func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opt
// DecryptPGP decrypts a PGP message using the provided armored private key and passphrase. // DecryptPGP decrypts a PGP message using the provided armored private key and passphrase.
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(bytes.NewReader([]byte(privateKey)))
if err != nil { if err != nil {
return "", coreerr.E("openpgp.DecryptPGP", "failed to read private key", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to read private key", err)
} }
@ -156,7 +155,7 @@ func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any
} }
// Decrypt armored message // Decrypt armored message
block, err := armor.Decode(strings.NewReader(message)) block, err := armor.Decode(bytes.NewReader([]byte(message)))
if err != nil { if err != nil {
return "", coreerr.E("openpgp.DecryptPGP", "failed to decode armored message", err) return "", coreerr.E("openpgp.DecryptPGP", "failed to decode armored message", err)
} }

View file

@ -6,25 +6,30 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"fmt"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
) )
// Service provides RSA functionality. // Service provides RSA functionality.
type Service struct{} type Service struct{}
// NewService creates and returns a new Service instance for performing RSA-related operations. // NewService creates a new Service instance for RSA operations.
//
// svc := rsa.NewService()
// pub, priv, err := svc.GenerateKeyPair(4096)
func NewService() *Service { func NewService() *Service {
return &Service{} return &Service{}
} }
// GenerateKeyPair creates a new RSA key pair. // GenerateKeyPair creates a new RSA key pair.
//
// pub, priv, err := svc.GenerateKeyPair(4096)
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" const op = "rsa.GenerateKeyPair"
if bits < 2048 { if bits < 2048 {
return nil, nil, coreerr.E(op, fmt.Sprintf("key size too small: %d (minimum 2048)", bits), nil) return nil, nil, coreerr.E(op, core.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 {
@ -49,7 +54,9 @@ func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err e
return pubKeyPEM, privKeyPEM, nil return pubKeyPEM, privKeyPEM, nil
} }
// Encrypt encrypts data with a public key. // Encrypt encrypts data with a public key using RSA-OAEP.
//
// ciphertext, err := svc.Encrypt(pubPEM, []byte("secret"), nil)
func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) { func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
const op = "rsa.Encrypt" const op = "rsa.Encrypt"
@ -76,7 +83,9 @@ func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
return ciphertext, nil return ciphertext, nil
} }
// Decrypt decrypts data with a private key. // Decrypt decrypts data with a private key using RSA-OAEP.
//
// plaintext, err := svc.Decrypt(privPEM, ciphertext, nil)
func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error) { func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error) {
const op = "rsa.Decrypt" const op = "rsa.Decrypt"

2
go.mod
View file

@ -3,7 +3,7 @@ module dappco.re/go/core/crypt
go 1.26.0 go 1.26.0
require ( require (
dappco.re/go/core v0.5.0 dappco.re/go/core v0.7.0
dappco.re/go/core/i18n v0.2.0 dappco.re/go/core/i18n v0.2.0
dappco.re/go/core/io v0.2.0 dappco.re/go/core/io v0.2.0
dappco.re/go/core/log v0.1.0 dappco.re/go/core/log v0.1.0

2
go.sum
View file

@ -1,5 +1,7 @@
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U= dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core v0.7.0 h1:A3vi7LD0jBBA7n+8WPZmjxbRDZ43FFoKhBJ/ydKDPSs=
dappco.re/go/core v0.7.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI= dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok= dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=

View file

@ -1,11 +1,11 @@
package trust package trust
import ( import (
"fmt"
"iter" "iter"
"sync" "sync"
"time" "time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
) )
@ -22,6 +22,8 @@ const (
) )
// String returns the human-readable name of the approval status. // String returns the human-readable name of the approval status.
//
// ApprovalPending.String() // "pending"
func (s ApprovalStatus) String() string { func (s ApprovalStatus) String() string {
switch s { switch s {
case ApprovalPending: case ApprovalPending:
@ -31,7 +33,7 @@ func (s ApprovalStatus) String() string {
case ApprovalDenied: case ApprovalDenied:
return "denied" return "denied"
default: default:
return fmt.Sprintf("unknown(%d)", int(s)) return core.Sprintf("unknown(%d)", int(s))
} }
} }
@ -85,7 +87,7 @@ func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (strin
defer q.mu.Unlock() defer q.mu.Unlock()
q.nextID++ q.nextID++
id := fmt.Sprintf("approval-%d", q.nextID) id := core.Sprintf("approval-%d", q.nextID)
q.requests[id] = &ApprovalRequest{ q.requests[id] = &ApprovalRequest{
ID: id, ID: id,
@ -107,10 +109,10 @@ func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) err
req, ok := q.requests[id] req, ok := q.requests[id]
if !ok { if !ok {
return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q not found", id), nil) return coreerr.E("trust.ApprovalQueue.Approve", core.Sprintf("request %q not found", id), nil)
} }
if req.Status != ApprovalPending { if req.Status != ApprovalPending {
return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q is already %s", id, req.Status), nil) return coreerr.E("trust.ApprovalQueue.Approve", core.Sprintf("request %q is already %s", id, req.Status), nil)
} }
req.Status = ApprovalApproved req.Status = ApprovalApproved
@ -128,10 +130,10 @@ func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error
req, ok := q.requests[id] req, ok := q.requests[id]
if !ok { if !ok {
return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q not found", id), nil) return coreerr.E("trust.ApprovalQueue.Deny", core.Sprintf("request %q not found", id), nil)
} }
if req.Status != ApprovalPending { if req.Status != ApprovalPending {
return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q is already %s", id, req.Status), nil) return coreerr.E("trust.ApprovalQueue.Deny", core.Sprintf("request %q is already %s", id, req.Status), nil)
} }
req.Status = ApprovalDenied req.Status = ApprovalDenied

View file

@ -2,7 +2,7 @@ package trust
import ( import (
"encoding/json" "encoding/json"
"io" goio "io"
"iter" "iter"
"sync" "sync"
"time" "time"
@ -54,12 +54,15 @@ func (d *Decision) UnmarshalJSON(data []byte) error {
type AuditLog struct { type AuditLog struct {
mu sync.Mutex mu sync.Mutex
entries []AuditEntry entries []AuditEntry
writer io.Writer writer goio.Writer
} }
// NewAuditLog creates an in-memory audit log. If a writer is provided, // NewAuditLog creates an in-memory audit log. If a writer is provided,
// each entry is also written as a JSON line to that writer (append-only). // each entry is also written as a JSON line to that writer (append-only).
func NewAuditLog(w io.Writer) *AuditLog { //
// auditLog := trust.NewAuditLog(os.Stdout)
// err := auditLog.Record(result, "host-uk/core")
func NewAuditLog(w goio.Writer) *AuditLog {
return &AuditLog{ return &AuditLog{
writer: w, writer: w,
} }

View file

@ -2,10 +2,10 @@ package trust
import ( import (
"encoding/json" "encoding/json"
"fmt" goio "io"
"io"
"os"
core "dappco.re/go/core"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
) )
@ -23,17 +23,21 @@ type PoliciesConfig struct {
} }
// LoadPoliciesFromFile reads a JSON file and returns parsed policies. // LoadPoliciesFromFile reads a JSON file and returns parsed policies.
//
// policies, err := trust.LoadPoliciesFromFile("/etc/agent/policies.json")
func LoadPoliciesFromFile(path string) ([]Policy, error) { func LoadPoliciesFromFile(path string) ([]Policy, error) {
f, err := os.Open(path) reader, err := coreio.ReadStream(coreio.Local, path)
if err != nil { if err != nil {
return nil, coreerr.E("trust.LoadPoliciesFromFile", "failed to open file", err) return nil, coreerr.E("trust.LoadPoliciesFromFile", "failed to open file", err)
} }
defer f.Close() defer func() { _ = reader.Close() }()
return LoadPolicies(f) return LoadPolicies(reader)
} }
// 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) { //
// policies, err := trust.LoadPolicies(strings.NewReader(jsonInput))
func LoadPolicies(r goio.Reader) ([]Policy, error) {
const op = "trust.LoadPolicies" const op = "trust.LoadPolicies"
var cfg PoliciesConfig var cfg PoliciesConfig
@ -45,7 +49,7 @@ func LoadPolicies(r io.Reader) ([]Policy, error) {
// Reject trailing data after the decoded value // Reject trailing data after the decoded value
var extra json.RawMessage var extra json.RawMessage
if err := dec.Decode(&extra); err != io.EOF { if err := dec.Decode(&extra); err != goio.EOF {
return nil, coreerr.E(op, "unexpected trailing data in JSON", nil) return nil, coreerr.E(op, "unexpected trailing data in JSON", nil)
} }
@ -59,7 +63,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
for i, pc := range cfg.Policies { for i, pc := range cfg.Policies {
tier := Tier(pc.Tier) tier := Tier(pc.Tier)
if !tier.Valid() { if !tier.Valid() {
return nil, coreerr.E("trust.LoadPolicies", fmt.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil) return nil, coreerr.E("trust.LoadPolicies", core.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil)
} }
p := Policy{ p := Policy{
@ -76,7 +80,9 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
// ApplyPolicies loads policies from a reader and sets them on the engine, // ApplyPolicies loads policies from a reader and sets them on the engine,
// replacing any existing policies for the same tiers. // replacing any existing policies for the same tiers.
func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error { //
// err := engine.ApplyPolicies(strings.NewReader(policyJSON))
func (pe *PolicyEngine) ApplyPolicies(r goio.Reader) error {
policies, err := LoadPolicies(r) policies, err := LoadPolicies(r)
if err != nil { if err != nil {
return err return err
@ -90,17 +96,22 @@ func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
} }
// ApplyPoliciesFromFile loads policies from a JSON file and sets them on the engine. // ApplyPoliciesFromFile loads policies from a JSON file and sets them on the engine.
//
// err := engine.ApplyPoliciesFromFile("/etc/agent/policies.json")
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error { func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error {
f, err := os.Open(path) reader, err := coreio.ReadStream(coreio.Local, path)
if err != nil { if err != nil {
return coreerr.E("trust.ApplyPoliciesFromFile", "failed to open file", err) return coreerr.E("trust.ApplyPoliciesFromFile", "failed to open file", err)
} }
defer f.Close() defer func() { _ = reader.Close() }()
return pe.ApplyPolicies(f) return pe.ApplyPolicies(reader)
} }
// ExportPolicies serialises the current policies as JSON to the given writer. // ExportPolicies serialises the current policies as JSON to the given writer.
func (pe *PolicyEngine) ExportPolicies(w io.Writer) error { //
// var buf bytes.Buffer
// err := engine.ExportPolicies(&buf)
func (pe *PolicyEngine) ExportPolicies(w goio.Writer) error {
var cfg PoliciesConfig var cfg PoliciesConfig
for _, tier := range []Tier{TierUntrusted, TierVerified, TierFull} { for _, tier := range []Tier{TierUntrusted, TierVerified, TierFull} {
p := pe.GetPolicy(tier) p := pe.GetPolicy(tier)

View file

@ -1,10 +1,9 @@
package trust package trust
import ( import (
"fmt"
"slices" "slices"
"strings"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
) )
@ -39,6 +38,8 @@ const (
) )
// String returns the human-readable name of the decision. // String returns the human-readable name of the decision.
//
// Allow.String() // "allow"
func (d Decision) String() string { func (d Decision) String() string {
switch d { switch d {
case Deny: case Deny:
@ -48,7 +49,7 @@ func (d Decision) String() string {
case NeedsApproval: case NeedsApproval:
return "needs_approval" return "needs_approval"
default: default:
return fmt.Sprintf("unknown(%d)", int(d)) return core.Sprintf("unknown(%d)", int(d))
} }
} }
@ -61,6 +62,9 @@ type EvalResult struct {
} }
// NewPolicyEngine creates a policy engine with the given registry and default policies. // NewPolicyEngine creates a policy engine with the given registry and default policies.
//
// engine := trust.NewPolicyEngine(registry)
// result := engine.Evaluate("Clotho", trust.CapPushRepo, "host-uk/core")
func NewPolicyEngine(registry *Registry) *PolicyEngine { func NewPolicyEngine(registry *Registry) *PolicyEngine {
pe := &PolicyEngine{ pe := &PolicyEngine{
registry: registry, registry: registry,
@ -90,7 +94,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Deny, Decision: Deny,
Agent: agentName, Agent: agentName,
Cap: cap, Cap: cap,
Reason: fmt.Sprintf("no policy for tier %s", agent.Tier), Reason: core.Sprintf("no policy for tier %s", agent.Tier),
} }
} }
@ -100,7 +104,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Deny, Decision: Deny,
Agent: agentName, Agent: agentName,
Cap: cap, Cap: cap,
Reason: fmt.Sprintf("capability %s is denied for tier %s", cap, agent.Tier), Reason: core.Sprintf("capability %s is denied for tier %s", cap, agent.Tier),
} }
} }
@ -110,7 +114,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: NeedsApproval, Decision: NeedsApproval,
Agent: agentName, Agent: agentName,
Cap: cap, Cap: cap,
Reason: fmt.Sprintf("capability %s requires approval for tier %s", cap, agent.Tier), Reason: core.Sprintf("capability %s requires approval for tier %s", cap, agent.Tier),
} }
} }
@ -124,7 +128,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Deny, Decision: Deny,
Agent: agentName, Agent: agentName,
Cap: cap, Cap: cap,
Reason: fmt.Sprintf("agent %q does not have access to repo %q", agentName, repo), Reason: core.Sprintf("agent %q does not have access to repo %q", agentName, repo),
} }
} }
} }
@ -132,7 +136,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Allow, Decision: Allow,
Agent: agentName, Agent: agentName,
Cap: cap, Cap: cap,
Reason: fmt.Sprintf("capability %s allowed for tier %s", cap, agent.Tier), Reason: core.Sprintf("capability %s allowed for tier %s", cap, agent.Tier),
} }
} }
} }
@ -141,14 +145,16 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
Decision: Deny, Decision: Deny,
Agent: agentName, Agent: agentName,
Cap: cap, Cap: cap,
Reason: fmt.Sprintf("capability %s not granted for tier %s", cap, agent.Tier), Reason: core.Sprintf("capability %s not granted for tier %s", cap, agent.Tier),
} }
} }
// SetPolicy replaces the policy for a given tier. // SetPolicy replaces the policy for a given tier.
//
// err := engine.SetPolicy(trust.Policy{Tier: trust.TierFull, Allowed: []trust.Capability{trust.CapPushRepo}})
func (pe *PolicyEngine) SetPolicy(p Policy) error { func (pe *PolicyEngine) SetPolicy(p Policy) error {
if !p.Tier.Valid() { if !p.Tier.Valid() {
return coreerr.E("trust.SetPolicy", fmt.Sprintf("invalid tier %d", p.Tier), nil) return coreerr.E("trust.SetPolicy", core.Sprintf("invalid tier %d", p.Tier), nil)
} }
pe.policies[p.Tier] = &p pe.policies[p.Tier] = &p
return nil return nil
@ -217,9 +223,11 @@ func (pe *PolicyEngine) loadDefaults() {
} }
// isRepoScoped returns true if the capability is constrained by repo scope. // isRepoScoped returns true if the capability is constrained by repo scope.
// Only repo.* capabilities and secrets.read require explicit repo authorisation.
// PR and issue capabilities are not repo-scoped — enforcement happens at the
// forge layer.
func isRepoScoped(cap Capability) bool { func isRepoScoped(cap Capability) bool {
return strings.HasPrefix(string(cap), "repo.") || return core.HasPrefix(string(cap), "repo.") ||
strings.HasPrefix(string(cap), "pr.") ||
cap == CapReadSecrets cap == CapReadSecrets
} }
@ -253,14 +261,14 @@ func matchScope(pattern, repo string) bool {
} }
// Check for wildcard patterns. // Check for wildcard patterns.
if !strings.Contains(pattern, "*") { if !core.Contains(pattern, "*") {
return false return false
} }
// "prefix/**" — recursive: matches anything under prefix/. // "prefix/**" — recursive: matches anything under prefix/.
if strings.HasSuffix(pattern, "/**") { if core.HasSuffix(pattern, "/**") {
prefix := pattern[:len(pattern)-3] // strip "/**" prefix := pattern[:len(pattern)-3] // strip "/**"
if !strings.HasPrefix(repo, prefix+"/") { if !core.HasPrefix(repo, prefix+"/") {
return false return false
} }
// Must have something after the prefix/. // Must have something after the prefix/.
@ -268,14 +276,14 @@ func matchScope(pattern, repo string) bool {
} }
// "prefix/*" — single level: matches prefix/X but not prefix/X/Y. // "prefix/*" — single level: matches prefix/X but not prefix/X/Y.
if strings.HasSuffix(pattern, "/*") { if core.HasSuffix(pattern, "/*") {
prefix := pattern[:len(pattern)-2] // strip "/*" prefix := pattern[:len(pattern)-2] // strip "/*"
if !strings.HasPrefix(repo, prefix+"/") { if !core.HasPrefix(repo, prefix+"/") {
return false return false
} }
remainder := repo[len(prefix)+1:] remainder := repo[len(prefix)+1:]
// Must have a non-empty name, and no further slashes. // Must have a non-empty name, and no further slashes.
return remainder != "" && !strings.Contains(remainder, "/") return remainder != "" && !core.Contains(remainder, "/")
} }
// Unsupported wildcard position — fall back to no match. // Unsupported wildcard position — fall back to no match.

View file

@ -212,11 +212,16 @@ func TestGetPolicy_Bad_NotFound(t *testing.T) {
func TestIsRepoScoped_Good(t *testing.T) { func TestIsRepoScoped_Good(t *testing.T) {
assert.True(t, isRepoScoped(CapPushRepo)) assert.True(t, isRepoScoped(CapPushRepo))
assert.True(t, isRepoScoped(CapCreatePR))
assert.True(t, isRepoScoped(CapMergePR))
assert.True(t, isRepoScoped(CapReadSecrets)) assert.True(t, isRepoScoped(CapReadSecrets))
} }
func TestIsRepoScoped_Bad_PRCapsNotRepoScoped(t *testing.T) {
// PR capabilities are not repo-scoped in the policy engine —
// enforcement for PR targets happens at the forge layer.
assert.False(t, isRepoScoped(CapCreatePR))
assert.False(t, isRepoScoped(CapMergePR))
}
func TestIsRepoScoped_Bad_NotScoped(t *testing.T) { func TestIsRepoScoped_Bad_NotScoped(t *testing.T) {
assert.False(t, isRepoScoped(CapRunPrivileged)) assert.False(t, isRepoScoped(CapRunPrivileged))
assert.False(t, isRepoScoped(CapAccessWorkspace)) assert.False(t, isRepoScoped(CapAccessWorkspace))

View file

@ -11,11 +11,11 @@
package trust package trust
import ( import (
"fmt"
"iter" "iter"
"sync" "sync"
"time" "time"
core "dappco.re/go/core"
coreerr "dappco.re/go/core/log" coreerr "dappco.re/go/core/log"
) )
@ -32,6 +32,8 @@ const (
) )
// String returns the human-readable name of the tier. // String returns the human-readable name of the tier.
//
// TierFull.String() // "full"
func (t Tier) String() string { func (t Tier) String() string {
switch t { switch t {
case TierUntrusted: case TierUntrusted:
@ -41,7 +43,7 @@ func (t Tier) String() string {
case TierFull: case TierFull:
return "full" return "full"
default: default:
return fmt.Sprintf("unknown(%d)", int(t)) return core.Sprintf("unknown(%d)", int(t))
} }
} }
@ -98,13 +100,14 @@ func NewRegistry() *Registry {
} }
// Register adds or updates an agent in the registry. // Register adds or updates an agent in the registry.
// Returns an error if the agent name is empty or the tier is invalid. //
// err := registry.Register(trust.Agent{Name: "Athena", Tier: trust.TierFull})
func (r *Registry) Register(agent Agent) error { func (r *Registry) Register(agent Agent) error {
if agent.Name == "" { if agent.Name == "" {
return coreerr.E("trust.Register", "agent name is required", nil) return coreerr.E("trust.Register", "agent name is required", nil)
} }
if !agent.Tier.Valid() { if !agent.Tier.Valid() {
return coreerr.E("trust.Register", fmt.Sprintf("invalid tier %d for agent %q", agent.Tier, agent.Name), nil) return coreerr.E("trust.Register", core.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()
@ -120,6 +123,8 @@ func (r *Registry) Register(agent Agent) error {
} }
// Get returns the agent with the given name, or nil if not found. // Get returns the agent with the given name, or nil if not found.
//
// agent := registry.Get("Athena") // nil if not registered
func (r *Registry) Get(name string) *Agent { func (r *Registry) Get(name string) *Agent {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()

View file

@ -169,7 +169,7 @@ func TestRegistryListSeq_Good(t *testing.T) {
// --- Agent --- // --- Agent ---
func TestAgentTokenExpiry(t *testing.T) { func TestAgentTokenExpiry_Good(t *testing.T) {
agent := Agent{ agent := Agent{
Name: "Test", Name: "Test",
Tier: TierVerified, Tier: TierVerified,