Compare commits

..

No commits in common. "ax/review-fixes" and "dev" have entirely different histories.

26 changed files with 176 additions and 345 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,24 +4,22 @@ import (
"crypto/sha256"
"crypto/sha512"
"encoding/hex"
goio "io"
"io"
"os"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
)
// 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) {
reader, err := coreio.ReadStream(coreio.Local, path)
f, err := os.Open(path)
if err != nil {
return "", coreerr.E("crypt.SHA256File", "failed to open file", err)
}
defer func() { _ = reader.Close() }()
defer func() { _ = f.Close() }()
h := sha256.New()
if _, err := goio.Copy(h, reader); err != nil {
if _, err := io.Copy(h, f); err != nil {
return "", coreerr.E("crypt.SHA256File", "failed to read file", err)
}
@ -29,17 +27,15 @@ func SHA256File(path string) (string, error) {
}
// 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) {
reader, err := coreio.ReadStream(coreio.Local, path)
f, err := os.Open(path)
if err != nil {
return "", coreerr.E("crypt.SHA512File", "failed to open file", err)
}
defer func() { _ = reader.Close() }()
defer func() { _ = f.Close() }()
h := sha512.New()
if _, err := goio.Copy(h, reader); err != nil {
if _, err := io.Copy(h, f); err != nil {
return "", coreerr.E("crypt.SHA512File", "failed to read file", err)
}
@ -47,16 +43,12 @@ func SHA512File(path string) (string, error) {
}
// 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 {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// 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 {
h := sha512.Sum512(data)
return hex.EncodeToString(h[:])

View file

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

View file

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

View file

@ -42,35 +42,9 @@ func TestHashBcrypt_Good(t *testing.T) {
match, err := VerifyBcrypt(password, hash)
assert.NoError(t, err)
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
match, err := VerifyBcrypt("wrong-password", hash)
match, err = VerifyBcrypt("wrong-password", hash)
assert.NoError(t, err)
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,8 +8,6 @@ import (
)
// 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 {
mac := hmac.New(sha256.New, key)
mac.Write(message)
@ -17,8 +15,6 @@ func HMACSHA256(message, key []byte) []byte {
}
// 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 {
mac := hmac.New(sha512.New, key)
mac.Write(message)
@ -26,8 +22,7 @@ func HMACSHA512(message, key []byte) []byte {
}
// VerifyHMAC verifies an HMAC using constant-time comparison.
//
// ok := crypt.VerifyHMAC(message, key, receivedMAC, sha256.New)
// hashFunc should be sha256.New, sha512.New, etc.
func VerifyHMAC(message, key, mac []byte, hashFunc func() hash.Hash) bool {
expected := hmac.New(hashFunc, key)
expected.Write(message)

View file

@ -38,25 +38,3 @@ func TestVerifyHMAC_Bad(t *testing.T) {
valid := VerifyHMAC(tampered, key, mac, sha256.New)
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,15 +24,13 @@ const (
)
// DeriveKey derives a key from a passphrase using Argon2id with default parameters.
//
// key := crypt.DeriveKey([]byte("passphrase"), salt16, 32)
// The salt must be argon2SaltLen bytes. keyLen specifies the desired key length.
func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte {
return argon2.IDKey(passphrase, salt, argon2Time, argon2Memory, argon2Parallelism, keyLen)
}
// DeriveKeyScrypt derives a key from a passphrase using scrypt.
//
// key, err := crypt.DeriveKeyScrypt([]byte("passphrase"), salt16, 32)
// Uses recommended parameters: N=32768, r=8, p=1.
func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error) {
key, err := scrypt.Key(passphrase, salt, 32768, 8, 1, keyLen)
if err != nil {

View file

@ -4,6 +4,7 @@ import (
"bytes"
"crypto"
goio "io"
"strings"
framework "dappco.re/go/core"
coreerr "dappco.re/go/core/log"
@ -101,7 +102,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).
// 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) {
entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(recipientPath)))
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath))
if err != nil {
return "", coreerr.E("openpgp.EncryptPGP", "failed to read recipient key", err)
}
@ -136,7 +137,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.
func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) {
entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKey)))
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey))
if err != nil {
return "", coreerr.E("openpgp.DecryptPGP", "failed to read private key", err)
}
@ -155,7 +156,7 @@ func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any
}
// Decrypt armored message
block, err := armor.Decode(bytes.NewReader([]byte(message)))
block, err := armor.Decode(strings.NewReader(message))
if err != nil {
return "", coreerr.E("openpgp.DecryptPGP", "failed to decode armored message", err)
}

View file

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

2
go.mod
View file

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

2
go.sum
View file

@ -1,7 +1,5 @@
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.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/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=

View file

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

View file

@ -2,7 +2,7 @@ package trust
import (
"encoding/json"
goio "io"
"io"
"iter"
"sync"
"time"
@ -54,15 +54,12 @@ func (d *Decision) UnmarshalJSON(data []byte) error {
type AuditLog struct {
mu sync.Mutex
entries []AuditEntry
writer goio.Writer
writer io.Writer
}
// 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).
//
// auditLog := trust.NewAuditLog(os.Stdout)
// err := auditLog.Record(result, "host-uk/core")
func NewAuditLog(w goio.Writer) *AuditLog {
func NewAuditLog(w io.Writer) *AuditLog {
return &AuditLog{
writer: w,
}

View file

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

View file

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

View file

@ -212,16 +212,11 @@ func TestGetPolicy_Bad_NotFound(t *testing.T) {
func TestIsRepoScoped_Good(t *testing.T) {
assert.True(t, isRepoScoped(CapPushRepo))
assert.True(t, isRepoScoped(CapCreatePR))
assert.True(t, isRepoScoped(CapMergePR))
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) {
assert.False(t, isRepoScoped(CapRunPrivileged))
assert.False(t, isRepoScoped(CapAccessWorkspace))

View file

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

View file

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