Compare commits
No commits in common. "ax/review-fixes" and "dev" have entirely different histories.
ax/review-
...
dev
26 changed files with 176 additions and 345 deletions
15
auth/auth.go
15
auth/auth.go
|
|
@ -30,10 +30,11 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
"dappco.re/go/core/crypt/crypt"
|
||||
"dappco.re/go/core/crypt/crypt/lthn"
|
||||
"dappco.re/go/core/crypt/crypt/pgp"
|
||||
|
|
@ -424,7 +425,7 @@ func (a *Authenticator) Login(userID, password string) (*Session, error) {
|
|||
return nil, coreerr.E(op, "failed to read password hash", err)
|
||||
}
|
||||
|
||||
if !core.HasPrefix(storedHash, "$argon2id$") {
|
||||
if !strings.HasPrefix(storedHash, "$argon2id$") {
|
||||
return nil, coreerr.E(op, "corrupted password hash", nil)
|
||||
}
|
||||
|
||||
|
|
@ -614,12 +615,12 @@ func (a *Authenticator) WriteChallengeFile(userID, path string) error {
|
|||
return coreerr.E(op, "failed to create challenge", err)
|
||||
}
|
||||
|
||||
challengeJSON, err := json.Marshal(challenge)
|
||||
data, err := json.Marshal(challenge)
|
||||
if err != nil {
|
||||
return coreerr.E(op, "failed to marshal challenge", err)
|
||||
}
|
||||
|
||||
if err := a.medium.Write(path, string(challengeJSON)); err != nil {
|
||||
if err := a.medium.Write(path, string(data)); err != nil {
|
||||
return coreerr.E(op, "failed to write challenge file", err)
|
||||
}
|
||||
|
||||
|
|
@ -658,7 +659,7 @@ func (a *Authenticator) verifyPassword(userID, password string) error {
|
|||
return coreerr.E(op, "failed to read password hash", err)
|
||||
}
|
||||
|
||||
if !core.HasPrefix(storedHash, "$argon2id$") {
|
||||
if !strings.HasPrefix(storedHash, "$argon2id$") {
|
||||
return coreerr.E(op, "corrupted password hash", nil)
|
||||
}
|
||||
|
||||
|
|
@ -720,11 +721,11 @@ func (a *Authenticator) StartCleanup(ctx context.Context, interval time.Duration
|
|||
case <-ticker.C:
|
||||
count, err := a.store.Cleanup()
|
||||
if err != nil {
|
||||
coreerr.E("auth.StartCleanup", "session cleanup error", err)
|
||||
fmt.Printf("auth: session cleanup error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if count > 0 {
|
||||
_ = count // cleanup count logged by caller if needed
|
||||
fmt.Printf("auth: cleaned up %d expired session(s)\n", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ package auth
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
"forge.lthn.ai/core/go-store"
|
||||
)
|
||||
|
||||
|
|
@ -20,11 +19,9 @@ type SQLiteSessionStore struct {
|
|||
}
|
||||
|
||||
// NewSQLiteSessionStore creates a new SQLite-backed session store.
|
||||
//
|
||||
// sessionStore, err := auth.NewSQLiteSessionStore("/var/lib/agent/sessions.db")
|
||||
// authenticator := auth.New(medium, auth.WithSessionStore(sessionStore))
|
||||
func NewSQLiteSessionStore(databasePath string) (*SQLiteSessionStore, error) {
|
||||
s, err := store.New(databasePath)
|
||||
// Use ":memory:" for testing or a file path for persistent storage.
|
||||
func NewSQLiteSessionStore(dbPath string) (*SQLiteSessionStore, error) {
|
||||
s, err := store.New(dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -36,17 +33,17 @@ func (s *SQLiteSessionStore) Get(token string) (*Session, error) {
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
value, err := s.store.Get(sessionGroup, token)
|
||||
val, err := s.store.Get(sessionGroup, token)
|
||||
if err != nil {
|
||||
if core.Is(err, store.ErrNotFound) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var session Session
|
||||
if err := json.Unmarshal([]byte(value), &session); err != nil {
|
||||
return nil, coreerr.E("auth.SQLiteSessionStore.Get", "failed to unmarshal session", err)
|
||||
if err := json.Unmarshal([]byte(val), &session); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
|
@ -58,7 +55,7 @@ func (s *SQLiteSessionStore) Set(session *Session) error {
|
|||
|
||||
data, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return coreerr.E("auth.SQLiteSessionStore.Set", "failed to marshal session", err)
|
||||
return err
|
||||
}
|
||||
return s.store.Set(sessionGroup, session.Token, string(data))
|
||||
}
|
||||
|
|
@ -71,7 +68,7 @@ func (s *SQLiteSessionStore) Delete(token string) error {
|
|||
// Check existence first to return ErrSessionNotFound
|
||||
_, err := s.store.Get(sessionGroup, token)
|
||||
if err != nil {
|
||||
if core.Is(err, store.ErrNotFound) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
return err
|
||||
|
|
@ -89,9 +86,9 @@ func (s *SQLiteSessionStore) DeleteByUser(userID string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
for token, value := range all {
|
||||
for token, val := range all {
|
||||
var session Session
|
||||
if err := json.Unmarshal([]byte(value), &session); err != nil {
|
||||
if err := json.Unmarshal([]byte(val), &session); err != nil {
|
||||
continue // Skip malformed entries
|
||||
}
|
||||
if session.UserID == userID {
|
||||
|
|
@ -115,9 +112,9 @@ func (s *SQLiteSessionStore) Cleanup() (int, error) {
|
|||
|
||||
now := time.Now()
|
||||
count := 0
|
||||
for token, value := range all {
|
||||
for token, val := range all {
|
||||
var session Session
|
||||
if err := json.Unmarshal([]byte(value), &session); err != nil {
|
||||
if err := json.Unmarshal([]byte(val), &session); err != nil {
|
||||
continue // Skip malformed entries
|
||||
}
|
||||
if now.After(session.ExpiresAt) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
core "dappco.re/go/core"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
|
@ -40,20 +42,20 @@ func runChecksum(path string) error {
|
|||
|
||||
if checksumVerify != "" {
|
||||
if hash == checksumVerify {
|
||||
cli.Success(core.Sprintf("Checksum matches: %s", core.PathBase(path)))
|
||||
cli.Success(fmt.Sprintf("Checksum matches: %s", filepath.Base(path)))
|
||||
return nil
|
||||
}
|
||||
cli.Error(core.Sprintf("Checksum mismatch: %s", core.PathBase(path)))
|
||||
cli.Dim(core.Sprintf(" expected: %s", checksumVerify))
|
||||
cli.Dim(core.Sprintf(" got: %s", hash))
|
||||
cli.Error(fmt.Sprintf("Checksum mismatch: %s", filepath.Base(path)))
|
||||
cli.Dim(fmt.Sprintf(" expected: %s", checksumVerify))
|
||||
cli.Dim(fmt.Sprintf(" got: %s", hash))
|
||||
return cli.Err("checksum verification failed")
|
||||
}
|
||||
|
||||
algorithm := "SHA-256"
|
||||
algo := "SHA-256"
|
||||
if checksumSHA512 {
|
||||
algorithm = "SHA-512"
|
||||
algo = "SHA-512"
|
||||
}
|
||||
|
||||
cli.Text(core.Sprintf("%s %s (%s)", hash, path, algorithm))
|
||||
fmt.Printf("%s %s (%s)\n", hash, path, algo)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
core "dappco.re/go/core"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt"
|
||||
coreio "dappco.re/go/core/io"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
|
@ -72,7 +74,7 @@ func runEncrypt(path string) error {
|
|||
return cli.Wrap(err, "failed to write encrypted file")
|
||||
}
|
||||
|
||||
cli.Success(core.Sprintf("Encrypted %s -> %s", path, outPath))
|
||||
cli.Success(fmt.Sprintf("Encrypted %s -> %s", path, outPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +103,7 @@ func runDecrypt(path string) error {
|
|||
return cli.Wrap(err, "failed to decrypt")
|
||||
}
|
||||
|
||||
outPath := core.TrimSuffix(path, ".enc")
|
||||
outPath := strings.TrimSuffix(path, ".enc")
|
||||
if outPath == path {
|
||||
outPath = path + ".dec"
|
||||
}
|
||||
|
|
@ -110,6 +112,6 @@ func runDecrypt(path string) error {
|
|||
return cli.Wrap(err, "failed to write decrypted file")
|
||||
}
|
||||
|
||||
cli.Success(core.Sprintf("Decrypted %s -> %s", path, outPath))
|
||||
cli.Success(fmt.Sprintf("Decrypted %s -> %s", path, outPath))
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package crypt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"dappco.re/go/core/crypt/crypt"
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
|
||||
|
|
@ -37,7 +39,7 @@ func runHash(input string) error {
|
|||
if err != nil {
|
||||
return cli.Wrap(err, "failed to hash password")
|
||||
}
|
||||
cli.Text(hash)
|
||||
fmt.Println(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +47,7 @@ func runHash(input string) error {
|
|||
if err != nil {
|
||||
return cli.Wrap(err, "failed to hash password")
|
||||
}
|
||||
cli.Text(hash)
|
||||
fmt.Println(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
|
||||
"forge.lthn.ai/core/cli/pkg/cli"
|
||||
)
|
||||
|
|
@ -42,12 +43,12 @@ func runKeygen() error {
|
|||
|
||||
switch {
|
||||
case keygenHex:
|
||||
cli.Text(hex.EncodeToString(key))
|
||||
fmt.Println(hex.EncodeToString(key))
|
||||
case keygenBase64:
|
||||
cli.Text(base64.StdEncoding.EncodeToString(key))
|
||||
fmt.Println(base64.StdEncoding.EncodeToString(key))
|
||||
default:
|
||||
// Default to hex output
|
||||
cli.Text(hex.EncodeToString(key))
|
||||
fmt.Println(hex.EncodeToString(key))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -2,17 +2,15 @@ package chachapoly
|
|||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
)
|
||||
|
||||
// Encrypt encrypts data using ChaCha20-Poly1305.
|
||||
//
|
||||
// ciphertext, err := chachapoly.Encrypt(plaintext, key32)
|
||||
func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
||||
aead, err := chacha20poly1305.NewX(key)
|
||||
if err != nil {
|
||||
|
|
@ -28,8 +26,6 @@ func Encrypt(plaintext []byte, key []byte) ([]byte, error) {
|
|||
}
|
||||
|
||||
// Decrypt decrypts data using ChaCha20-Poly1305.
|
||||
//
|
||||
// plaintext, err := chachapoly.Decrypt(ciphertext, key32)
|
||||
func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
||||
const op = "chachapoly.Decrypt"
|
||||
|
||||
|
|
@ -40,7 +36,7 @@ func Decrypt(ciphertext []byte, key []byte) ([]byte, error) {
|
|||
|
||||
minLen := aead.NonceSize() + aead.Overhead()
|
||||
if len(ciphertext) < minLen {
|
||||
return nil, coreerr.E(op, core.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil)
|
||||
return nil, coreerr.E(op, fmt.Sprintf("ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen), nil)
|
||||
}
|
||||
|
||||
nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():]
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// mockReader is a reader that always returns an error.
|
||||
// mockReader is a reader that returns an error.
|
||||
type mockReader struct{}
|
||||
|
||||
func (r *mockReader) Read(p []byte) (n int, err error) {
|
||||
return 0, coreerr.E("chachapoly.mockReader.Read", "read error", nil)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_Good(t *testing.T) {
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
for i := range key {
|
||||
key[i] = 1
|
||||
|
|
@ -32,27 +32,14 @@ func TestEncryptDecrypt_Good(t *testing.T) {
|
|||
assert.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_Good_EmptyPlaintext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
plaintext := []byte("")
|
||||
ciphertext, err := Encrypt(plaintext, key)
|
||||
assert.NoError(t, err)
|
||||
|
||||
decrypted, err := Decrypt(ciphertext, key)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
func TestEncryptInvalidKeySize(t *testing.T) {
|
||||
key := make([]byte, 16) // Wrong size
|
||||
plaintext := []byte("test")
|
||||
_, err := Encrypt(plaintext, key)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_Good_CiphertextDiffersFromPlaintext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
plaintext := []byte("Hello, world!")
|
||||
ciphertext, err := Encrypt(plaintext, key)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, plaintext, ciphertext)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_Bad_WrongKey(t *testing.T) {
|
||||
func TestDecryptWithWrongKey(t *testing.T) {
|
||||
key1 := make([]byte, 32)
|
||||
key2 := make([]byte, 32)
|
||||
key2[0] = 1 // Different key
|
||||
|
|
@ -65,7 +52,7 @@ func TestEncryptDecrypt_Bad_WrongKey(t *testing.T) {
|
|||
assert.Error(t, err) // Should fail authentication
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_Bad_TamperedCiphertext(t *testing.T) {
|
||||
func TestDecryptTamperedCiphertext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
plaintext := []byte("secret")
|
||||
ciphertext, err := Encrypt(plaintext, key)
|
||||
|
|
@ -78,17 +65,36 @@ func TestEncryptDecrypt_Bad_TamperedCiphertext(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_Bad_InvalidKeySize(t *testing.T) {
|
||||
key := make([]byte, 16) // Wrong size
|
||||
plaintext := []byte("test")
|
||||
_, err := Encrypt(plaintext, key)
|
||||
assert.Error(t, err)
|
||||
func TestEncryptEmptyPlaintext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
plaintext := []byte("")
|
||||
ciphertext, err := Encrypt(plaintext, key)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = Decrypt([]byte("test"), key)
|
||||
assert.Error(t, err)
|
||||
decrypted, err := Decrypt(ciphertext, key)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
|
||||
func TestEncryptDecrypt_Ugly_NonceError(t *testing.T) {
|
||||
func TestDecryptShortCiphertext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
shortCiphertext := []byte("short")
|
||||
|
||||
_, err := Decrypt(shortCiphertext, key)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too short")
|
||||
}
|
||||
|
||||
func TestCiphertextDiffersFromPlaintext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
plaintext := []byte("Hello, world!")
|
||||
ciphertext, err := Encrypt(plaintext, key)
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, plaintext, ciphertext)
|
||||
}
|
||||
|
||||
func TestEncryptNonceError(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
plaintext := []byte("test")
|
||||
|
||||
|
|
@ -101,11 +107,9 @@ func TestEncryptDecrypt_Ugly_NonceError(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDecrypt_Ugly_ShortCiphertext(t *testing.T) {
|
||||
key := make([]byte, 32)
|
||||
shortCiphertext := []byte("short")
|
||||
|
||||
_, err := Decrypt(shortCiphertext, key)
|
||||
func TestDecryptInvalidKeySize(t *testing.T) {
|
||||
key := make([]byte, 16) // Wrong size
|
||||
ciphertext := []byte("test")
|
||||
_, err := Decrypt(ciphertext, key)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too short")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,24 +4,22 @@ import (
|
|||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
goio "io"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// SHA256File computes the SHA-256 checksum of a file and returns it as a hex string.
|
||||
//
|
||||
// sum, err := crypt.SHA256File("/path/to/archive.tar.gz")
|
||||
func SHA256File(path string) (string, error) {
|
||||
reader, err := coreio.ReadStream(coreio.Local, path)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", coreerr.E("crypt.SHA256File", "failed to open file", err)
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := goio.Copy(h, reader); err != nil {
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", coreerr.E("crypt.SHA256File", "failed to read file", err)
|
||||
}
|
||||
|
||||
|
|
@ -29,17 +27,15 @@ func SHA256File(path string) (string, error) {
|
|||
}
|
||||
|
||||
// SHA512File computes the SHA-512 checksum of a file and returns it as a hex string.
|
||||
//
|
||||
// sum, err := crypt.SHA512File("/path/to/archive.tar.gz")
|
||||
func SHA512File(path string) (string, error) {
|
||||
reader, err := coreio.ReadStream(coreio.Local, path)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", coreerr.E("crypt.SHA512File", "failed to open file", err)
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
h := sha512.New()
|
||||
if _, err := goio.Copy(h, reader); err != nil {
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", coreerr.E("crypt.SHA512File", "failed to read file", err)
|
||||
}
|
||||
|
||||
|
|
@ -47,16 +43,12 @@ func SHA512File(path string) (string, error) {
|
|||
}
|
||||
|
||||
// SHA256Sum computes the SHA-256 checksum of data and returns it as a hex string.
|
||||
//
|
||||
// digest := crypt.SHA256Sum([]byte("hello")) // "2cf24dba..."
|
||||
func SHA256Sum(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// SHA512Sum computes the SHA-512 checksum of data and returns it as a hex string.
|
||||
//
|
||||
// digest := crypt.SHA512Sum([]byte("hello")) // "9b71d224..."
|
||||
func SHA512Sum(data []byte) string {
|
||||
h := sha512.Sum512(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import (
|
|||
)
|
||||
|
||||
// Encrypt encrypts data with a passphrase using ChaCha20-Poly1305.
|
||||
//
|
||||
// ciphertext, err := crypt.Encrypt([]byte("secret"), []byte("passphrase"))
|
||||
// // Format: salt (16 bytes) + nonce (24 bytes) + ciphertext
|
||||
// A random salt is generated and prepended to the output.
|
||||
// Format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
|
||||
func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
|
||||
salt, err := generateSalt(argon2SaltLen)
|
||||
if err != nil {
|
||||
|
|
@ -29,8 +28,7 @@ func Encrypt(plaintext, passphrase []byte) ([]byte, error) {
|
|||
}
|
||||
|
||||
// Decrypt decrypts data encrypted with Encrypt.
|
||||
//
|
||||
// plaintext, err := crypt.Decrypt(ciphertext, []byte("passphrase"))
|
||||
// Expects format: salt (16 bytes) + nonce (24 bytes) + ciphertext.
|
||||
func Decrypt(ciphertext, passphrase []byte) ([]byte, error) {
|
||||
if len(ciphertext) < argon2SaltLen {
|
||||
return nil, coreerr.E("crypt.Decrypt", "ciphertext too short", nil)
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ package crypt
|
|||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
|
@ -12,9 +13,7 @@ import (
|
|||
)
|
||||
|
||||
// HashPassword hashes a password using Argon2id with default parameters.
|
||||
//
|
||||
// hash, err := crypt.HashPassword("hunter2")
|
||||
// // hash starts with "$argon2id$v=19$m=65536,t=3,p=4$..."
|
||||
// Returns a string in the format: $argon2id$v=19$m=65536,t=3,p=4$<base64salt>$<base64hash>
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt, err := generateSalt(argon2SaltLen)
|
||||
if err != nil {
|
||||
|
|
@ -26,7 +25,7 @@ func HashPassword(password string) (string, error) {
|
|||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
encoded := core.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argon2Memory, argon2Time, argon2Parallelism,
|
||||
b64Salt, b64Hash)
|
||||
|
||||
|
|
@ -34,29 +33,26 @@ func HashPassword(password string) (string, error) {
|
|||
}
|
||||
|
||||
// VerifyPassword verifies a password against an Argon2id hash string.
|
||||
//
|
||||
// ok, err := crypt.VerifyPassword("hunter2", storedHash)
|
||||
// if !ok { return coreerr.E("auth.Login", "invalid password", nil) }
|
||||
// The hash must be in the format produced by HashPassword.
|
||||
func VerifyPassword(password, hash string) (bool, error) {
|
||||
parts := core.Split(hash, "$")
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 {
|
||||
return false, coreerr.E("crypt.VerifyPassword", "invalid hash format", nil)
|
||||
}
|
||||
|
||||
// Parse version field: "v=19" -> 19
|
||||
var version uint32
|
||||
if err := parseUint32Field(parts[2], "v=", &version); err != nil {
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
|
||||
return false, coreerr.E("crypt.VerifyPassword", "failed to parse version", err)
|
||||
}
|
||||
|
||||
// Parse parameter field: "m=65536,t=3,p=4"
|
||||
var memory, timeParam uint32
|
||||
var memory uint32
|
||||
var time uint32
|
||||
var parallelism uint8
|
||||
if err := parseArgon2Params(parts[3], &memory, &timeParam, ¶llelism); err != nil {
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, ¶llelism); err != nil {
|
||||
return false, coreerr.E("crypt.VerifyPassword", "failed to parse parameters", err)
|
||||
}
|
||||
|
||||
saltBytes, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false, coreerr.E("crypt.VerifyPassword", "failed to decode salt", err)
|
||||
}
|
||||
|
|
@ -66,72 +62,13 @@ func VerifyPassword(password, hash string) (bool, error) {
|
|||
return false, coreerr.E("crypt.VerifyPassword", "failed to decode hash", err)
|
||||
}
|
||||
|
||||
computedHash := argon2.IDKey([]byte(password), saltBytes, timeParam, memory, parallelism, uint32(len(expectedHash)))
|
||||
computedHash := argon2.IDKey([]byte(password), salt, time, memory, parallelism, uint32(len(expectedHash)))
|
||||
|
||||
return subtle.ConstantTimeCompare(computedHash, expectedHash) == 1, nil
|
||||
}
|
||||
|
||||
// parseUint32Field parses a "prefix=N" string into a uint32.
|
||||
// For example: parseUint32Field("v=19", "v=", &out) sets out to 19.
|
||||
func parseUint32Field(s, prefix string, out *uint32) error {
|
||||
value := core.TrimPrefix(s, prefix)
|
||||
if value == s {
|
||||
return coreerr.E("crypt.parseUint32Field", "missing prefix "+prefix, nil)
|
||||
}
|
||||
n, err := parseDecimalUint32(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*out = n
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseArgon2Params parses "m=N,t=N,p=N" into memory, time, and parallelism.
|
||||
//
|
||||
// var m, t uint32; var p uint8
|
||||
// parseArgon2Params("m=65536,t=3,p=4", &m, &t, &p)
|
||||
func parseArgon2Params(s string, memory, timeParam *uint32, parallelism *uint8) error {
|
||||
const op = "crypt.parseArgon2Params"
|
||||
parts := core.Split(s, ",")
|
||||
if len(parts) != 3 {
|
||||
return coreerr.E(op, "expected 3 comma-separated fields", nil)
|
||||
}
|
||||
|
||||
var m, t, p uint32
|
||||
if err := parseUint32Field(parts[0], "m=", &m); err != nil {
|
||||
return coreerr.E(op, "failed to parse memory", err)
|
||||
}
|
||||
if err := parseUint32Field(parts[1], "t=", &t); err != nil {
|
||||
return coreerr.E(op, "failed to parse time", err)
|
||||
}
|
||||
if err := parseUint32Field(parts[2], "p=", &p); err != nil {
|
||||
return coreerr.E(op, "failed to parse parallelism", err)
|
||||
}
|
||||
|
||||
*memory = m
|
||||
*timeParam = t
|
||||
*parallelism = uint8(p)
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseDecimalUint32 parses a decimal string into a uint32.
|
||||
func parseDecimalUint32(s string) (uint32, error) {
|
||||
if s == "" {
|
||||
return 0, coreerr.E("crypt.parseDecimalUint32", "empty string", nil)
|
||||
}
|
||||
var result uint32
|
||||
for _, ch := range s {
|
||||
if ch < '0' || ch > '9' {
|
||||
return 0, coreerr.E("crypt.parseDecimalUint32", "non-digit character in "+s, nil)
|
||||
}
|
||||
result = result*10 + uint32(ch-'0')
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// HashBcrypt hashes a password using bcrypt with the given cost.
|
||||
//
|
||||
// hash, err := crypt.HashBcrypt("hunter2", bcrypt.DefaultCost)
|
||||
// Cost must be between bcrypt.MinCost and bcrypt.MaxCost.
|
||||
func HashBcrypt(password string, cost int) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), cost)
|
||||
if err != nil {
|
||||
|
|
@ -141,8 +78,6 @@ func HashBcrypt(password string, cost int) (string, error) {
|
|||
}
|
||||
|
||||
// VerifyBcrypt verifies a password against a bcrypt hash.
|
||||
//
|
||||
// ok, err := crypt.VerifyBcrypt("hunter2", storedHash)
|
||||
func VerifyBcrypt(password, hash string) (bool, error) {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
if err == bcrypt.ErrMismatchedHashAndPassword {
|
||||
|
|
|
|||
|
|
@ -42,35 +42,9 @@ func TestHashBcrypt_Good(t *testing.T) {
|
|||
match, err := VerifyBcrypt(password, hash)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, match)
|
||||
}
|
||||
|
||||
func TestHashBcrypt_Bad_WrongPassword(t *testing.T) {
|
||||
password := "bcrypt-test-password"
|
||||
|
||||
hash, err := HashBcrypt(password, bcrypt.DefaultCost)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Wrong password should not match
|
||||
match, err := VerifyBcrypt("wrong-password", hash)
|
||||
match, err = VerifyBcrypt("wrong-password", hash)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, match)
|
||||
}
|
||||
|
||||
func TestHashBcrypt_Ugly_InvalidCost(t *testing.T) {
|
||||
// bcrypt cost above maximum is rejected by the library.
|
||||
_, err := HashBcrypt("password", bcrypt.MaxCost+1)
|
||||
assert.Error(t, err, "invalid bcrypt cost above maximum should return error")
|
||||
}
|
||||
|
||||
func TestVerifyPassword_Ugly_InvalidHashFormat(t *testing.T) {
|
||||
// Hash string with wrong number of dollar-delimited parts.
|
||||
_, err := VerifyPassword("anypassword", "not-a-valid-hash")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid hash format")
|
||||
}
|
||||
|
||||
func TestVerifyPassword_Ugly_CorruptBase64Salt(t *testing.T) {
|
||||
// Valid structure but corrupt base64 in the salt field.
|
||||
_, err := VerifyPassword("pass", "$argon2id$v=19$m=65536,t=3,p=4$!!!invalid!!!$aGVsbG8=")
|
||||
assert.Error(t, err, "corrupt salt base64 should return error")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@ import (
|
|||
)
|
||||
|
||||
// HMACSHA256 computes the HMAC-SHA256 of a message using the given key.
|
||||
//
|
||||
// mac := crypt.HMACSHA256([]byte("message"), []byte("secret"))
|
||||
func HMACSHA256(message, key []byte) []byte {
|
||||
mac := hmac.New(sha256.New, key)
|
||||
mac.Write(message)
|
||||
|
|
@ -17,8 +15,6 @@ func HMACSHA256(message, key []byte) []byte {
|
|||
}
|
||||
|
||||
// HMACSHA512 computes the HMAC-SHA512 of a message using the given key.
|
||||
//
|
||||
// mac := crypt.HMACSHA512([]byte("message"), []byte("secret"))
|
||||
func HMACSHA512(message, key []byte) []byte {
|
||||
mac := hmac.New(sha512.New, key)
|
||||
mac.Write(message)
|
||||
|
|
@ -26,8 +22,7 @@ func HMACSHA512(message, key []byte) []byte {
|
|||
}
|
||||
|
||||
// VerifyHMAC verifies an HMAC using constant-time comparison.
|
||||
//
|
||||
// ok := crypt.VerifyHMAC(message, key, receivedMAC, sha256.New)
|
||||
// hashFunc should be sha256.New, sha512.New, etc.
|
||||
func VerifyHMAC(message, key, mac []byte, hashFunc func() hash.Hash) bool {
|
||||
expected := hmac.New(hashFunc, key)
|
||||
expected.Write(message)
|
||||
|
|
|
|||
|
|
@ -38,25 +38,3 @@ func TestVerifyHMAC_Bad(t *testing.T) {
|
|||
valid := VerifyHMAC(tampered, key, mac, sha256.New)
|
||||
assert.False(t, valid)
|
||||
}
|
||||
|
||||
func TestHMACSHA512_Good(t *testing.T) {
|
||||
key := []byte("secret-key")
|
||||
message := []byte("test message sha512")
|
||||
|
||||
mac512 := HMACSHA512(message, key)
|
||||
assert.NotEmpty(t, mac512)
|
||||
assert.Len(t, mac512, 64) // SHA-512 produces 64 bytes
|
||||
|
||||
// Must differ from SHA-256 result
|
||||
mac256 := HMACSHA256(message, key)
|
||||
assert.NotEqual(t, mac512, mac256)
|
||||
}
|
||||
|
||||
func TestVerifyHMAC_Ugly_EmptyKeyAndMessage(t *testing.T) {
|
||||
// Both key and message are empty — HMAC is still deterministic.
|
||||
mac := HMACSHA256([]byte{}, []byte{})
|
||||
assert.NotEmpty(t, mac, "HMAC of empty inputs must still produce a digest")
|
||||
|
||||
valid := VerifyHMAC([]byte{}, []byte{}, mac, sha256.New)
|
||||
assert.True(t, valid, "HMAC verification must succeed for matching empty inputs")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,15 +24,13 @@ const (
|
|||
)
|
||||
|
||||
// DeriveKey derives a key from a passphrase using Argon2id with default parameters.
|
||||
//
|
||||
// key := crypt.DeriveKey([]byte("passphrase"), salt16, 32)
|
||||
// The salt must be argon2SaltLen bytes. keyLen specifies the desired key length.
|
||||
func DeriveKey(passphrase, salt []byte, keyLen uint32) []byte {
|
||||
return argon2.IDKey(passphrase, salt, argon2Time, argon2Memory, argon2Parallelism, keyLen)
|
||||
}
|
||||
|
||||
// DeriveKeyScrypt derives a key from a passphrase using scrypt.
|
||||
//
|
||||
// key, err := crypt.DeriveKeyScrypt([]byte("passphrase"), salt16, 32)
|
||||
// Uses recommended parameters: N=32768, r=8, p=1.
|
||||
func DeriveKeyScrypt(passphrase, salt []byte, keyLen int) ([]byte, error) {
|
||||
key, err := scrypt.Key(passphrase, salt, 32768, 8, 1, keyLen)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"bytes"
|
||||
"crypto"
|
||||
goio "io"
|
||||
"strings"
|
||||
|
||||
framework "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
|
|
@ -101,7 +102,7 @@ func serializeEntity(w goio.Writer, e *openpgp.Entity) error {
|
|||
// EncryptPGP encrypts data for a recipient identified by their public key (armored string in recipientPath).
|
||||
// The encrypted data is written to the provided writer and also returned as an armored string.
|
||||
func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opts ...any) (string, error) {
|
||||
entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(recipientPath)))
|
||||
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(recipientPath))
|
||||
if err != nil {
|
||||
return "", coreerr.E("openpgp.EncryptPGP", "failed to read recipient key", err)
|
||||
}
|
||||
|
|
@ -136,7 +137,7 @@ func (s *Service) EncryptPGP(writer goio.Writer, recipientPath, data string, opt
|
|||
|
||||
// DecryptPGP decrypts a PGP message using the provided armored private key and passphrase.
|
||||
func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any) (string, error) {
|
||||
entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKey)))
|
||||
entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(privateKey))
|
||||
if err != nil {
|
||||
return "", coreerr.E("openpgp.DecryptPGP", "failed to read private key", err)
|
||||
}
|
||||
|
|
@ -155,7 +156,7 @@ func (s *Service) DecryptPGP(privateKey, message, passphrase string, opts ...any
|
|||
}
|
||||
|
||||
// Decrypt armored message
|
||||
block, err := armor.Decode(bytes.NewReader([]byte(message)))
|
||||
block, err := armor.Decode(strings.NewReader(message))
|
||||
if err != nil {
|
||||
return "", coreerr.E("openpgp.DecryptPGP", "failed to decode armored message", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,30 +6,25 @@ import (
|
|||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
// Service provides RSA functionality.
|
||||
type Service struct{}
|
||||
|
||||
// NewService creates a new Service instance for RSA operations.
|
||||
//
|
||||
// svc := rsa.NewService()
|
||||
// pub, priv, err := svc.GenerateKeyPair(4096)
|
||||
// NewService creates and returns a new Service instance for performing RSA-related operations.
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// GenerateKeyPair creates a new RSA key pair.
|
||||
//
|
||||
// pub, priv, err := svc.GenerateKeyPair(4096)
|
||||
func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err error) {
|
||||
const op = "rsa.GenerateKeyPair"
|
||||
|
||||
if bits < 2048 {
|
||||
return nil, nil, coreerr.E(op, core.Sprintf("key size too small: %d (minimum 2048)", bits), nil)
|
||||
return nil, nil, coreerr.E(op, fmt.Sprintf("key size too small: %d (minimum 2048)", bits), nil)
|
||||
}
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, bits)
|
||||
if err != nil {
|
||||
|
|
@ -54,9 +49,7 @@ func (s *Service) GenerateKeyPair(bits int) (publicKey, privateKey []byte, err e
|
|||
return pubKeyPEM, privKeyPEM, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts data with a public key using RSA-OAEP.
|
||||
//
|
||||
// ciphertext, err := svc.Encrypt(pubPEM, []byte("secret"), nil)
|
||||
// Encrypt encrypts data with a public key.
|
||||
func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
|
||||
const op = "rsa.Encrypt"
|
||||
|
||||
|
|
@ -83,9 +76,7 @@ func (s *Service) Encrypt(publicKey, data, label []byte) ([]byte, error) {
|
|||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts data with a private key using RSA-OAEP.
|
||||
//
|
||||
// plaintext, err := svc.Decrypt(privPEM, ciphertext, nil)
|
||||
// Decrypt decrypts data with a private key.
|
||||
func (s *Service) Decrypt(privateKey, ciphertext, label []byte) ([]byte, error) {
|
||||
const op = "rsa.Decrypt"
|
||||
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -3,7 +3,7 @@ module dappco.re/go/core/crypt
|
|||
go 1.26.0
|
||||
|
||||
require (
|
||||
dappco.re/go/core v0.7.0
|
||||
dappco.re/go/core v0.5.0
|
||||
dappco.re/go/core/i18n v0.2.0
|
||||
dappco.re/go/core/io v0.2.0
|
||||
dappco.re/go/core/log v0.1.0
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -1,7 +1,5 @@
|
|||
dappco.re/go/core v0.5.0 h1:P5DJoaCiK5Q+af5UiTdWqUIW4W4qYKzpgGK50thm21U=
|
||||
dappco.re/go/core v0.5.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core v0.7.0 h1:A3vi7LD0jBBA7n+8WPZmjxbRDZ43FFoKhBJ/ydKDPSs=
|
||||
dappco.re/go/core v0.7.0/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
|
||||
dappco.re/go/core/i18n v0.2.0 h1:NHzk6RCU93/qVRA3f2jvMr9P1R6FYheR/sHL+TnvKbI=
|
||||
dappco.re/go/core/i18n v0.2.0/go.mod h1:9eSVJXr3OpIGWQvDynfhqcp27xnLMwlYLgsByU+p7ok=
|
||||
dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4=
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
|
|
@ -22,8 +22,6 @@ const (
|
|||
)
|
||||
|
||||
// String returns the human-readable name of the approval status.
|
||||
//
|
||||
// ApprovalPending.String() // "pending"
|
||||
func (s ApprovalStatus) String() string {
|
||||
switch s {
|
||||
case ApprovalPending:
|
||||
|
|
@ -33,7 +31,7 @@ func (s ApprovalStatus) String() string {
|
|||
case ApprovalDenied:
|
||||
return "denied"
|
||||
default:
|
||||
return core.Sprintf("unknown(%d)", int(s))
|
||||
return fmt.Sprintf("unknown(%d)", int(s))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +85,7 @@ func (q *ApprovalQueue) Submit(agent string, cap Capability, repo string) (strin
|
|||
defer q.mu.Unlock()
|
||||
|
||||
q.nextID++
|
||||
id := core.Sprintf("approval-%d", q.nextID)
|
||||
id := fmt.Sprintf("approval-%d", q.nextID)
|
||||
|
||||
q.requests[id] = &ApprovalRequest{
|
||||
ID: id,
|
||||
|
|
@ -109,10 +107,10 @@ func (q *ApprovalQueue) Approve(id string, reviewedBy string, reason string) err
|
|||
|
||||
req, ok := q.requests[id]
|
||||
if !ok {
|
||||
return coreerr.E("trust.ApprovalQueue.Approve", core.Sprintf("request %q not found", id), nil)
|
||||
return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q not found", id), nil)
|
||||
}
|
||||
if req.Status != ApprovalPending {
|
||||
return coreerr.E("trust.ApprovalQueue.Approve", core.Sprintf("request %q is already %s", id, req.Status), nil)
|
||||
return coreerr.E("trust.ApprovalQueue.Approve", fmt.Sprintf("request %q is already %s", id, req.Status), nil)
|
||||
}
|
||||
|
||||
req.Status = ApprovalApproved
|
||||
|
|
@ -130,10 +128,10 @@ func (q *ApprovalQueue) Deny(id string, reviewedBy string, reason string) error
|
|||
|
||||
req, ok := q.requests[id]
|
||||
if !ok {
|
||||
return coreerr.E("trust.ApprovalQueue.Deny", core.Sprintf("request %q not found", id), nil)
|
||||
return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q not found", id), nil)
|
||||
}
|
||||
if req.Status != ApprovalPending {
|
||||
return coreerr.E("trust.ApprovalQueue.Deny", core.Sprintf("request %q is already %s", id, req.Status), nil)
|
||||
return coreerr.E("trust.ApprovalQueue.Deny", fmt.Sprintf("request %q is already %s", id, req.Status), nil)
|
||||
}
|
||||
|
||||
req.Status = ApprovalDenied
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package trust
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
goio "io"
|
||||
"io"
|
||||
"iter"
|
||||
"sync"
|
||||
"time"
|
||||
|
|
@ -54,15 +54,12 @@ func (d *Decision) UnmarshalJSON(data []byte) error {
|
|||
type AuditLog struct {
|
||||
mu sync.Mutex
|
||||
entries []AuditEntry
|
||||
writer goio.Writer
|
||||
writer io.Writer
|
||||
}
|
||||
|
||||
// NewAuditLog creates an in-memory audit log. If a writer is provided,
|
||||
// each entry is also written as a JSON line to that writer (append-only).
|
||||
//
|
||||
// auditLog := trust.NewAuditLog(os.Stdout)
|
||||
// err := auditLog.Record(result, "host-uk/core")
|
||||
func NewAuditLog(w goio.Writer) *AuditLog {
|
||||
func NewAuditLog(w io.Writer) *AuditLog {
|
||||
return &AuditLog{
|
||||
writer: w,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ package trust
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
goio "io"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreio "dappco.re/go/core/io"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
|
|
@ -23,21 +23,17 @@ type PoliciesConfig struct {
|
|||
}
|
||||
|
||||
// LoadPoliciesFromFile reads a JSON file and returns parsed policies.
|
||||
//
|
||||
// policies, err := trust.LoadPoliciesFromFile("/etc/agent/policies.json")
|
||||
func LoadPoliciesFromFile(path string) ([]Policy, error) {
|
||||
reader, err := coreio.ReadStream(coreio.Local, path)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, coreerr.E("trust.LoadPoliciesFromFile", "failed to open file", err)
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
return LoadPolicies(reader)
|
||||
defer f.Close()
|
||||
return LoadPolicies(f)
|
||||
}
|
||||
|
||||
// LoadPolicies reads JSON from a reader and returns parsed policies.
|
||||
//
|
||||
// policies, err := trust.LoadPolicies(strings.NewReader(jsonInput))
|
||||
func LoadPolicies(r goio.Reader) ([]Policy, error) {
|
||||
func LoadPolicies(r io.Reader) ([]Policy, error) {
|
||||
const op = "trust.LoadPolicies"
|
||||
|
||||
var cfg PoliciesConfig
|
||||
|
|
@ -49,7 +45,7 @@ func LoadPolicies(r goio.Reader) ([]Policy, error) {
|
|||
|
||||
// Reject trailing data after the decoded value
|
||||
var extra json.RawMessage
|
||||
if err := dec.Decode(&extra); err != goio.EOF {
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
return nil, coreerr.E(op, "unexpected trailing data in JSON", nil)
|
||||
}
|
||||
|
||||
|
|
@ -63,7 +59,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
|
|||
for i, pc := range cfg.Policies {
|
||||
tier := Tier(pc.Tier)
|
||||
if !tier.Valid() {
|
||||
return nil, coreerr.E("trust.LoadPolicies", core.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil)
|
||||
return nil, coreerr.E("trust.LoadPolicies", fmt.Sprintf("invalid tier %d at index %d", pc.Tier, i), nil)
|
||||
}
|
||||
|
||||
p := Policy{
|
||||
|
|
@ -80,9 +76,7 @@ func convertPolicies(cfg PoliciesConfig) ([]Policy, error) {
|
|||
|
||||
// ApplyPolicies loads policies from a reader and sets them on the engine,
|
||||
// replacing any existing policies for the same tiers.
|
||||
//
|
||||
// err := engine.ApplyPolicies(strings.NewReader(policyJSON))
|
||||
func (pe *PolicyEngine) ApplyPolicies(r goio.Reader) error {
|
||||
func (pe *PolicyEngine) ApplyPolicies(r io.Reader) error {
|
||||
policies, err := LoadPolicies(r)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -96,22 +90,17 @@ func (pe *PolicyEngine) ApplyPolicies(r goio.Reader) error {
|
|||
}
|
||||
|
||||
// ApplyPoliciesFromFile loads policies from a JSON file and sets them on the engine.
|
||||
//
|
||||
// err := engine.ApplyPoliciesFromFile("/etc/agent/policies.json")
|
||||
func (pe *PolicyEngine) ApplyPoliciesFromFile(path string) error {
|
||||
reader, err := coreio.ReadStream(coreio.Local, path)
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return coreerr.E("trust.ApplyPoliciesFromFile", "failed to open file", err)
|
||||
}
|
||||
defer func() { _ = reader.Close() }()
|
||||
return pe.ApplyPolicies(reader)
|
||||
defer f.Close()
|
||||
return pe.ApplyPolicies(f)
|
||||
}
|
||||
|
||||
// ExportPolicies serialises the current policies as JSON to the given writer.
|
||||
//
|
||||
// var buf bytes.Buffer
|
||||
// err := engine.ExportPolicies(&buf)
|
||||
func (pe *PolicyEngine) ExportPolicies(w goio.Writer) error {
|
||||
func (pe *PolicyEngine) ExportPolicies(w io.Writer) error {
|
||||
var cfg PoliciesConfig
|
||||
for _, tier := range []Tier{TierUntrusted, TierVerified, TierFull} {
|
||||
p := pe.GetPolicy(tier)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
|
|
@ -38,8 +39,6 @@ const (
|
|||
)
|
||||
|
||||
// String returns the human-readable name of the decision.
|
||||
//
|
||||
// Allow.String() // "allow"
|
||||
func (d Decision) String() string {
|
||||
switch d {
|
||||
case Deny:
|
||||
|
|
@ -49,7 +48,7 @@ func (d Decision) String() string {
|
|||
case NeedsApproval:
|
||||
return "needs_approval"
|
||||
default:
|
||||
return core.Sprintf("unknown(%d)", int(d))
|
||||
return fmt.Sprintf("unknown(%d)", int(d))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -62,9 +61,6 @@ type EvalResult struct {
|
|||
}
|
||||
|
||||
// NewPolicyEngine creates a policy engine with the given registry and default policies.
|
||||
//
|
||||
// engine := trust.NewPolicyEngine(registry)
|
||||
// result := engine.Evaluate("Clotho", trust.CapPushRepo, "host-uk/core")
|
||||
func NewPolicyEngine(registry *Registry) *PolicyEngine {
|
||||
pe := &PolicyEngine{
|
||||
registry: registry,
|
||||
|
|
@ -94,7 +90,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
|
|||
Decision: Deny,
|
||||
Agent: agentName,
|
||||
Cap: cap,
|
||||
Reason: core.Sprintf("no policy for tier %s", agent.Tier),
|
||||
Reason: fmt.Sprintf("no policy for tier %s", agent.Tier),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +100,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
|
|||
Decision: Deny,
|
||||
Agent: agentName,
|
||||
Cap: cap,
|
||||
Reason: core.Sprintf("capability %s is denied for tier %s", cap, agent.Tier),
|
||||
Reason: fmt.Sprintf("capability %s is denied for tier %s", cap, agent.Tier),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -114,7 +110,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
|
|||
Decision: NeedsApproval,
|
||||
Agent: agentName,
|
||||
Cap: cap,
|
||||
Reason: core.Sprintf("capability %s requires approval for tier %s", cap, agent.Tier),
|
||||
Reason: fmt.Sprintf("capability %s requires approval for tier %s", cap, agent.Tier),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -128,7 +124,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
|
|||
Decision: Deny,
|
||||
Agent: agentName,
|
||||
Cap: cap,
|
||||
Reason: core.Sprintf("agent %q does not have access to repo %q", agentName, repo),
|
||||
Reason: fmt.Sprintf("agent %q does not have access to repo %q", agentName, repo),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,7 +132,7 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
|
|||
Decision: Allow,
|
||||
Agent: agentName,
|
||||
Cap: cap,
|
||||
Reason: core.Sprintf("capability %s allowed for tier %s", cap, agent.Tier),
|
||||
Reason: fmt.Sprintf("capability %s allowed for tier %s", cap, agent.Tier),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -145,16 +141,14 @@ func (pe *PolicyEngine) Evaluate(agentName string, cap Capability, repo string)
|
|||
Decision: Deny,
|
||||
Agent: agentName,
|
||||
Cap: cap,
|
||||
Reason: core.Sprintf("capability %s not granted for tier %s", cap, agent.Tier),
|
||||
Reason: fmt.Sprintf("capability %s not granted for tier %s", cap, agent.Tier),
|
||||
}
|
||||
}
|
||||
|
||||
// SetPolicy replaces the policy for a given tier.
|
||||
//
|
||||
// err := engine.SetPolicy(trust.Policy{Tier: trust.TierFull, Allowed: []trust.Capability{trust.CapPushRepo}})
|
||||
func (pe *PolicyEngine) SetPolicy(p Policy) error {
|
||||
if !p.Tier.Valid() {
|
||||
return coreerr.E("trust.SetPolicy", core.Sprintf("invalid tier %d", p.Tier), nil)
|
||||
return coreerr.E("trust.SetPolicy", fmt.Sprintf("invalid tier %d", p.Tier), nil)
|
||||
}
|
||||
pe.policies[p.Tier] = &p
|
||||
return nil
|
||||
|
|
@ -223,11 +217,9 @@ func (pe *PolicyEngine) loadDefaults() {
|
|||
}
|
||||
|
||||
// isRepoScoped returns true if the capability is constrained by repo scope.
|
||||
// Only repo.* capabilities and secrets.read require explicit repo authorisation.
|
||||
// PR and issue capabilities are not repo-scoped — enforcement happens at the
|
||||
// forge layer.
|
||||
func isRepoScoped(cap Capability) bool {
|
||||
return core.HasPrefix(string(cap), "repo.") ||
|
||||
return strings.HasPrefix(string(cap), "repo.") ||
|
||||
strings.HasPrefix(string(cap), "pr.") ||
|
||||
cap == CapReadSecrets
|
||||
}
|
||||
|
||||
|
|
@ -261,14 +253,14 @@ func matchScope(pattern, repo string) bool {
|
|||
}
|
||||
|
||||
// Check for wildcard patterns.
|
||||
if !core.Contains(pattern, "*") {
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return false
|
||||
}
|
||||
|
||||
// "prefix/**" — recursive: matches anything under prefix/.
|
||||
if core.HasSuffix(pattern, "/**") {
|
||||
if strings.HasSuffix(pattern, "/**") {
|
||||
prefix := pattern[:len(pattern)-3] // strip "/**"
|
||||
if !core.HasPrefix(repo, prefix+"/") {
|
||||
if !strings.HasPrefix(repo, prefix+"/") {
|
||||
return false
|
||||
}
|
||||
// Must have something after the prefix/.
|
||||
|
|
@ -276,14 +268,14 @@ func matchScope(pattern, repo string) bool {
|
|||
}
|
||||
|
||||
// "prefix/*" — single level: matches prefix/X but not prefix/X/Y.
|
||||
if core.HasSuffix(pattern, "/*") {
|
||||
if strings.HasSuffix(pattern, "/*") {
|
||||
prefix := pattern[:len(pattern)-2] // strip "/*"
|
||||
if !core.HasPrefix(repo, prefix+"/") {
|
||||
if !strings.HasPrefix(repo, prefix+"/") {
|
||||
return false
|
||||
}
|
||||
remainder := repo[len(prefix)+1:]
|
||||
// Must have a non-empty name, and no further slashes.
|
||||
return remainder != "" && !core.Contains(remainder, "/")
|
||||
return remainder != "" && !strings.Contains(remainder, "/")
|
||||
}
|
||||
|
||||
// Unsupported wildcard position — fall back to no match.
|
||||
|
|
|
|||
|
|
@ -212,16 +212,11 @@ func TestGetPolicy_Bad_NotFound(t *testing.T) {
|
|||
|
||||
func TestIsRepoScoped_Good(t *testing.T) {
|
||||
assert.True(t, isRepoScoped(CapPushRepo))
|
||||
assert.True(t, isRepoScoped(CapCreatePR))
|
||||
assert.True(t, isRepoScoped(CapMergePR))
|
||||
assert.True(t, isRepoScoped(CapReadSecrets))
|
||||
}
|
||||
|
||||
func TestIsRepoScoped_Bad_PRCapsNotRepoScoped(t *testing.T) {
|
||||
// PR capabilities are not repo-scoped in the policy engine —
|
||||
// enforcement for PR targets happens at the forge layer.
|
||||
assert.False(t, isRepoScoped(CapCreatePR))
|
||||
assert.False(t, isRepoScoped(CapMergePR))
|
||||
}
|
||||
|
||||
func TestIsRepoScoped_Bad_NotScoped(t *testing.T) {
|
||||
assert.False(t, isRepoScoped(CapRunPrivileged))
|
||||
assert.False(t, isRepoScoped(CapAccessWorkspace))
|
||||
|
|
|
|||
|
|
@ -11,11 +11,11 @@
|
|||
package trust
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"iter"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
core "dappco.re/go/core"
|
||||
coreerr "dappco.re/go/core/log"
|
||||
)
|
||||
|
||||
|
|
@ -32,8 +32,6 @@ const (
|
|||
)
|
||||
|
||||
// String returns the human-readable name of the tier.
|
||||
//
|
||||
// TierFull.String() // "full"
|
||||
func (t Tier) String() string {
|
||||
switch t {
|
||||
case TierUntrusted:
|
||||
|
|
@ -43,7 +41,7 @@ func (t Tier) String() string {
|
|||
case TierFull:
|
||||
return "full"
|
||||
default:
|
||||
return core.Sprintf("unknown(%d)", int(t))
|
||||
return fmt.Sprintf("unknown(%d)", int(t))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -100,14 +98,13 @@ func NewRegistry() *Registry {
|
|||
}
|
||||
|
||||
// Register adds or updates an agent in the registry.
|
||||
//
|
||||
// err := registry.Register(trust.Agent{Name: "Athena", Tier: trust.TierFull})
|
||||
// Returns an error if the agent name is empty or the tier is invalid.
|
||||
func (r *Registry) Register(agent Agent) error {
|
||||
if agent.Name == "" {
|
||||
return coreerr.E("trust.Register", "agent name is required", nil)
|
||||
}
|
||||
if !agent.Tier.Valid() {
|
||||
return coreerr.E("trust.Register", core.Sprintf("invalid tier %d for agent %q", agent.Tier, agent.Name), nil)
|
||||
return coreerr.E("trust.Register", fmt.Sprintf("invalid tier %d for agent %q", agent.Tier, agent.Name), nil)
|
||||
}
|
||||
if agent.CreatedAt.IsZero() {
|
||||
agent.CreatedAt = time.Now()
|
||||
|
|
@ -123,8 +120,6 @@ func (r *Registry) Register(agent Agent) error {
|
|||
}
|
||||
|
||||
// Get returns the agent with the given name, or nil if not found.
|
||||
//
|
||||
// agent := registry.Get("Athena") // nil if not registered
|
||||
func (r *Registry) Get(name string) *Agent {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ func TestRegistryListSeq_Good(t *testing.T) {
|
|||
|
||||
// --- Agent ---
|
||||
|
||||
func TestAgentTokenExpiry_Good(t *testing.T) {
|
||||
func TestAgentTokenExpiry(t *testing.T) {
|
||||
agent := Agent{
|
||||
Name: "Test",
|
||||
Tier: TierVerified,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue