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>
This commit is contained in:
parent
c9a7a6fb4b
commit
7407b89b8d
26 changed files with 345 additions and 176 deletions
15
auth/auth.go
15
auth/auth.go
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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():]
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[:])
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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, ¶llelism); err != nil {
|
if err := parseArgon2Params(parts[3], &memory, &timeParam, ¶llelism); 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 {
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
2
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue