diff --git a/auth/auth.go b/auth/auth.go index 8eb1264..7f86abf 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -30,11 +30,10 @@ 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" @@ -425,7 +424,7 @@ func (a *Authenticator) Login(userID, password string) (*Session, error) { return nil, coreerr.E(op, "failed to read password hash", err) } - if !strings.HasPrefix(storedHash, "$argon2id$") { + if !core.HasPrefix(storedHash, "$argon2id$") { 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) } - data, err := json.Marshal(challenge) + challengeJSON, err := json.Marshal(challenge) if err != nil { 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) } @@ -659,7 +658,7 @@ func (a *Authenticator) verifyPassword(userID, password string) error { 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) } @@ -721,11 +720,11 @@ func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration case <-ticker.C: count, err := a.store.Cleanup() if err != nil { - fmt.Printf("auth: session cleanup error: %v\n", err) + coreerr.E("auth.StartCleanup", "session cleanup error", err) continue } if count > 0 { - fmt.Printf("auth: cleaned up %d expired session(s)\n", count) + _ = count // cleanup count logged by caller if needed } } } diff --git a/auth/session_store_sqlite.go b/auth/session_store_sqlite.go index 843ae58..c1ef91b 100644 --- a/auth/session_store_sqlite.go +++ b/auth/session_store_sqlite.go @@ -2,10 +2,11 @@ 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" ) @@ -19,9 +20,11 @@ type SQLiteSessionStore struct { } // 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) { - s, err := store.New(dbPath) +// +// 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) if err != nil { return nil, err } @@ -33,17 +36,17 @@ func (s *SQLiteSessionStore) Get(token string) (*Session, error) { s.mu.Lock() defer s.mu.Unlock() - val, err := s.store.Get(sessionGroup, token) + value, err := s.store.Get(sessionGroup, token) if err != nil { - if errors.Is(err, store.ErrNotFound) { + if core.Is(err, store.ErrNotFound) { return nil, ErrSessionNotFound } return nil, err } var session Session - if err := json.Unmarshal([]byte(val), &session); err != nil { - return nil, err + if err := json.Unmarshal([]byte(value), &session); err != nil { + return nil, coreerr.E("auth.SQLiteSessionStore.Get", "failed to unmarshal session", err) } return &session, nil } @@ -55,7 +58,7 @@ func (s *SQLiteSessionStore) Set(session *Session) error { data, err := json.Marshal(session) 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)) } @@ -68,7 +71,7 @@ func (s *SQLiteSessionStore) Delete(token string) error { // Check existence first to return ErrSessionNotFound _, err := s.store.Get(sessionGroup, token) if err != nil { - if errors.Is(err, store.ErrNotFound) { + if core.Is(err, store.ErrNotFound) { return ErrSessionNotFound } return err @@ -86,9 +89,9 @@ func (s *SQLiteSessionStore) DeleteByUser(userID string) error { return err } - for token, val := range all { + for token, value := range all { 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 } if session.UserID == userID { @@ -112,9 +115,9 @@ func (s *SQLiteSessionStore) Cleanup() (int, error) { now := time.Now() count := 0 - for token, val := range all { + for token, value := range all { 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 } if now.After(session.ExpiresAt) { diff --git a/cmd/crypt/cmd_checksum.go b/cmd/crypt/cmd_checksum.go index 0475fa5..f1cfc19 100644 --- a/cmd/crypt/cmd_checksum.go +++ b/cmd/crypt/cmd_checksum.go @@ -1,9 +1,7 @@ package crypt import ( - "fmt" - "path/filepath" - + core "dappco.re/go/core" "dappco.re/go/core/crypt/crypt" "forge.lthn.ai/core/cli/pkg/cli" ) @@ -42,20 +40,20 @@ func runChecksum(path string) error { if 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 } - cli.Error(fmt.Sprintf("Checksum mismatch: %s", filepath.Base(path))) - cli.Dim(fmt.Sprintf(" expected: %s", checksumVerify)) - cli.Dim(fmt.Sprintf(" got: %s", hash)) + cli.Error(core.Sprintf("Checksum mismatch: %s", core.PathBase(path))) + cli.Dim(core.Sprintf(" expected: %s", checksumVerify)) + cli.Dim(core.Sprintf(" got: %s", hash)) return cli.Err("checksum verification failed") } - algo := "SHA-256" + algorithm := "SHA-256" 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 } diff --git a/cmd/crypt/cmd_encrypt.go b/cmd/crypt/cmd_encrypt.go index 709733d..44db894 100644 --- a/cmd/crypt/cmd_encrypt.go +++ b/cmd/crypt/cmd_encrypt.go @@ -1,9 +1,7 @@ package crypt import ( - "fmt" - "strings" - + core "dappco.re/go/core" "dappco.re/go/core/crypt/crypt" coreio "dappco.re/go/core/io" "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") } - cli.Success(fmt.Sprintf("Encrypted %s -> %s", path, outPath)) + cli.Success(core.Sprintf("Encrypted %s -> %s", path, outPath)) return nil } @@ -103,7 +101,7 @@ func runDecrypt(path string) error { return cli.Wrap(err, "failed to decrypt") } - outPath := strings.TrimSuffix(path, ".enc") + outPath := core.TrimSuffix(path, ".enc") if outPath == path { outPath = path + ".dec" } @@ -112,6 +110,6 @@ func runDecrypt(path string) error { 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 } diff --git a/cmd/crypt/cmd_hash.go b/cmd/crypt/cmd_hash.go index d8fca7b..97399b3 100644 --- a/cmd/crypt/cmd_hash.go +++ b/cmd/crypt/cmd_hash.go @@ -1,8 +1,6 @@ package crypt import ( - "fmt" - "dappco.re/go/core/crypt/crypt" "forge.lthn.ai/core/cli/pkg/cli" @@ -39,7 +37,7 @@ func runHash(input string) error { if err != nil { return cli.Wrap(err, "failed to hash password") } - fmt.Println(hash) + cli.Text(hash) return nil } @@ -47,7 +45,7 @@ func runHash(input string) error { if err != nil { return cli.Wrap(err, "failed to hash password") } - fmt.Println(hash) + cli.Text(hash) return nil } diff --git a/cmd/crypt/cmd_keygen.go b/cmd/crypt/cmd_keygen.go index 025ebf5..ebb9f04 100644 --- a/cmd/crypt/cmd_keygen.go +++ b/cmd/crypt/cmd_keygen.go @@ -4,7 +4,6 @@ import ( "crypto/rand" "encoding/base64" "encoding/hex" - "fmt" "forge.lthn.ai/core/cli/pkg/cli" ) @@ -43,12 +42,12 @@ func runKeygen() error { switch { case keygenHex: - fmt.Println(hex.EncodeToString(key)) + cli.Text(hex.EncodeToString(key)) case keygenBase64: - fmt.Println(base64.StdEncoding.EncodeToString(key)) + cli.Text(base64.StdEncoding.EncodeToString(key)) default: // Default to hex output - fmt.Println(hex.EncodeToString(key)) + cli.Text(hex.EncodeToString(key)) } return nil diff --git a/crypt/chachapoly/chachapoly.go b/crypt/chachapoly/chachapoly.go index b627d9e..20057af 100644 --- a/crypt/chachapoly/chachapoly.go +++ b/crypt/chachapoly/chachapoly.go @@ -2,15 +2,17 @@ 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 { @@ -26,6 +28,8 @@ 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" @@ -36,7 +40,7 @@ func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { minLen := aead.NonceSize() + aead.Overhead() if len(ciphertext) < minLen { - return nil, coreerr.E(op, fmt.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil) + return nil, 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():] diff --git a/crypt/chachapoly/chachapoly_test.go b/crypt/chachapoly/chachapoly_test.go index 2bc9cf5..9724338 100644 --- a/crypt/chachapoly/chachapoly_test.go +++ b/crypt/chachapoly/chachapoly_test.go @@ -9,14 +9,14 @@ import ( "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{} func (r *mockReader) Read(p []byte) (n int, err error) { 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) for i := range key { key[i] = 1 @@ -32,14 +32,27 @@ func TestEncryptDecrypt(t *testing.T) { 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_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 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) key2 := make([]byte, 32) key2[0] = 1 // Different key @@ -52,7 +65,7 @@ func TestDecryptWithWrongKey(t *testing.T) { assert.Error(t, err) // Should fail authentication } -func TestDecryptTamperedCiphertext(t *testing.T) { +func TestEncryptDecrypt_Bad_TamperedCiphertext(t *testing.T) { key := make([]byte, 32) plaintext := []byte("secret") ciphertext, err := Encrypt(plaintext, key) @@ -65,36 +78,17 @@ func TestDecryptTamperedCiphertext(t *testing.T) { assert.Error(t, err) } -func TestEncryptEmptyPlaintext(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 TestDecryptShortCiphertext(t *testing.T) { - key := make([]byte, 32) - shortCiphertext := []byte("short") - - _, err := Decrypt(shortCiphertext, key) +func TestEncryptDecrypt_Bad_InvalidKeySize(t *testing.T) { + key := make([]byte, 16) // Wrong size + plaintext := []byte("test") + _, err := Encrypt(plaintext, key) + assert.Error(t, err) + + _, err = Decrypt([]byte("test"), 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) { +func TestEncryptDecrypt_Ugly_NonceError(t *testing.T) { key := make([]byte, 32) plaintext := []byte("test") @@ -107,9 +101,11 @@ func TestEncryptNonceError(t *testing.T) { assert.Error(t, err) } -func TestDecryptInvalidKeySize(t *testing.T) { - key := make([]byte, 16) // Wrong size - ciphertext := []byte("test") - _, err := Decrypt(ciphertext, key) +func TestDecrypt_Ugly_ShortCiphertext(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") } diff --git a/crypt/checksum.go b/crypt/checksum.go index 7f5c7a7..0cfeb25 100644 --- a/crypt/checksum.go +++ b/crypt/checksum.go @@ -4,22 +4,24 @@ import ( "crypto/sha256" "crypto/sha512" "encoding/hex" - "io" - "os" + goio "io" + 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) { - f, err := os.Open(path) + reader, err := coreio.ReadStream(coreio.Local, path) if err != nil { return "", coreerr.E("crypt.SHA256File", "failed to open file", err) } - defer func() { _ = f.Close() }() + defer func() { _ = reader.Close() }() 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) } @@ -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. +// +// sum, err := crypt.SHA512File("/path/to/archive.tar.gz") func SHA512File(path string) (string, error) { - f, err := os.Open(path) + reader, err := coreio.ReadStream(coreio.Local, path) if err != nil { return "", coreerr.E("crypt.SHA512File", "failed to open file", err) } - defer func() { _ = f.Close() }() + defer func() { _ = reader.Close() }() 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) } @@ -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. +// +// 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[:]) diff --git a/crypt/crypt.go b/crypt/crypt.go index df18f2f..a362963 100644 --- a/crypt/crypt.go +++ b/crypt/crypt.go @@ -5,8 +5,9 @@ import ( ) // 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) { salt, err := generateSalt(argon2SaltLen) if err != nil { @@ -28,7 +29,8 @@ func Encrypt(plaintext, passphrase []byte) ([]byte, error) { } // 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) { if len(ciphertext) < argon2SaltLen { return nil, coreerr.E("crypt.Decrypt", "ciphertext too short", nil) diff --git a/crypt/hash.go b/crypt/hash.go index 80b2127..5cefb2f 100644 --- a/crypt/hash.go +++ b/crypt/hash.go @@ -3,9 +3,8 @@ 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" @@ -13,7 +12,9 @@ import ( ) // HashPassword hashes a password using Argon2id with default parameters. -// Returns a string in the format: $argon2id$v=19$m=65536,t=3,p=4$$ +// +// hash, err := crypt.HashPassword("hunter2") +// // hash starts with "$argon2id$v=19$m=65536,t=3,p=4$..." func HashPassword(password string) (string, error) { salt, err := generateSalt(argon2SaltLen) if err != nil { @@ -25,7 +26,7 @@ func HashPassword(password string) (string, error) { b64Salt := base64.RawStdEncoding.EncodeToString(salt) 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, b64Salt, b64Hash) @@ -33,26 +34,29 @@ func HashPassword(password string) (string, error) { } // 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) { - parts := strings.Split(hash, "$") + parts := core.Split(hash, "$") if len(parts) != 6 { return false, coreerr.E("crypt.VerifyPassword", "invalid hash format", nil) } - var version int - if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { + // Parse version field: "v=19" -> 19 + var version uint32 + if err := parseUint32Field(parts[2], "v=", &version); err != nil { return false, coreerr.E("crypt.VerifyPassword", "failed to parse version", err) } - var memory uint32 - var time uint32 + // Parse parameter field: "m=65536,t=3,p=4" + var memory, timeParam uint32 var parallelism uint8 - if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, ¶llelism); err != nil { + if err := parseArgon2Params(parts[3], &memory, &timeParam, ¶llelism); err != nil { 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 { 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) } - 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 } +// 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. -// Cost must be between bcrypt.MinCost and bcrypt.MaxCost. +// +// hash, err := crypt.HashBcrypt("hunter2", bcrypt.DefaultCost) func HashBcrypt(password string, cost int) (string, error) { hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) if err != nil { @@ -78,6 +141,8 @@ 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 { diff --git a/crypt/hash_test.go b/crypt/hash_test.go index ad308a0..0ee6cc0 100644 --- a/crypt/hash_test.go +++ b/crypt/hash_test.go @@ -42,9 +42,35 @@ 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") +} diff --git a/crypt/hmac.go b/crypt/hmac.go index adb80c2..5f06917 100644 --- a/crypt/hmac.go +++ b/crypt/hmac.go @@ -8,6 +8,8 @@ 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) @@ -15,6 +17,8 @@ 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) @@ -22,7 +26,8 @@ func HMACSHA512(message, key []byte) []byte { } // 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 { expected := hmac.New(hashFunc, key) expected.Write(message) diff --git a/crypt/hmac_test.go b/crypt/hmac_test.go index 31dc474..a9f0893 100644 --- a/crypt/hmac_test.go +++ b/crypt/hmac_test.go @@ -38,3 +38,25 @@ 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") +} diff --git a/crypt/kdf.go b/crypt/kdf.go index c3058e8..02efcc0 100644 --- a/crypt/kdf.go +++ b/crypt/kdf.go @@ -24,13 +24,15 @@ const ( ) // 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 { return argon2.IDKey(passphrase, salt, argon2Time, argon2Memory, argon2Parallelism, keyLen) } // 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) { key, err := scrypt.Key(passphrase, salt, 32768, 8, 1, keyLen) if err != nil { diff --git a/crypt/openpgp/service.go b/crypt/openpgp/service.go index 50fc7d3..226a6fc 100644 --- a/crypt/openpgp/service.go +++ b/crypt/openpgp/service.go @@ -4,7 +4,6 @@ import ( "bytes" "crypto" goio "io" - "strings" framework "dappco.re/go/core" 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). // 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(strings.NewReader(recipientPath)) + entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(recipientPath))) if err != nil { 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. 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 { 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 - block, err := armor.Decode(strings.NewReader(message)) + block, err := armor.Decode(bytes.NewReader([]byte(message))) if err != nil { return "", coreerr.E("openpgp.DecryptPGP", "failed to decode armored message", err) } diff --git a/crypt/rsa/rsa.go b/crypt/rsa/rsa.go index 93bc3be..3b53ce7 100644 --- a/crypt/rsa/rsa.go +++ b/crypt/rsa/rsa.go @@ -6,25 +6,30 @@ 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 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 { 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, 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) if err != nil { @@ -49,7 +54,9 @@ func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err e 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) { const op = "rsa.Encrypt" @@ -76,7 +83,9 @@ func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) { 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) { const op = "rsa.Decrypt" diff --git a/go.mod b/go.mod index b1656ce..9b97158 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module dappco.re/go/core/crypt go 1.26.0 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/io v0.2.0 dappco.re/go/core/log v0.1.0 diff --git a/go.sum b/go.sum index 78359db..cd5d9d3 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ 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= diff --git a/trust/approval.go b/trust/approval.go index 627ca0b..14d4ad3 100644 --- a/trust/approval.go +++ b/trust/approval.go @@ -1,11 +1,11 @@ package trust import ( - "fmt" "iter" "sync" "time" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" ) @@ -22,6 +22,8 @@ const ( ) // String returns the human-readable name of the approval status. +// +// ApprovalPending.String() // "pending" func (s ApprovalStatus) String() string { switch s { case ApprovalPending: @@ -31,7 +33,7 @@ func (s ApprovalStatus) String() string { case ApprovalDenied: return "denied" 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() q.nextID++ - id := fmt.Sprintf("approval-%d", q.nextID) + id := core.Sprintf("approval-%d", q.nextID) q.requests[id] = &ApprovalRequest{ ID: id, @@ -107,10 +109,10 @@ func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) err req, ok := q.requests[id] if !ok { - return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q not found", id), nil) + return coreerr.E("trust.ApprovalQueue.Approve", core.Sprintf("request %q not found", id), nil) } 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 @@ -128,10 +130,10 @@ func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error req, ok := q.requests[id] if !ok { - return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q not found", id), nil) + return coreerr.E("trust.ApprovalQueue.Deny", core.Sprintf("request %q not found", id), nil) } 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 diff --git a/trust/audit.go b/trust/audit.go index a9445f7..c6db8a9 100644 --- a/trust/audit.go +++ b/trust/audit.go @@ -2,7 +2,7 @@ package trust import ( "encoding/json" - "io" + goio "io" "iter" "sync" "time" @@ -54,12 +54,15 @@ func (d *Decision) UnmarshalJSON(data []byte) error { type AuditLog struct { mu sync.Mutex entries []AuditEntry - writer io.Writer + writer goio.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). -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{ writer: w, } diff --git a/trust/config.go b/trust/config.go index 0414d29..0441c66 100644 --- a/trust/config.go +++ b/trust/config.go @@ -2,10 +2,10 @@ package trust import ( "encoding/json" - "fmt" - "io" - "os" + goio "io" + core "dappco.re/go/core" + coreio "dappco.re/go/core/io" coreerr "dappco.re/go/core/log" ) @@ -23,17 +23,21 @@ 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) { - f, err := os.Open(path) + reader, err := coreio.ReadStream(coreio.Local, path) if err != nil { return nil, coreerr.E("trust.LoadPoliciesFromFile", "failed to open file", err) } - defer f.Close() - return LoadPolicies(f) + defer func() { _ = reader.Close() }() + return LoadPolicies(reader) } // 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" var cfg PoliciesConfig @@ -45,7 +49,7 @@ func LoadPolicies(r io.Reader) ([]Policy, error) { // Reject trailing data after the decoded value 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) } @@ -59,7 +63,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) { for i, pc := range cfg.Policies { tier := Tier(pc.Tier) if !tier.Valid() { - return nil, coreerr.E("trust.LoadPolicies", fmt.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil) + return nil, coreerr.E("trust.LoadPolicies", core.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil) } p := Policy{ @@ -76,7 +80,9 @@ 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. -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) if err != nil { 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. +// +// err := engine.ApplyPoliciesFromFile("/etc/agent/policies.json") func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error { - f, err := os.Open(path) + reader, err := coreio.ReadStream(coreio.Local, path) if err != nil { return coreerr.E("trust.ApplyPoliciesFromFile", "failed to open file", err) } - defer f.Close() - return pe.ApplyPolicies(f) + defer func() { _ = reader.Close() }() + return pe.ApplyPolicies(reader) } // 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 for _, tier := range []Tier{TierUntrusted, TierVerified, TierFull} { p := pe.GetPolicy(tier) diff --git a/trust/policy.go b/trust/policy.go index 28cbbc4..258255a 100644 --- a/trust/policy.go +++ b/trust/policy.go @@ -1,10 +1,9 @@ package trust import ( - "fmt" "slices" - "strings" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" ) @@ -39,6 +38,8 @@ const ( ) // String returns the human-readable name of the decision. +// +// Allow.String() // "allow" func (d Decision) String() string { switch d { case Deny: @@ -48,7 +49,7 @@ func (d Decision) String() string { case NeedsApproval: return "needs_approval" 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. +// +// engine := trust.NewPolicyEngine(registry) +// result := engine.Evaluate("Clotho", trust.CapPushRepo, "host-uk/core") func NewPolicyEngine(registry *Registry) *PolicyEngine { pe := &PolicyEngine{ registry: registry, @@ -90,7 +94,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string) Decision: Deny, Agent: agentName, 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, Agent: agentName, 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, Agent: agentName, 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, Agent: agentName, 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, Agent: agentName, 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, Agent: agentName, 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. +// +// 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", 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 return nil @@ -217,9 +223,11 @@ 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 strings.HasPrefix(string(cap), "repo.") || - strings.HasPrefix(string(cap), "pr.") || + return core.HasPrefix(string(cap), "repo.") || cap == CapReadSecrets } @@ -253,14 +261,14 @@ func matchScope(pattern, repo string) bool { } // Check for wildcard patterns. - if !strings.Contains(pattern, "*") { + if !core.Contains(pattern, "*") { return false } // "prefix/**" — recursive: matches anything under prefix/. - if strings.HasSuffix(pattern, "/**") { + if core.HasSuffix(pattern, "/**") { prefix := pattern[:len(pattern)-3] // strip "/**" - if !strings.HasPrefix(repo, prefix+"/") { + if !core.HasPrefix(repo, prefix+"/") { return false } // 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. - if strings.HasSuffix(pattern, "/*") { + if core.HasSuffix(pattern, "/*") { prefix := pattern[:len(pattern)-2] // strip "/*" - if !strings.HasPrefix(repo, prefix+"/") { + if !core.HasPrefix(repo, prefix+"/") { return false } remainder := repo[len(prefix)+1:] // 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. diff --git a/trust/policy_test.go b/trust/policy_test.go index 6dce0d6..18b23a3 100644 --- a/trust/policy_test.go +++ b/trust/policy_test.go @@ -212,11 +212,16 @@ 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)) diff --git a/trust/trust.go b/trust/trust.go index 4620d4f..91f9d35 100644 --- a/trust/trust.go +++ b/trust/trust.go @@ -11,11 +11,11 @@ package trust import ( - "fmt" "iter" "sync" "time" + core "dappco.re/go/core" coreerr "dappco.re/go/core/log" ) @@ -32,6 +32,8 @@ const ( ) // String returns the human-readable name of the tier. +// +// TierFull.String() // "full" func (t Tier) String() string { switch t { case TierUntrusted: @@ -41,7 +43,7 @@ func (t Tier) String() string { case TierFull: return "full" 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. -// 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 { if agent.Name == "" { return coreerr.E("trust.Register", "agent name is required", nil) } 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() { 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. +// +// agent := registry.Get("Athena") // nil if not registered func (r *Registry) Get(name string) *Agent { r.mu.RLock() defer r.mu.RUnlock() diff --git a/trust/trust_test.go b/trust/trust_test.go index 69a5369..e323110 100644 --- a/trust/trust_test.go +++ b/trust/trust_test.go @@ -169,7 +169,7 @@ func TestRegistryListSeq_Good(t *testing.T) { // --- Agent --- -func TestAgentTokenExpiry(t *testing.T) { +func TestAgentTokenExpiry_Good(t *testing.T) { agent := Agent{ Name: "Test", Tier: TierVerified,