This commit is contained in:
Vi 2026-02-05 21:34:22 +00:00 committed by GitHub
commit 746ca5b5e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3985 additions and 0 deletions

144
cmd/trix/keyserver.go Normal file
View file

@ -0,0 +1,144 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/Snider/Enchantrix/pkg/keyserver"
"github.com/Snider/Enchantrix/pkg/keystore"
"github.com/spf13/cobra"
)
var (
keyserverCmd = &cobra.Command{
Use: "keyserver",
Short: "Manage and run the keyserver",
Long: "Start the keyserver process and manage sessions for capability-based access to crypto operations.",
}
keyserverStartCmd = &cobra.Command{
Use: "start",
Short: "Start the keyserver (blocks until interrupted)",
Long: "Opens the key store and keeps the keyserver running. Reads master password from stdin or ENCHANTRIX_MASTER env.",
RunE: runKeyserverStart,
}
keyserverSessionCmd = &cobra.Command{
Use: "session",
Short: "Manage keyserver sessions",
}
keyserverSessionCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a new session token",
RunE: runKeyserverSessionCreate,
}
)
func init() {
keyserverStartCmd.Flags().StringP("store", "s", "", "Key store file path (required)")
keyserverStartCmd.Flags().StringP("addr", "a", ":9100", "Listen address (for future network use)")
keyserverStartCmd.MarkFlagRequired("store")
keyserverSessionCreateCmd.Flags().StringP("store", "s", "", "Key store file path (required)")
keyserverSessionCreateCmd.Flags().StringP("caps", "c", "", "Capabilities: comma-separated op:keyID pairs (required)")
keyserverSessionCreateCmd.Flags().StringP("ttl", "", "1h", "Session time-to-live (e.g. 1h, 30m, 24h)")
keyserverSessionCreateCmd.Flags().StringP("client", "", "", "Client identifier")
keyserverSessionCreateCmd.MarkFlagRequired("store")
keyserverSessionCreateCmd.MarkFlagRequired("caps")
keyserverSessionCmd.AddCommand(keyserverSessionCreateCmd)
keyserverCmd.AddCommand(keyserverStartCmd, keyserverSessionCmd)
rootCmd.AddCommand(keyserverCmd)
}
func runKeyserverStart(cmd *cobra.Command, args []string) error {
storePath, _ := cmd.Flags().GetString("store")
addr, _ := cmd.Flags().GetString("addr")
r := stdinReader(cmd)
master, err := readMasterPassword(cmd, r)
if err != nil {
return err
}
store, err := keystore.Open(storePath, master)
if err != nil {
return err
}
ks := keyserver.NewServer(store)
_ = ks // server is ready for in-process callers
fmt.Fprintf(cmd.OutOrStdout(), "Keyserver started (store: %s, addr: %s)\n", storePath, addr)
fmt.Fprintf(cmd.OutOrStdout(), "Keys loaded: %d\n", len(store.List()))
fmt.Fprintln(cmd.OutOrStdout(), "Press Ctrl+C to stop.")
// Block until signal
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
select {
case sig := <-sigCh:
fmt.Fprintf(cmd.OutOrStdout(), "\nReceived %s, shutting down...\n", sig)
case <-ctx.Done():
}
store.Close()
fmt.Fprintln(cmd.OutOrStdout(), "Keyserver stopped. Key material zeroed.")
return nil
}
func runKeyserverSessionCreate(cmd *cobra.Command, args []string) error {
storePath, _ := cmd.Flags().GetString("store")
capsStr, _ := cmd.Flags().GetString("caps")
ttlStr, _ := cmd.Flags().GetString("ttl")
clientID, _ := cmd.Flags().GetString("client")
ttl, err := time.ParseDuration(ttlStr)
if err != nil {
return fmt.Errorf("invalid TTL %q: %w", ttlStr, err)
}
caps, err := keyserver.ParseCapabilities(capsStr)
if err != nil {
return err
}
r := stdinReader(cmd)
master, err := readMasterPassword(cmd, r)
if err != nil {
return err
}
store, err := keystore.Open(storePath, master)
if err != nil {
return err
}
defer store.Close()
sm := keyserver.NewSessionManager()
ctx := context.Background()
session, err := sm.CreateSession(ctx, clientID, caps, ttl)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Session ID: %s\n", session.ID)
fmt.Fprintf(cmd.OutOrStdout(), "Client: %s\n", session.ClientID)
fmt.Fprintf(cmd.OutOrStdout(), "Expires: %s\n", session.ExpiresAt.Format(time.RFC3339))
fmt.Fprintln(cmd.OutOrStdout(), "Capabilities:")
for _, c := range session.Capabilities {
fmt.Fprintf(cmd.OutOrStdout(), " - %s\n", c)
}
return nil
}

View file

@ -0,0 +1,69 @@
package main
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestKeyserverSessionCreate(t *testing.T) {
dir := t.TempDir()
storePath := dir + "/keys.trix"
// Init store
cmd, _, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
require.NoError(t, runKeystoreInit(cmd, nil))
// Create session
cmd, out, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("store", "s", storePath, "")
cmd.Flags().StringP("caps", "c", "encrypt:key-abc,decrypt:key-abc", "")
cmd.Flags().StringP("ttl", "", "1h", "")
cmd.Flags().StringP("client", "", "test-agent", "")
err := runKeyserverSessionCreate(cmd, nil)
require.NoError(t, err)
output := out.String()
assert.Contains(t, output, "Session ID:")
assert.Contains(t, output, "ses_")
assert.Contains(t, output, "test-agent")
assert.Contains(t, output, "encrypt:key-abc")
assert.Contains(t, output, "decrypt:key-abc")
}
func TestKeyserverSessionCreateInvalidCaps(t *testing.T) {
dir := t.TempDir()
storePath := dir + "/keys.trix"
// Init store
cmd, _, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
require.NoError(t, runKeystoreInit(cmd, nil))
// Create session with bad capabilities
cmd, _, _ = newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("store", "s", storePath, "")
cmd.Flags().StringP("caps", "c", "badop:key", "")
cmd.Flags().StringP("ttl", "", "1h", "")
cmd.Flags().StringP("client", "", "", "")
err := runKeyserverSessionCreate(cmd, nil)
assert.Error(t, err)
}
func TestKeyserverSessionCreateInvalidTTL(t *testing.T) {
cmd, _, _ := newTestCmd()
cmd.Flags().StringP("store", "s", "/dummy", "")
cmd.Flags().StringP("caps", "c", "encrypt:key", "")
cmd.Flags().StringP("ttl", "", "notaduration", "")
cmd.Flags().StringP("client", "", "", "")
err := runKeyserverSessionCreate(cmd, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid TTL")
}

262
cmd/trix/keystore.go Normal file
View file

@ -0,0 +1,262 @@
package main
import (
"bufio"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/Snider/Enchantrix/pkg/keystore"
"github.com/spf13/cobra"
)
var (
keystoreCmd = &cobra.Command{
Use: "keystore",
Short: "Manage the encrypted key store",
Long: "Create, import, generate, list, and delete keys in a Trix-based encrypted key store.",
}
keystoreInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize a new key store",
RunE: runKeystoreInit,
}
keystoreImportCmd = &cobra.Command{
Use: "import",
Short: "Import a password as a derived key",
RunE: runKeystoreImport,
}
keystoreGenerateCmd = &cobra.Command{
Use: "generate",
Short: "Generate a new random key",
RunE: runKeystoreGenerate,
}
keystoreListCmd = &cobra.Command{
Use: "list",
Short: "List stored keys (no key material shown)",
RunE: runKeystoreList,
}
keystoreDeleteCmd = &cobra.Command{
Use: "delete",
Short: "Delete a key by ID",
RunE: runKeystoreDelete,
}
)
func init() {
keystoreInitCmd.Flags().StringP("path", "p", "", "Path for the new key store file (required)")
keystoreInitCmd.MarkFlagRequired("path")
keystoreImportCmd.Flags().StringP("path", "p", "", "Key store file path (required)")
keystoreImportCmd.Flags().StringP("label", "l", "", "Label for the imported key (required)")
keystoreImportCmd.Flags().StringP("type", "t", "chacha256", "Key type (chacha256)")
keystoreImportCmd.MarkFlagRequired("path")
keystoreImportCmd.MarkFlagRequired("label")
keystoreGenerateCmd.Flags().StringP("path", "p", "", "Key store file path (required)")
keystoreGenerateCmd.Flags().StringP("label", "l", "", "Label for the generated key (required)")
keystoreGenerateCmd.Flags().StringP("type", "t", "", "Key type: chacha256, x25519, hmac (required)")
keystoreGenerateCmd.MarkFlagRequired("path")
keystoreGenerateCmd.MarkFlagRequired("label")
keystoreGenerateCmd.MarkFlagRequired("type")
keystoreListCmd.Flags().StringP("path", "p", "", "Key store file path (required)")
keystoreListCmd.MarkFlagRequired("path")
keystoreDeleteCmd.Flags().StringP("path", "p", "", "Key store file path (required)")
keystoreDeleteCmd.Flags().StringP("id", "", "", "Key ID to delete (required)")
keystoreDeleteCmd.MarkFlagRequired("path")
keystoreDeleteCmd.MarkFlagRequired("id")
keystoreCmd.AddCommand(keystoreInitCmd, keystoreImportCmd, keystoreGenerateCmd, keystoreListCmd, keystoreDeleteCmd)
rootCmd.AddCommand(keystoreCmd)
}
// stdinReader returns a buffered reader for the command's stdin.
// A single reader must be used for all sequential reads from stdin
// to avoid buffering conflicts with multiple Scanner instances.
func stdinReader(cmd *cobra.Command) *bufio.Reader {
return bufio.NewReader(cmd.InOrStdin())
}
// readLine reads a single line from the reader, trimming trailing newlines.
func readLine(r io.Reader) (string, error) {
br, ok := r.(*bufio.Reader)
if !ok {
br = bufio.NewReader(r)
}
line, err := br.ReadString('\n')
line = strings.TrimRight(line, "\r\n")
if err != nil && line == "" {
return "", err
}
return line, nil
}
// readMasterPassword reads the master password from ENCHANTRIX_MASTER env var
// or from the provided reader. Passwords are never passed via CLI flags to
// avoid leaking into shell history.
func readMasterPassword(cmd *cobra.Command, r *bufio.Reader) (string, error) {
if env := os.Getenv("ENCHANTRIX_MASTER"); env != "" {
return env, nil
}
fmt.Fprint(cmd.ErrOrStderr(), "Master password: ")
pw, err := readLine(r)
if err != nil {
return "", fmt.Errorf("failed to read master password")
}
if pw == "" {
return "", fmt.Errorf("master password cannot be empty")
}
return pw, nil
}
// readPasswordFromStdin reads a password from the provided reader for import operations.
func readPasswordFromStdin(cmd *cobra.Command, r *bufio.Reader) (string, error) {
fmt.Fprint(cmd.ErrOrStderr(), "Password to import: ")
pw, err := readLine(r)
if err != nil {
return "", fmt.Errorf("failed to read password")
}
if pw == "" {
return "", fmt.Errorf("password cannot be empty")
}
return pw, nil
}
// openStore opens an existing keystore, reading master password from env/stdin.
func openStore(cmd *cobra.Command, r *bufio.Reader) (*keystore.Store, error) {
path, _ := cmd.Flags().GetString("path")
master, err := readMasterPassword(cmd, r)
if err != nil {
return nil, err
}
return keystore.Open(path, master)
}
func runKeystoreInit(cmd *cobra.Command, args []string) error {
path, _ := cmd.Flags().GetString("path")
r := stdinReader(cmd)
master, err := readMasterPassword(cmd, r)
if err != nil {
return err
}
store, err := keystore.Create(path, master)
if err != nil {
return err
}
defer store.Close()
fmt.Fprintf(cmd.OutOrStdout(), "Key store created: %s\n", path)
return nil
}
func runKeystoreImport(cmd *cobra.Command, args []string) error {
label, _ := cmd.Flags().GetString("label")
r := stdinReader(cmd)
store, err := openStore(cmd, r)
if err != nil {
return err
}
defer store.Close()
password, err := readPasswordFromStdin(cmd, r)
if err != nil {
return err
}
keyID, err := store.Import(password, label)
if err != nil {
return err
}
if err := store.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Imported key: %s (label: %s)\n", keyID, label)
return nil
}
func runKeystoreGenerate(cmd *cobra.Command, args []string) error {
label, _ := cmd.Flags().GetString("label")
typeName, _ := cmd.Flags().GetString("type")
kt := keystore.KeyType(typeName)
if !kt.IsValid() {
return fmt.Errorf("invalid key type %q (valid: chacha256, x25519, hmac)", typeName)
}
r := stdinReader(cmd)
store, err := openStore(cmd, r)
if err != nil {
return err
}
defer store.Close()
keyID, err := store.Generate(kt, label)
if err != nil {
return err
}
if err := store.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Generated %s key: %s (label: %s)\n", typeName, keyID, label)
return nil
}
func runKeystoreList(cmd *cobra.Command, args []string) error {
r := stdinReader(cmd)
store, err := openStore(cmd, r)
if err != nil {
return err
}
defer store.Close()
entries := store.List()
if len(entries) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No keys stored.")
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tTYPE\tLABEL\tCREATED")
for _, e := range entries {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", e.ID, e.Type, e.Label, e.CreatedAt.Format("2006-01-02 15:04:05"))
}
return w.Flush()
}
func runKeystoreDelete(cmd *cobra.Command, args []string) error {
keyID, _ := cmd.Flags().GetString("id")
r := stdinReader(cmd)
store, err := openStore(cmd, r)
if err != nil {
return err
}
defer store.Close()
if err := store.Delete(keyID); err != nil {
return err
}
if err := store.Save(); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Deleted key: %s\n", keyID)
return nil
}

207
cmd/trix/keystore_test.go Normal file
View file

@ -0,0 +1,207 @@
package main
import (
"bytes"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestCmd() (*cobra.Command, *bytes.Buffer, *bytes.Buffer) {
cmd := &cobra.Command{}
out := new(bytes.Buffer)
errOut := new(bytes.Buffer)
cmd.SetOut(out)
cmd.SetErr(errOut)
return cmd, out, errOut
}
func TestKeystoreInitAndList(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "test.trix")
// Init
cmd, out, _ := newTestCmd()
cmd.SetIn(strings.NewReader("test-master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
err := runKeystoreInit(cmd, nil)
require.NoError(t, err)
assert.Contains(t, out.String(), "Key store created")
// List (empty)
cmd, out, _ = newTestCmd()
cmd.SetIn(strings.NewReader("test-master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
err = runKeystoreList(cmd, nil)
require.NoError(t, err)
assert.Contains(t, out.String(), "No keys stored")
}
func TestKeystoreImportAndList(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "test.trix")
// Init
cmd, _, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
require.NoError(t, runKeystoreInit(cmd, nil))
// Import - stdin provides master password then import password
cmd, out, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\nmy-secret-password\n"))
cmd.Flags().StringP("path", "p", storePath, "")
cmd.Flags().StringP("label", "l", "test-key", "")
cmd.Flags().StringP("type", "t", "chacha256", "")
err := runKeystoreImport(cmd, nil)
require.NoError(t, err)
assert.Contains(t, out.String(), "Imported key")
assert.Contains(t, out.String(), "test-key")
// List (should have 1 key)
cmd, out, _ = newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
err = runKeystoreList(cmd, nil)
require.NoError(t, err)
assert.Contains(t, out.String(), "test-key")
assert.Contains(t, out.String(), "chacha256")
}
func TestKeystoreGenerate(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "test.trix")
// Init
cmd, _, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
require.NoError(t, runKeystoreInit(cmd, nil))
// Generate x25519
cmd, out, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
cmd.Flags().StringP("label", "l", "stmf-server", "")
cmd.Flags().StringP("type", "t", "x25519", "")
err := runKeystoreGenerate(cmd, nil)
require.NoError(t, err)
assert.Contains(t, out.String(), "Generated x25519 key")
assert.Contains(t, out.String(), "stmf-server")
// List
cmd, out, _ = newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
err = runKeystoreList(cmd, nil)
require.NoError(t, err)
assert.Contains(t, out.String(), "stmf-server")
assert.Contains(t, out.String(), "x25519")
}
func TestKeystoreDelete(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "test.trix")
// Init
cmd, _, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
require.NoError(t, runKeystoreInit(cmd, nil))
// Import
cmd, out, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\npassword\n"))
cmd.Flags().StringP("path", "p", storePath, "")
cmd.Flags().StringP("label", "l", "to-delete", "")
cmd.Flags().StringP("type", "t", "chacha256", "")
require.NoError(t, runKeystoreImport(cmd, nil))
// Extract key ID from output
keyID := ""
for _, line := range strings.Split(out.String(), "\n") {
if strings.HasPrefix(line, "Imported key: ") {
parts := strings.SplitN(line, " ", 3)
keyID = strings.TrimSuffix(parts[2], " (label: to-delete)")
break
}
}
require.NotEmpty(t, keyID)
// Delete
cmd, out, _ = newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
cmd.Flags().StringP("id", "", keyID, "")
err := runKeystoreDelete(cmd, nil)
require.NoError(t, err)
assert.Contains(t, out.String(), "Deleted key")
// List (should be empty)
cmd, out, _ = newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
err = runKeystoreList(cmd, nil)
require.NoError(t, err)
assert.Contains(t, out.String(), "No keys stored")
}
func TestKeystoreGenerateInvalidType(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "test.trix")
// Init
cmd, _, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
require.NoError(t, runKeystoreInit(cmd, nil))
// Generate with invalid type
cmd, _, _ = newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
cmd.Flags().StringP("label", "l", "bad", "")
cmd.Flags().StringP("type", "t", "aes512", "")
err := runKeystoreGenerate(cmd, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid key type")
}
func TestKeystoreInitDuplicate(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "test.trix")
// Init first time
cmd, _, _ := newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
require.NoError(t, runKeystoreInit(cmd, nil))
// Init second time — should fail
cmd, _, _ = newTestCmd()
cmd.SetIn(strings.NewReader("master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
err := runKeystoreInit(cmd, nil)
assert.Error(t, err)
}
func TestKeystoreWrongPassword(t *testing.T) {
dir := t.TempDir()
storePath := filepath.Join(dir, "test.trix")
// Init
cmd, _, _ := newTestCmd()
cmd.SetIn(strings.NewReader("correct-master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
require.NoError(t, runKeystoreInit(cmd, nil))
// List with wrong password
cmd, _, _ = newTestCmd()
cmd.SetIn(strings.NewReader("wrong-master\n"))
cmd.Flags().StringP("path", "p", storePath, "")
err := runKeystoreList(cmd, nil)
assert.Error(t, err)
}

143
pkg/keyserver/audit.go Normal file
View file

@ -0,0 +1,143 @@
package keyserver
import (
"encoding/json"
"fmt"
"os"
"sync"
"time"
)
// AuditEvent records a single crypto operation for the audit trail.
type AuditEvent struct {
Timestamp time.Time `json:"ts"`
SessionID string `json:"session_id,omitempty"`
Operation string `json:"op"`
KeyID string `json:"key_id,omitempty"`
Success bool `json:"ok"`
Error string `json:"error,omitempty"`
InputSize int `json:"input_size"`
}
// AuditLogger writes append-only JSONL audit events.
type AuditLogger struct {
file *os.File
mu sync.Mutex
events []AuditEvent // in-memory copy for querying
}
// NewAuditLogger creates or opens an audit log file for append-only writing.
func NewAuditLogger(path string) (*AuditLogger, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return nil, fmt.Errorf("keyserver: open audit log: %w", err)
}
return &AuditLogger{file: f}, nil
}
// NewMemoryAuditLogger creates an in-memory-only audit logger (for testing).
func NewMemoryAuditLogger() *AuditLogger {
return &AuditLogger{}
}
// Log records an audit event. It is always appended — never overwritten or deleted.
func (a *AuditLogger) Log(event AuditEvent) error {
if event.Timestamp.IsZero() {
event.Timestamp = time.Now().UTC()
}
a.mu.Lock()
defer a.mu.Unlock()
a.events = append(a.events, event)
if a.file != nil {
data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("keyserver: marshal audit event: %w", err)
}
data = append(data, '\n')
if _, err := a.file.Write(data); err != nil {
return fmt.Errorf("keyserver: write audit event: %w", err)
}
}
return nil
}
// LogOp is a convenience method for logging a successful operation.
func (a *AuditLogger) LogOp(sessionID, op, keyID string, inputSize int) {
a.Log(AuditEvent{
SessionID: sessionID,
Operation: op,
KeyID: keyID,
Success: true,
InputSize: inputSize,
})
}
// LogError is a convenience method for logging a failed operation.
func (a *AuditLogger) LogError(sessionID, op, keyID string, inputSize int, err error) {
a.Log(AuditEvent{
SessionID: sessionID,
Operation: op,
KeyID: keyID,
Success: false,
Error: err.Error(),
InputSize: inputSize,
})
}
// Query returns audit events matching the given filter criteria.
// All filter fields are optional — zero values are ignored.
type AuditQuery struct {
SessionID string
Operation string
KeyID string
Since time.Time
Until time.Time
}
// Query filters the in-memory audit log by the given criteria.
func (a *AuditLogger) Query(q AuditQuery) []AuditEvent {
a.mu.Lock()
defer a.mu.Unlock()
var results []AuditEvent
for _, e := range a.events {
if q.SessionID != "" && e.SessionID != q.SessionID {
continue
}
if q.Operation != "" && e.Operation != q.Operation {
continue
}
if q.KeyID != "" && e.KeyID != q.KeyID {
continue
}
if !q.Since.IsZero() && e.Timestamp.Before(q.Since) {
continue
}
if !q.Until.IsZero() && e.Timestamp.After(q.Until) {
continue
}
results = append(results, e)
}
return results
}
// All returns all audit events.
func (a *AuditLogger) All() []AuditEvent {
a.mu.Lock()
defer a.mu.Unlock()
result := make([]AuditEvent, len(a.events))
copy(result, a.events)
return result
}
// Close flushes and closes the underlying file.
func (a *AuditLogger) Close() error {
if a.file != nil {
return a.file.Close()
}
return nil
}

162
pkg/keyserver/audit_test.go Normal file
View file

@ -0,0 +1,162 @@
package keyserver
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuditLogMemory(t *testing.T) {
logger := NewMemoryAuditLogger()
logger.LogOp("ses-1", "encrypt", "key-abc", 1024)
logger.LogOp("ses-1", "decrypt", "key-abc", 512)
logger.LogError("ses-2", "encrypt", "key-xyz", 256, fmt.Errorf("permission denied"))
all := logger.All()
assert.Len(t, all, 3)
assert.True(t, all[0].Success)
assert.False(t, all[2].Success)
assert.Equal(t, "permission denied", all[2].Error)
}
func TestAuditLogFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
logger, err := NewAuditLogger(path)
require.NoError(t, err)
logger.LogOp("ses-1", "encrypt", "key-1", 100)
logger.LogOp("ses-1", "decrypt", "key-1", 200)
logger.Close()
// Verify file was written
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(data), `"op":"encrypt"`)
assert.Contains(t, string(data), `"op":"decrypt"`)
// Verify permissions
info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
}
func TestAuditLogAppendOnly(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit.jsonl")
// Write first batch
logger1, _ := NewAuditLogger(path)
logger1.LogOp("ses-1", "encrypt", "key-1", 100)
logger1.Close()
// Reopen and write more
logger2, _ := NewAuditLogger(path)
logger2.LogOp("ses-2", "decrypt", "key-2", 200)
logger2.Close()
// Both entries should be present
data, _ := os.ReadFile(path)
lines := 0
for _, b := range data {
if b == '\n' {
lines++
}
}
assert.Equal(t, 2, lines)
}
func TestAuditQueryBySessionID(t *testing.T) {
logger := NewMemoryAuditLogger()
logger.LogOp("ses-1", "encrypt", "key-1", 100)
logger.LogOp("ses-2", "decrypt", "key-1", 200)
logger.LogOp("ses-1", "sign", "key-2", 300)
results := logger.Query(AuditQuery{SessionID: "ses-1"})
assert.Len(t, results, 2)
}
func TestAuditQueryByOperation(t *testing.T) {
logger := NewMemoryAuditLogger()
logger.LogOp("ses-1", "encrypt", "key-1", 100)
logger.LogOp("ses-1", "decrypt", "key-1", 200)
logger.LogOp("ses-1", "encrypt", "key-2", 300)
results := logger.Query(AuditQuery{Operation: "encrypt"})
assert.Len(t, results, 2)
}
func TestAuditQueryByKeyID(t *testing.T) {
logger := NewMemoryAuditLogger()
logger.LogOp("ses-1", "encrypt", "key-1", 100)
logger.LogOp("ses-1", "decrypt", "key-2", 200)
logger.LogOp("ses-2", "sign", "key-1", 300)
results := logger.Query(AuditQuery{KeyID: "key-1"})
assert.Len(t, results, 2)
}
func TestAuditQueryByTimeRange(t *testing.T) {
logger := NewMemoryAuditLogger()
t1 := time.Now().UTC()
logger.Log(AuditEvent{Timestamp: t1, Operation: "encrypt", KeyID: "key-1", Success: true})
time.Sleep(10 * time.Millisecond)
t2 := time.Now().UTC()
logger.Log(AuditEvent{Timestamp: t2, Operation: "decrypt", KeyID: "key-1", Success: true})
time.Sleep(10 * time.Millisecond)
t3 := time.Now().UTC()
logger.Log(AuditEvent{Timestamp: t3, Operation: "sign", KeyID: "key-1", Success: true})
// Query events between t1 and t2 (exclusive of t3)
results := logger.Query(AuditQuery{
Since: t1.Add(-time.Millisecond),
Until: t2.Add(time.Millisecond),
})
assert.Len(t, results, 2)
}
func TestAuditQueryCombined(t *testing.T) {
logger := NewMemoryAuditLogger()
logger.LogOp("ses-1", "encrypt", "key-1", 100)
logger.LogOp("ses-1", "encrypt", "key-2", 200)
logger.LogOp("ses-2", "encrypt", "key-1", 300)
logger.LogOp("ses-1", "decrypt", "key-1", 400)
results := logger.Query(AuditQuery{SessionID: "ses-1", Operation: "encrypt"})
assert.Len(t, results, 2)
}
func TestAuditEventTimestamp(t *testing.T) {
logger := NewMemoryAuditLogger()
logger.LogOp("ses-1", "encrypt", "key-1", 100)
events := logger.All()
require.Len(t, events, 1)
assert.False(t, events[0].Timestamp.IsZero())
}
func TestAuditInputSizeNotContent(t *testing.T) {
logger := NewMemoryAuditLogger()
logger.LogOp("ses-1", "encrypt", "key-1", 1048576)
events := logger.All()
require.Len(t, events, 1)
assert.Equal(t, 1048576, events[0].InputSize)
// Verify no actual content is stored — only the size
}

View file

@ -0,0 +1,82 @@
package keyserver
import (
"fmt"
"strings"
)
// Capability represents a permission to perform a specific operation on a
// specific key (or all keys with wildcard "*").
//
// Format: "operation:keyID" — e.g. "encrypt:abc123" or "decrypt:*"
type Capability struct {
Operation string // encrypt, decrypt, sign, verify, list, derive, delete
KeyID string // specific key ID or "*" for all
}
// Valid operation names for capability strings.
var validOperations = map[string]bool{
"encrypt": true,
"decrypt": true,
"sign": true,
"verify": true,
"list": true,
"derive": true,
"delete": true,
"generate": true,
"import": true,
"wrap": true,
"unwrap": true,
}
// ParseCapability parses a capability string like "encrypt:key-abc".
func ParseCapability(s string) (Capability, error) {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {
return Capability{}, fmt.Errorf("keyserver: invalid capability format %q (expected op:keyID)", s)
}
op := strings.TrimSpace(parts[0])
keyID := strings.TrimSpace(parts[1])
if !validOperations[op] {
return Capability{}, fmt.Errorf("keyserver: unknown operation %q", op)
}
if keyID == "" {
return Capability{}, fmt.Errorf("keyserver: empty key ID in capability %q", s)
}
return Capability{Operation: op, KeyID: keyID}, nil
}
// ParseCapabilities parses a comma-separated list of capability strings.
func ParseCapabilities(s string) ([]Capability, error) {
parts := strings.Split(s, ",")
caps := make([]Capability, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
cap, err := ParseCapability(p)
if err != nil {
return nil, err
}
caps = append(caps, cap)
}
return caps, nil
}
// Matches reports whether this capability grants permission for the given
// operation on the given key.
func (c Capability) Matches(op, keyID string) bool {
if c.Operation != op {
return false
}
return c.KeyID == "*" || c.KeyID == keyID
}
// String returns the canonical string form "operation:keyID".
func (c Capability) String() string {
return c.Operation + ":" + c.KeyID
}

View file

@ -0,0 +1,87 @@
// Package keyserver provides a Secure Environment (SE) service that performs
// cryptographic operations by key reference. Callers never see raw key material;
// they pass a key ID and the server performs the operation internally.
package keyserver
import (
"context"
"github.com/Snider/Enchantrix/pkg/keystore"
)
// KeyServer defines the interface for a key management and crypto operations
// service. All operations reference keys by ID — raw key material never leaves
// the server boundary.
type KeyServer interface {
// --- Key lifecycle ---
// GenerateKey creates a new random key of the specified type and stores it.
// Returns the key ID for future reference.
GenerateKey(ctx context.Context, keyType keystore.KeyType, label string) (keyID string, err error)
// ImportPassword derives a symmetric key from a password (SHA-256, matching
// Borg's DeriveKey) and stores the derived key. The raw password is never
// persisted.
ImportPassword(ctx context.Context, password string, label string) (keyID string, err error)
// DeleteKey removes a key from the store and zeroes its material.
DeleteKey(ctx context.Context, keyID string) error
// ListKeys returns metadata for all stored keys. KeyData is never included.
ListKeys(ctx context.Context) ([]keystore.Entry, error)
// GetPublicKey returns the public component of an asymmetric key.
// Returns an error for symmetric key types.
GetPublicKey(ctx context.Context, keyID string) ([]byte, error)
// --- Primitive crypto operations ---
// Encrypt encrypts plaintext using the key identified by keyID.
Encrypt(ctx context.Context, keyID string, plaintext []byte) ([]byte, error)
// Decrypt decrypts ciphertext using the key identified by keyID.
Decrypt(ctx context.Context, keyID string, ciphertext []byte) ([]byte, error)
// Sign signs data using the key identified by keyID (HMAC for symmetric keys).
Sign(ctx context.Context, keyID string, data []byte) ([]byte, error)
// Verify verifies a signature against data using the key identified by keyID.
Verify(ctx context.Context, keyID string, data, signature []byte) error
// --- TIM composite operations (Borg calls these) ---
// EncryptTIM encrypts a TIM's config and rootfs atomically with the same key.
// The key never leaves the server boundary.
EncryptTIM(ctx context.Context, keyID string, config []byte, rootfs []byte) ([]byte, error)
// DecryptTIM decrypts a TIM payload back into config and rootfs.
DecryptTIM(ctx context.Context, keyID string, payload []byte) (config []byte, rootfs []byte, err error)
// --- SMSG composite operations ---
// EncryptSMSG encrypts a message payload using the key identified by keyID.
EncryptSMSG(ctx context.Context, keyID string, message []byte) ([]byte, error)
// DecryptSMSG decrypts a message payload using the key identified by keyID.
DecryptSMSG(ctx context.Context, keyID string, ciphertext []byte) ([]byte, error)
// --- Stream key derivation (for SMSG V3) ---
// DeriveStreamKey derives a stream key from license+date+fingerprint and
// stores it temporarily. Returns the key ID.
DeriveStreamKey(ctx context.Context, license, date, fingerprint string) (keyID string, err error)
// WrapCEK wraps a Content Encryption Key with the stream key identified
// by streamKeyID. Returns a base64-encoded wrapped key.
WrapCEK(ctx context.Context, streamKeyID string, cek []byte) (string, error)
// UnwrapCEK unwraps a Content Encryption Key using the stream key.
// Takes base64-encoded wrapped key, returns raw CEK bytes.
UnwrapCEK(ctx context.Context, streamKeyID string, wrapped string) ([]byte, error)
// --- STMF composite operations ---
// DecryptSTMF performs ECDH with the server's private key and the ephemeral
// public key embedded in the STMF payload, then decrypts the form data.
DecryptSTMF(ctx context.Context, keyID string, stmfPayload []byte) ([]byte, error)
}

340
pkg/keyserver/ops.go Normal file
View file

@ -0,0 +1,340 @@
package keyserver
import (
"context"
"crypto/ecdh"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"github.com/Snider/Enchantrix/pkg/crypt"
"github.com/Snider/Enchantrix/pkg/enchantrix"
"github.com/Snider/Enchantrix/pkg/keystore"
"github.com/Snider/Enchantrix/pkg/trix"
)
func generateID() string {
b := make([]byte, 8)
io.ReadFull(rand.Reader, b)
return fmt.Sprintf("%x", b)
}
// --- Primitive crypto operations ---
// Encrypt encrypts plaintext using ChaCha20-Poly1305 with the referenced key.
func (s *Server) Encrypt(ctx context.Context, keyID string, plaintext []byte) ([]byte, error) {
entry, err := s.getKey(keyID)
if err != nil {
return nil, err
}
sigil, err := enchantrix.NewChaChaPolySigil(entry.KeyData)
if err != nil {
return nil, fmt.Errorf("keyserver: encrypt: %w", err)
}
return sigil.In(plaintext)
}
// Decrypt decrypts ciphertext using ChaCha20-Poly1305 with the referenced key.
func (s *Server) Decrypt(ctx context.Context, keyID string, ciphertext []byte) ([]byte, error) {
entry, err := s.getKey(keyID)
if err != nil {
return nil, err
}
sigil, err := enchantrix.NewChaChaPolySigil(entry.KeyData)
if err != nil {
return nil, fmt.Errorf("keyserver: decrypt: %w", err)
}
plaintext, err := sigil.Out(ciphertext)
if err != nil {
return nil, fmt.Errorf("keyserver: decrypt: %w", err)
}
return plaintext, nil
}
// Sign produces an HMAC-SHA256 signature for symmetric keys.
func (s *Server) Sign(ctx context.Context, keyID string, data []byte) ([]byte, error) {
entry, err := s.getKey(keyID)
if err != nil {
return nil, err
}
mac := hmac.New(sha256.New, entry.KeyData)
mac.Write(data)
return mac.Sum(nil), nil
}
// Verify checks an HMAC-SHA256 signature for symmetric keys.
func (s *Server) Verify(ctx context.Context, keyID string, data, signature []byte) error {
expected, err := s.Sign(ctx, keyID, data)
if err != nil {
return err
}
if !hmac.Equal(expected, signature) {
return fmt.Errorf("keyserver: signature verification failed")
}
return nil
}
// --- TIM composite operations ---
// EncryptTIM encrypts config and rootfs separately with the same key,
// matching Borg's TIM payload format:
//
// [4-byte config_size][encrypted_config][encrypted_rootfs]
func (s *Server) EncryptTIM(ctx context.Context, keyID string, config []byte, rootfs []byte) ([]byte, error) {
entry, err := s.getKey(keyID)
if err != nil {
return nil, err
}
sigil, err := enchantrix.NewChaChaPolySigil(entry.KeyData)
if err != nil {
return nil, fmt.Errorf("keyserver: encrypt TIM: %w", err)
}
encConfig, err := sigil.In(config)
if err != nil {
return nil, fmt.Errorf("keyserver: encrypt TIM config: %w", err)
}
encRootFS, err := sigil.In(rootfs)
if err != nil {
return nil, fmt.Errorf("keyserver: encrypt TIM rootfs: %w", err)
}
// Build payload: [config_size(4 bytes)][encrypted_config][encrypted_rootfs]
payload := make([]byte, 4+len(encConfig)+len(encRootFS))
binary.BigEndian.PutUint32(payload[:4], uint32(len(encConfig)))
copy(payload[4:4+len(encConfig)], encConfig)
copy(payload[4+len(encConfig):], encRootFS)
return payload, nil
}
// DecryptTIM decrypts a TIM payload back into config and rootfs.
func (s *Server) DecryptTIM(ctx context.Context, keyID string, payload []byte) ([]byte, []byte, error) {
if len(payload) < 4 {
return nil, nil, fmt.Errorf("keyserver: decrypt TIM: payload too short")
}
entry, err := s.getKey(keyID)
if err != nil {
return nil, nil, err
}
sigil, err := enchantrix.NewChaChaPolySigil(entry.KeyData)
if err != nil {
return nil, nil, fmt.Errorf("keyserver: decrypt TIM: %w", err)
}
configSize := binary.BigEndian.Uint32(payload[:4])
if len(payload) < int(4+configSize) {
return nil, nil, fmt.Errorf("keyserver: decrypt TIM: invalid payload structure")
}
encConfig := payload[4 : 4+configSize]
encRootFS := payload[4+configSize:]
config, err := sigil.Out(encConfig)
if err != nil {
return nil, nil, fmt.Errorf("keyserver: decrypt TIM config: %w", err)
}
rootfs, err := sigil.Out(encRootFS)
if err != nil {
return nil, nil, fmt.Errorf("keyserver: decrypt TIM rootfs: %w", err)
}
return config, rootfs, nil
}
// --- SMSG composite operations ---
// EncryptSMSG encrypts a message payload using ChaCha20-Poly1305.
func (s *Server) EncryptSMSG(ctx context.Context, keyID string, message []byte) ([]byte, error) {
return s.Encrypt(ctx, keyID, message)
}
// DecryptSMSG decrypts a message payload using ChaCha20-Poly1305.
func (s *Server) DecryptSMSG(ctx context.Context, keyID string, ciphertext []byte) ([]byte, error) {
return s.Decrypt(ctx, keyID, ciphertext)
}
// --- Stream key derivation (SMSG V3) ---
// DeriveStreamKey derives a stream key from license+date+fingerprint using
// the LTHN rolling key algorithm (matching Borg's smsg.DeriveStreamKey).
// The derived key is stored and its ID returned.
func (s *Server) DeriveStreamKey(ctx context.Context, license, date, fingerprint string) (string, error) {
input := fmt.Sprintf("%s:%s:%s", date, license, fingerprint)
cryptService := crypt.NewService()
lthnHash := cryptService.Hash(crypt.LTHN, input)
key := sha256.Sum256([]byte(lthnHash))
label := fmt.Sprintf("stream-%s-%s", license, date)
entry := &keystore.Entry{
ID: generateID(),
Type: keystore.ChaCha256,
KeyData: key[:],
Label: label,
Metadata: map[string]string{
"derived_from": "stream",
"kdf": "lthn-sha256",
"license": license,
"date": date,
"fingerprint": fingerprint,
},
}
if err := s.store.PutEntry(entry); err != nil {
return "", fmt.Errorf("keyserver: derive stream key: %w", err)
}
return entry.ID, nil
}
// WrapCEK wraps a Content Encryption Key with the stream key.
func (s *Server) WrapCEK(ctx context.Context, streamKeyID string, cek []byte) (string, error) {
entry, err := s.getKey(streamKeyID)
if err != nil {
return "", err
}
sigil, err := enchantrix.NewChaChaPolySigil(entry.KeyData)
if err != nil {
return "", fmt.Errorf("keyserver: wrap CEK: %w", err)
}
wrapped, err := sigil.In(cek)
if err != nil {
return "", fmt.Errorf("keyserver: wrap CEK: %w", err)
}
return base64.StdEncoding.EncodeToString(wrapped), nil
}
// UnwrapCEK unwraps a Content Encryption Key using the stream key.
func (s *Server) UnwrapCEK(ctx context.Context, streamKeyID string, wrapped string) ([]byte, error) {
entry, err := s.getKey(streamKeyID)
if err != nil {
return nil, err
}
wrappedBytes, err := base64.StdEncoding.DecodeString(wrapped)
if err != nil {
return nil, fmt.Errorf("keyserver: unwrap CEK: invalid base64: %w", err)
}
sigil, err := enchantrix.NewChaChaPolySigil(entry.KeyData)
if err != nil {
return nil, fmt.Errorf("keyserver: unwrap CEK: %w", err)
}
cek, err := sigil.Out(wrappedBytes)
if err != nil {
return nil, fmt.Errorf("keyserver: unwrap CEK: decryption failed")
}
return cek, nil
}
// --- STMF operations ---
// getPublicKey extracts the public key from an X25519 keypair entry.
func (s *Server) getPublicKey(keyID string) ([]byte, error) {
entry, err := s.getKey(keyID)
if err != nil {
return nil, err
}
if entry.Type != keystore.X25519 {
return nil, fmt.Errorf("keyserver: GetPublicKey: key %s is %s, not X25519", keyID, entry.Type)
}
// Derive public key from private key
privKey, err := ecdh.X25519().NewPrivateKey(entry.KeyData)
if err != nil {
return nil, fmt.Errorf("keyserver: GetPublicKey: %w", err)
}
return privKey.PublicKey().Bytes(), nil
}
// DecryptSTMF performs ECDH with the server's X25519 private key and the
// ephemeral public key from the STMF header, derives the symmetric key,
// and decrypts the form data. The private key never leaves the server.
func (s *Server) DecryptSTMF(ctx context.Context, keyID string, stmfPayload []byte) ([]byte, error) {
entry, err := s.getKey(keyID)
if err != nil {
return nil, err
}
if entry.Type != keystore.X25519 {
return nil, fmt.Errorf("keyserver: DecryptSTMF: key %s is %s, not X25519", keyID, entry.Type)
}
// Load server's private key
serverPriv, err := ecdh.X25519().NewPrivateKey(entry.KeyData)
if err != nil {
return nil, fmt.Errorf("keyserver: DecryptSTMF: invalid private key: %w", err)
}
// Decode STMF Trix container
t, err := trix.Decode(stmfPayload, "STMF", nil)
if err != nil {
return nil, fmt.Errorf("keyserver: DecryptSTMF: invalid STMF container: %w", err)
}
// Extract ephemeral public key from header
ephemeralPKBase64, ok := t.Header["ephemeral_pk"].(string)
if !ok {
return nil, fmt.Errorf("keyserver: DecryptSTMF: missing ephemeral_pk in header")
}
ephemeralPKBytes, err := base64.StdEncoding.DecodeString(ephemeralPKBase64)
if err != nil {
return nil, fmt.Errorf("keyserver: DecryptSTMF: invalid ephemeral_pk base64: %w", err)
}
ephemeralPub, err := ecdh.X25519().NewPublicKey(ephemeralPKBytes)
if err != nil {
return nil, fmt.Errorf("keyserver: DecryptSTMF: invalid ephemeral public key: %w", err)
}
// ECDH: server_private × ephemeral_public = shared_secret
sharedSecret, err := serverPriv.ECDH(ephemeralPub)
if err != nil {
return nil, fmt.Errorf("keyserver: DecryptSTMF: ECDH failed: %w", err)
}
// Derive symmetric key
symmetricKey := sha256.Sum256(sharedSecret)
// Decrypt
sigil, err := enchantrix.NewChaChaPolySigil(symmetricKey[:])
if err != nil {
return nil, fmt.Errorf("keyserver: DecryptSTMF: %w", err)
}
plaintext, err := sigil.Out(t.Payload)
if err != nil {
return nil, fmt.Errorf("keyserver: DecryptSTMF: decryption failed")
}
// Verify it's valid JSON (form data)
var check json.RawMessage
if err := json.Unmarshal(plaintext, &check); err != nil {
return nil, fmt.Errorf("keyserver: DecryptSTMF: invalid form data JSON: %w", err)
}
return plaintext, nil
}

87
pkg/keyserver/server.go Normal file
View file

@ -0,0 +1,87 @@
package keyserver
import (
"context"
"fmt"
"sync"
"github.com/Snider/Enchantrix/pkg/keystore"
)
// Server is an in-process KeyServer backed by a keystore.Store.
// It holds the store open and performs all crypto operations internally —
// callers only see key IDs and encrypted results.
type Server struct {
store *keystore.Store
mu sync.RWMutex
}
// NewServer creates a new keyserver backed by the given store.
func NewServer(store *keystore.Store) *Server {
return &Server{store: store}
}
// GenerateKey creates a new random key of the specified type.
func (s *Server) GenerateKey(ctx context.Context, keyType keystore.KeyType, label string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
id, err := s.store.Generate(keyType, label)
if err != nil {
return "", fmt.Errorf("keyserver: generate key: %w", err)
}
return id, nil
}
// ImportPassword derives a key from a password and stores it.
func (s *Server) ImportPassword(ctx context.Context, password string, label string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
id, err := s.store.Import(password, label)
if err != nil {
return "", fmt.Errorf("keyserver: import password: %w", err)
}
return id, nil
}
// DeleteKey removes a key from the store.
func (s *Server) DeleteKey(ctx context.Context, keyID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if err := s.store.Delete(keyID); err != nil {
return fmt.Errorf("keyserver: delete key: %w", err)
}
return nil
}
// ListKeys returns metadata for all keys (no key material).
func (s *Server) ListKeys(ctx context.Context) ([]keystore.Entry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.store.List(), nil
}
// GetPublicKey returns the public component of an asymmetric key.
func (s *Server) GetPublicKey(ctx context.Context, keyID string) ([]byte, error) {
return s.getPublicKey(keyID)
}
// getKey retrieves the raw key material for internal use. Never exposed externally.
func (s *Server) getKey(keyID string) (*keystore.Entry, error) {
entry, err := s.store.Get(keyID)
if err != nil {
return nil, fmt.Errorf("keyserver: %w", err)
}
return entry, nil
}
// Store returns the underlying keystore for direct access (e.g. for Save).
func (s *Server) Store() *keystore.Store {
return s.store
}

View file

@ -0,0 +1,310 @@
package keyserver
import (
"context"
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"path/filepath"
"testing"
"github.com/Snider/Enchantrix/pkg/enchantrix"
"github.com/Snider/Enchantrix/pkg/keystore"
"github.com/Snider/Enchantrix/pkg/trix"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
store, err := keystore.Create(path, "test-master")
require.NoError(t, err)
t.Cleanup(func() { store.Close() })
return NewServer(store)
}
func TestGenerateKeyAndList(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
id, err := srv.GenerateKey(ctx, keystore.ChaCha256, "test-key")
require.NoError(t, err)
assert.NotEmpty(t, id)
keys, err := srv.ListKeys(ctx)
require.NoError(t, err)
assert.Len(t, keys, 1)
assert.Equal(t, "test-key", keys[0].Label)
assert.Nil(t, keys[0].KeyData, "ListKeys must not expose key data")
}
func TestImportPasswordAndDelete(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
id, err := srv.ImportPassword(ctx, "my-password", "imported")
require.NoError(t, err)
keys, _ := srv.ListKeys(ctx)
assert.Len(t, keys, 1)
err = srv.DeleteKey(ctx, id)
require.NoError(t, err)
keys, _ = srv.ListKeys(ctx)
assert.Len(t, keys, 0)
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
id, err := srv.GenerateKey(ctx, keystore.ChaCha256, "enc-key")
require.NoError(t, err)
plaintext := []byte("Hello, Secure Environment!")
ciphertext, err := srv.Encrypt(ctx, id, plaintext)
require.NoError(t, err)
assert.NotEqual(t, plaintext, ciphertext)
decrypted, err := srv.Decrypt(ctx, id, ciphertext)
require.NoError(t, err)
assert.Equal(t, plaintext, decrypted)
}
func TestSignVerify(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
id, err := srv.GenerateKey(ctx, keystore.HMAC, "sign-key")
require.NoError(t, err)
data := []byte("sign this data")
sig, err := srv.Sign(ctx, id, data)
require.NoError(t, err)
assert.Len(t, sig, 32) // HMAC-SHA256
err = srv.Verify(ctx, id, data, sig)
assert.NoError(t, err)
// Tampered data should fail
err = srv.Verify(ctx, id, []byte("tampered"), sig)
assert.Error(t, err)
}
func TestTIMRoundTrip(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
id, err := srv.ImportPassword(ctx, "tim-password", "tim-key")
require.NoError(t, err)
config := []byte(`{"ociVersion":"1.0.2"}`)
rootfs := []byte("rootfs-tar-data-here")
// Encrypt
payload, err := srv.EncryptTIM(ctx, id, config, rootfs)
require.NoError(t, err)
assert.NotEmpty(t, payload)
// Decrypt
decConfig, decRootFS, err := srv.DecryptTIM(ctx, id, payload)
require.NoError(t, err)
assert.Equal(t, config, decConfig)
assert.Equal(t, rootfs, decRootFS)
}
func TestTIMRoundTripMatchesBorgFormat(t *testing.T) {
// Verify our TIM payload format matches Borg's:
// [4-byte config_size][encrypted_config][encrypted_rootfs]
ctx := context.Background()
srv := newTestServer(t)
id, err := srv.ImportPassword(ctx, "test-pass", "borg-compat")
require.NoError(t, err)
config := []byte(`{"test":"config"}`)
rootfs := []byte("rootfs-data")
payload, err := srv.EncryptTIM(ctx, id, config, rootfs)
require.NoError(t, err)
// Verify we can also decrypt with raw Enchantrix sigil using the same key
key := sha256.Sum256([]byte("test-pass"))
sigil, err := enchantrix.NewChaChaPolySigil(key[:])
require.NoError(t, err)
// Parse payload structure
require.True(t, len(payload) >= 4)
configSize := uint32(payload[0])<<24 | uint32(payload[1])<<16 | uint32(payload[2])<<8 | uint32(payload[3])
encConfig := payload[4 : 4+configSize]
encRootFS := payload[4+configSize:]
decConfig, err := sigil.Out(encConfig)
require.NoError(t, err)
assert.Equal(t, config, decConfig)
decRootFS, err := sigil.Out(encRootFS)
require.NoError(t, err)
assert.Equal(t, rootfs, decRootFS)
}
func TestSMSGRoundTrip(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
id, err := srv.ImportPassword(ctx, "smsg-password", "smsg-key")
require.NoError(t, err)
message := []byte(`{"body":"Hello, encrypted world!","timestamp":1234567890}`)
enc, err := srv.EncryptSMSG(ctx, id, message)
require.NoError(t, err)
dec, err := srv.DecryptSMSG(ctx, id, enc)
require.NoError(t, err)
assert.Equal(t, message, dec)
}
func TestStreamKeyDerivation(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
keyID, err := srv.DeriveStreamKey(ctx, "license-123", "2025-01-15", "device-fp")
require.NoError(t, err)
assert.NotEmpty(t, keyID)
// Generate a CEK and wrap/unwrap it
cek := make([]byte, 32)
rand.Read(cek)
wrapped, err := srv.WrapCEK(ctx, keyID, cek)
require.NoError(t, err)
assert.NotEmpty(t, wrapped)
unwrapped, err := srv.UnwrapCEK(ctx, keyID, wrapped)
require.NoError(t, err)
assert.Equal(t, cek, unwrapped)
}
func TestGetPublicKey(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
// Generate X25519 key
id, err := srv.GenerateKey(ctx, keystore.X25519, "x25519-key")
require.NoError(t, err)
pubKey, err := srv.GetPublicKey(ctx, id)
require.NoError(t, err)
assert.Len(t, pubKey, 32)
// Symmetric key should fail
symID, err := srv.GenerateKey(ctx, keystore.ChaCha256, "sym-key")
require.NoError(t, err)
_, err = srv.GetPublicKey(ctx, symID)
assert.Error(t, err)
}
func TestDecryptSTMF(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
// Generate server keypair via keyserver
keyID, err := srv.GenerateKey(ctx, keystore.X25519, "stmf-server")
require.NoError(t, err)
serverPubKey, err := srv.GetPublicKey(ctx, keyID)
require.NoError(t, err)
// Client-side: encrypt form data using server's public key
serverPub, err := ecdh.X25519().NewPublicKey(serverPubKey)
require.NoError(t, err)
ephemeralPriv, err := ecdh.X25519().GenerateKey(rand.Reader)
require.NoError(t, err)
sharedSecret, err := ephemeralPriv.ECDH(serverPub)
require.NoError(t, err)
symmetricKey := sha256.Sum256(sharedSecret)
sigil, err := enchantrix.NewChaChaPolySigil(symmetricKey[:])
require.NoError(t, err)
formData := map[string]interface{}{
"fields": []map[string]string{
{"name": "username", "value": "alice", "type": "text"},
{"name": "password", "value": "secret123", "type": "password"},
},
}
payload, err := json.Marshal(formData)
require.NoError(t, err)
encrypted, err := sigil.In(payload)
require.NoError(t, err)
// Build STMF container
header := map[string]interface{}{
"version": "1.0",
"algorithm": "x25519-chacha20poly1305",
"ephemeral_pk": base64.StdEncoding.EncodeToString(ephemeralPriv.PublicKey().Bytes()),
}
container := &trix.Trix{
Header: header,
Payload: encrypted,
}
stmfData, err := trix.Encode(container, "STMF", nil)
require.NoError(t, err)
// Server-side: decrypt via keyserver (private key never leaves)
decrypted, err := srv.DecryptSTMF(ctx, keyID, stmfData)
require.NoError(t, err)
var result map[string]interface{}
err = json.Unmarshal(decrypted, &result)
require.NoError(t, err)
fields := result["fields"].([]interface{})
first := fields[0].(map[string]interface{})
assert.Equal(t, "username", first["name"])
assert.Equal(t, "alice", first["value"])
}
func TestDecryptWrongKey(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
id1, _ := srv.GenerateKey(ctx, keystore.ChaCha256, "key1")
id2, _ := srv.GenerateKey(ctx, keystore.ChaCha256, "key2")
enc, err := srv.Encrypt(ctx, id1, []byte("secret"))
require.NoError(t, err)
_, err = srv.Decrypt(ctx, id2, enc)
assert.Error(t, err)
}
func TestDeleteNonExistent(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
err := srv.DeleteKey(ctx, "nonexistent")
assert.Error(t, err)
}
func TestEncryptWithNonExistentKey(t *testing.T) {
ctx := context.Background()
srv := newTestServer(t)
_, err := srv.Encrypt(ctx, "nonexistent", []byte("data"))
assert.Error(t, err)
}

153
pkg/keyserver/session.go Normal file
View file

@ -0,0 +1,153 @@
package keyserver
import (
"context"
"crypto/rand"
"fmt"
"io"
"sync"
"time"
)
// Session represents an authenticated session with specific capabilities.
// Agents receive a session token that limits what operations they can perform
// and on which keys.
type Session struct {
ID string `json:"id"`
Capabilities []Capability `json:"capabilities"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
ClientID string `json:"client_id"`
revoked bool
}
// IsExpired reports whether the session has passed its TTL.
func (s *Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
}
// HasCapability checks whether this session grants the given operation on the given key.
func (s *Session) HasCapability(op, keyID string) bool {
for _, cap := range s.Capabilities {
if cap.Matches(op, keyID) {
return true
}
}
return false
}
// SessionManager manages session lifecycle: create, validate, revoke.
type SessionManager struct {
sessions map[string]*Session
mu sync.RWMutex
}
// NewSessionManager creates a new session manager.
func NewSessionManager() *SessionManager {
return &SessionManager{
sessions: make(map[string]*Session),
}
}
// CreateSession creates a new session with the given capabilities and TTL.
func (m *SessionManager) CreateSession(ctx context.Context, clientID string, caps []Capability, ttl time.Duration) (*Session, error) {
if len(caps) == 0 {
return nil, fmt.Errorf("keyserver: session requires at least one capability")
}
if ttl <= 0 {
return nil, fmt.Errorf("keyserver: session TTL must be positive")
}
id, err := generateSessionID()
if err != nil {
return nil, err
}
now := time.Now()
session := &Session{
ID: id,
Capabilities: caps,
CreatedAt: now,
ExpiresAt: now.Add(ttl),
ClientID: clientID,
}
m.mu.Lock()
m.sessions[id] = session
m.mu.Unlock()
return session, nil
}
// ValidateSession checks that a session exists, is not expired, is not revoked,
// and has the capability for the requested operation on the requested key.
func (m *SessionManager) ValidateSession(ctx context.Context, sessionID string, op string, keyID string) error {
m.mu.RLock()
session, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
return fmt.Errorf("keyserver: session not found: %s", sessionID)
}
if session.revoked {
return fmt.Errorf("keyserver: session revoked: %s", sessionID)
}
if session.IsExpired() {
return fmt.Errorf("keyserver: session expired: %s", sessionID)
}
if !session.HasCapability(op, keyID) {
return fmt.Errorf("keyserver: session %s does not have %s capability for key %s", sessionID, op, keyID)
}
return nil
}
// RevokeSession immediately invalidates a session.
func (m *SessionManager) RevokeSession(ctx context.Context, sessionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
session, ok := m.sessions[sessionID]
if !ok {
return fmt.Errorf("keyserver: session not found: %s", sessionID)
}
session.revoked = true
return nil
}
// GetSession returns session metadata (for inspection/auditing).
func (m *SessionManager) GetSession(ctx context.Context, sessionID string) (*Session, error) {
m.mu.RLock()
defer m.mu.RUnlock()
session, ok := m.sessions[sessionID]
if !ok {
return nil, fmt.Errorf("keyserver: session not found: %s", sessionID)
}
return session, nil
}
// CleanExpired removes expired sessions from memory.
func (m *SessionManager) CleanExpired() int {
m.mu.Lock()
defer m.mu.Unlock()
count := 0
for id, session := range m.sessions {
if session.IsExpired() {
delete(m.sessions, id)
count++
}
}
return count
}
func generateSessionID() (string, error) {
b := make([]byte, 16)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", fmt.Errorf("keyserver: failed to generate session ID: %w", err)
}
return fmt.Sprintf("ses_%x", b), nil
}

View file

@ -0,0 +1,208 @@
package keyserver
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateSession(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
caps := []Capability{
{Operation: "encrypt", KeyID: "key-abc"},
{Operation: "decrypt", KeyID: "key-abc"},
}
session, err := mgr.CreateSession(ctx, "agent-1", caps, time.Hour)
require.NoError(t, err)
assert.NotEmpty(t, session.ID)
assert.Equal(t, "agent-1", session.ClientID)
assert.Len(t, session.Capabilities, 2)
assert.False(t, session.IsExpired())
}
func TestCreateSessionNoCaps(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
_, err := mgr.CreateSession(ctx, "agent", nil, time.Hour)
assert.Error(t, err)
}
func TestCreateSessionZeroTTL(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
caps := []Capability{{Operation: "encrypt", KeyID: "*"}}
_, err := mgr.CreateSession(ctx, "agent", caps, 0)
assert.Error(t, err)
}
func TestValidateSession(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
caps := []Capability{
{Operation: "encrypt", KeyID: "key-1"},
}
session, _ := mgr.CreateSession(ctx, "agent", caps, time.Hour)
// Should succeed: has encrypt:key-1
err := mgr.ValidateSession(ctx, session.ID, "encrypt", "key-1")
assert.NoError(t, err)
// Should fail: does not have decrypt capability
err = mgr.ValidateSession(ctx, session.ID, "decrypt", "key-1")
assert.Error(t, err)
// Should fail: wrong key
err = mgr.ValidateSession(ctx, session.ID, "encrypt", "key-2")
assert.Error(t, err)
}
func TestWildcardCapability(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
caps := []Capability{
{Operation: "decrypt", KeyID: "*"},
}
session, _ := mgr.CreateSession(ctx, "agent", caps, time.Hour)
// Wildcard should match any key
assert.NoError(t, mgr.ValidateSession(ctx, session.ID, "decrypt", "key-1"))
assert.NoError(t, mgr.ValidateSession(ctx, session.ID, "decrypt", "key-2"))
assert.NoError(t, mgr.ValidateSession(ctx, session.ID, "decrypt", "any-key"))
// But not other operations
assert.Error(t, mgr.ValidateSession(ctx, session.ID, "encrypt", "key-1"))
}
func TestExpiredSession(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
caps := []Capability{{Operation: "encrypt", KeyID: "*"}}
// Create session with 1ms TTL
session, _ := mgr.CreateSession(ctx, "agent", caps, time.Millisecond)
time.Sleep(5 * time.Millisecond)
err := mgr.ValidateSession(ctx, session.ID, "encrypt", "key-1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "expired")
}
func TestRevokedSession(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
caps := []Capability{{Operation: "encrypt", KeyID: "*"}}
session, _ := mgr.CreateSession(ctx, "agent", caps, time.Hour)
// Works before revocation
assert.NoError(t, mgr.ValidateSession(ctx, session.ID, "encrypt", "key-1"))
// Revoke
err := mgr.RevokeSession(ctx, session.ID)
require.NoError(t, err)
// Fails immediately after revocation
err = mgr.ValidateSession(ctx, session.ID, "encrypt", "key-1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "revoked")
}
func TestRevokeNonExistent(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
err := mgr.RevokeSession(ctx, "nonexistent")
assert.Error(t, err)
}
func TestValidateNonExistent(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
err := mgr.ValidateSession(ctx, "nonexistent", "encrypt", "key-1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestGetSession(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
caps := []Capability{{Operation: "encrypt", KeyID: "key-1"}}
session, _ := mgr.CreateSession(ctx, "agent-x", caps, time.Hour)
got, err := mgr.GetSession(ctx, session.ID)
require.NoError(t, err)
assert.Equal(t, session.ID, got.ID)
assert.Equal(t, "agent-x", got.ClientID)
}
func TestCleanExpired(t *testing.T) {
ctx := context.Background()
mgr := NewSessionManager()
caps := []Capability{{Operation: "encrypt", KeyID: "*"}}
// Create one short-lived and one long-lived session
mgr.CreateSession(ctx, "short", caps, time.Millisecond)
mgr.CreateSession(ctx, "long", caps, time.Hour)
time.Sleep(5 * time.Millisecond)
cleaned := mgr.CleanExpired()
assert.Equal(t, 1, cleaned)
}
func TestParseCapability(t *testing.T) {
tests := []struct {
input string
want Capability
err bool
}{
{"encrypt:key-abc", Capability{"encrypt", "key-abc"}, false},
{"decrypt:*", Capability{"decrypt", "*"}, false},
{"sign:key-123", Capability{"sign", "key-123"}, false},
{"bogus:key", Capability{}, true},
{"encrypt", Capability{}, true},
{"encrypt:", Capability{}, true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got, err := ParseCapability(tt.input)
if tt.err {
assert.Error(t, err)
} else {
require.NoError(t, err)
assert.Equal(t, tt.want, got)
}
})
}
}
func TestParseCapabilities(t *testing.T) {
caps, err := ParseCapabilities("encrypt:key-1, decrypt:key-1, list:*")
require.NoError(t, err)
assert.Len(t, caps, 3)
assert.Equal(t, "encrypt", caps[0].Operation)
assert.Equal(t, "key-1", caps[0].KeyID)
assert.Equal(t, "*", caps[2].KeyID)
}
func TestCapabilityString(t *testing.T) {
c := Capability{Operation: "encrypt", KeyID: "key-abc"}
assert.Equal(t, "encrypt:key-abc", c.String())
}

62
pkg/keystore/argon2.go Normal file
View file

@ -0,0 +1,62 @@
package keystore
import (
"crypto/rand"
"io"
"golang.org/x/crypto/argon2"
)
const (
// Default Argon2id parameters
defaultArgon2Time = 1
defaultArgon2Memory = 64 * 1024 // 64 MB
defaultArgon2Threads = 4
defaultArgon2KeyLen = 32
saltSize = 16
)
// Argon2Params holds the parameters for Argon2id key derivation.
type Argon2Params struct {
Time uint32 `json:"time"`
Memory uint32 `json:"memory"`
Threads uint8 `json:"threads"`
KeyLen uint32 `json:"key_len"`
}
// DefaultArgon2Params returns the default Argon2id parameters.
func DefaultArgon2Params() *Argon2Params {
return &Argon2Params{
Time: defaultArgon2Time,
Memory: defaultArgon2Memory,
Threads: defaultArgon2Threads,
KeyLen: defaultArgon2KeyLen,
}
}
// DeriveKey derives a cryptographic key from a password and salt using Argon2id.
// If salt is nil, a random 16-byte salt is generated.
// Returns the derived key and the salt used.
func DeriveKey(password string, salt []byte, params *Argon2Params) (key []byte, usedSalt []byte, err error) {
if params == nil {
params = DefaultArgon2Params()
}
if salt == nil {
salt = make([]byte, saltSize)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return nil, nil, err
}
}
key = argon2.IDKey(
[]byte(password),
salt,
params.Time,
params.Memory,
params.Threads,
params.KeyLen,
)
return key, salt, nil
}

View file

@ -0,0 +1,80 @@
package keystore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDeriveKey(t *testing.T) {
t.Run("generates salt when nil", func(t *testing.T) {
key, salt, err := DeriveKey("password", nil, nil)
require.NoError(t, err)
assert.Len(t, key, 32)
assert.Len(t, salt, 16)
})
t.Run("uses provided salt", func(t *testing.T) {
salt := make([]byte, 16)
for i := range salt {
salt[i] = byte(i)
}
key1, usedSalt, err := DeriveKey("password", salt, nil)
require.NoError(t, err)
assert.Equal(t, salt, usedSalt)
// Same password + salt = same key
key2, _, err := DeriveKey("password", salt, nil)
require.NoError(t, err)
assert.Equal(t, key1, key2)
})
t.Run("different passwords produce different keys", func(t *testing.T) {
salt := make([]byte, 16)
key1, _, err := DeriveKey("password1", salt, nil)
require.NoError(t, err)
key2, _, err := DeriveKey("password2", salt, nil)
require.NoError(t, err)
assert.NotEqual(t, key1, key2)
})
t.Run("different salts produce different keys", func(t *testing.T) {
salt1 := make([]byte, 16)
salt2 := make([]byte, 16)
salt2[0] = 1
key1, _, err := DeriveKey("password", salt1, nil)
require.NoError(t, err)
key2, _, err := DeriveKey("password", salt2, nil)
require.NoError(t, err)
assert.NotEqual(t, key1, key2)
})
t.Run("custom params", func(t *testing.T) {
params := &Argon2Params{
Time: 2,
Memory: 32 * 1024,
Threads: 2,
KeyLen: 32,
}
key, _, err := DeriveKey("password", nil, params)
require.NoError(t, err)
assert.Len(t, key, 32)
})
}
func TestDefaultArgon2Params(t *testing.T) {
params := DefaultArgon2Params()
assert.Equal(t, uint32(1), params.Time)
assert.Equal(t, uint32(64*1024), params.Memory)
assert.Equal(t, uint8(4), params.Threads)
assert.Equal(t, uint32(32), params.KeyLen)
}

54
pkg/keystore/entry.go Normal file
View file

@ -0,0 +1,54 @@
package keystore
import "time"
// Entry represents a single key stored in the key store.
// KeyData is only populated when accessed through the Store; it is
// never included in List() results to prevent accidental exposure.
type Entry struct {
// ID is the unique identifier for this key entry.
ID string `json:"id"`
// Type identifies the cryptographic algorithm this key is for.
Type KeyType `json:"type"`
// KeyData holds the raw key material. This field is zeroed on Delete
// and omitted from List() responses.
KeyData []byte `json:"key_data,omitempty"`
// Label is a human-readable name for the key.
Label string `json:"label"`
// CreatedAt records when the key was added to the store.
CreatedAt time.Time `json:"created_at"`
// Metadata holds optional key-value pairs (e.g. algorithm params, origin).
Metadata map[string]string `json:"metadata,omitempty"`
}
// Clone returns a deep copy of the entry, including key data.
func (e *Entry) Clone() *Entry {
clone := &Entry{
ID: e.ID,
Type: e.Type,
Label: e.Label,
CreatedAt: e.CreatedAt,
}
if e.KeyData != nil {
clone.KeyData = make([]byte, len(e.KeyData))
copy(clone.KeyData, e.KeyData)
}
if e.Metadata != nil {
clone.Metadata = make(map[string]string, len(e.Metadata))
for k, v := range e.Metadata {
clone.Metadata[k] = v
}
}
return clone
}
// Redacted returns a copy with KeyData removed, suitable for listing.
func (e *Entry) Redacted() Entry {
return Entry{
ID: e.ID,
Type: e.Type,
Label: e.Label,
CreatedAt: e.CreatedAt,
Metadata: e.Metadata,
}
}

433
pkg/keystore/store.go Normal file
View file

@ -0,0 +1,433 @@
package keystore
import (
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"os"
"sync"
"time"
"github.com/Snider/Enchantrix/pkg/enchantrix"
"github.com/Snider/Enchantrix/pkg/trix"
)
const storeMagic = "KEYS"
// storeData is the serialized form persisted inside the Trix container.
type storeData struct {
Salt []byte `json:"salt"`
Argon2Params *Argon2Params `json:"argon2_params"`
Entries []*Entry `json:"entries"`
}
// Store is an encrypted key store backed by a Trix container file.
// All key entries are encrypted at rest using a master key derived from
// the master password via Argon2id.
type Store struct {
path string
master []byte // Argon2id(masterPassword, salt)
salt []byte
params *Argon2Params
entries map[string]*Entry
mu sync.RWMutex
closed bool
}
// Create initializes a new key store at the given path with the provided
// master password. The file must not already exist.
func Create(path string, masterPassword string) (*Store, error) {
if _, err := os.Stat(path); err == nil {
return nil, fmt.Errorf("keystore: file already exists: %s", path)
}
params := DefaultArgon2Params()
master, salt, err := DeriveKey(masterPassword, nil, params)
if err != nil {
return nil, err
}
s := &Store{
path: path,
master: master,
salt: salt,
params: params,
entries: make(map[string]*Entry),
}
if err := s.Save(); err != nil {
return nil, err
}
return s, nil
}
// Open loads an existing key store from disk and decrypts it with the
// master password.
func Open(path string, masterPassword string) (*Store, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("keystore: %w", err)
}
// Decode the outer Trix container (unencrypted envelope)
container, err := trix.Decode(data, storeMagic, nil)
if err != nil {
return nil, fmt.Errorf("keystore: invalid store file: %w", err)
}
// Extract salt and params from the Trix header
saltHex, _ := container.Header["salt"].(string)
paramsJSON, _ := container.Header["argon2_params"].(string)
var salt []byte
if err := json.Unmarshal([]byte(`"`+saltHex+`"`), &salt); err != nil {
// Try direct approach - header stores as base64 via JSON
return nil, fmt.Errorf("keystore: invalid salt in header: %w", err)
}
var params Argon2Params
if err := json.Unmarshal([]byte(paramsJSON), &params); err != nil {
return nil, fmt.Errorf("keystore: invalid argon2 params: %w", err)
}
// Derive master key
master, _, err := DeriveKey(masterPassword, salt, &params)
if err != nil {
return nil, err
}
// Decrypt the payload
sigil, err := enchantrix.NewChaChaPolySigil(master)
if err != nil {
return nil, err
}
plaintext, err := sigil.Out(container.Payload)
if err != nil {
return nil, ErrDecryptionFailed
}
// Deserialize entries
var entries []*Entry
if err := json.Unmarshal(plaintext, &entries); err != nil {
return nil, ErrDecryptionFailed
}
entryMap := make(map[string]*Entry, len(entries))
for _, e := range entries {
entryMap[e.ID] = e
}
return &Store{
path: path,
master: master,
salt: salt,
params: &params,
entries: entryMap,
}, nil
}
// Put adds or replaces a key entry in the store. The key type and data size
// are validated before storage.
func (s *Store) Put(id string, keyType KeyType, raw []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return ErrStoreClosed
}
if !keyType.IsValid() {
return ErrInvalidKeyType
}
if expectedSize := keyType.KeySize(); expectedSize > 0 && len(raw) != expectedSize {
return fmt.Errorf("%w: expected %d bytes for %s, got %d", ErrInvalidKeySize, expectedSize, keyType, len(raw))
}
if _, exists := s.entries[id]; exists {
return fmt.Errorf("%w: %s", ErrDuplicateKey, id)
}
keyData := make([]byte, len(raw))
copy(keyData, raw)
s.entries[id] = &Entry{
ID: id,
Type: keyType,
KeyData: keyData,
CreatedAt: time.Now().UTC(),
}
return nil
}
// PutEntry adds an entry with full metadata. If Label or Metadata are needed,
// use this instead of Put.
func (s *Store) PutEntry(entry *Entry) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return ErrStoreClosed
}
if !entry.Type.IsValid() {
return ErrInvalidKeyType
}
if expectedSize := entry.Type.KeySize(); expectedSize > 0 && len(entry.KeyData) != expectedSize {
return fmt.Errorf("%w: expected %d bytes for %s, got %d", ErrInvalidKeySize, expectedSize, entry.Type, len(entry.KeyData))
}
if _, exists := s.entries[entry.ID]; exists {
return fmt.Errorf("%w: %s", ErrDuplicateKey, entry.ID)
}
s.entries[entry.ID] = entry.Clone()
if s.entries[entry.ID].CreatedAt.IsZero() {
s.entries[entry.ID].CreatedAt = time.Now().UTC()
}
return nil
}
// Get retrieves a key entry by ID. Returns a clone to prevent mutation
// of the internal state.
func (s *Store) Get(id string) (*Entry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
return nil, ErrStoreClosed
}
entry, ok := s.entries[id]
if !ok {
return nil, fmt.Errorf("%w: %s", ErrKeyNotFound, id)
}
return entry.Clone(), nil
}
// Delete removes a key entry from the store and zeroes its key material.
func (s *Store) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return ErrStoreClosed
}
entry, ok := s.entries[id]
if !ok {
return fmt.Errorf("%w: %s", ErrKeyNotFound, id)
}
// Zero key material before removing
for i := range entry.KeyData {
entry.KeyData[i] = 0
}
delete(s.entries, id)
return nil
}
// List returns metadata for all stored keys. KeyData is never included
// in the returned entries.
func (s *Store) List() []Entry {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]Entry, 0, len(s.entries))
for _, e := range s.entries {
result = append(result, e.Redacted())
}
return result
}
// Import derives a key from a password using SHA-256 (matching Borg's
// DeriveKey behavior) and stores the derived key. The original password
// is never persisted — only the derived 32-byte key.
// Returns the generated key ID.
func (s *Store) Import(password string, label string) (string, error) {
hash := sha256.Sum256([]byte(password))
id := generateID()
entry := &Entry{
ID: id,
Type: ChaCha256,
KeyData: hash[:],
Label: label,
Metadata: map[string]string{
"derived_from": "password",
"kdf": "sha256",
},
}
if err := s.PutEntry(entry); err != nil {
return "", err
}
return id, nil
}
// Generate creates a new random key of the specified type and stores it.
// For X25519, both private and public keys are stored (private in KeyData,
// public in Metadata["public_key"]).
// Returns the generated key ID.
func (s *Store) Generate(keyType KeyType, label string) (string, error) {
if !keyType.IsValid() {
return "", ErrInvalidKeyType
}
id := generateID()
var keyData []byte
metadata := map[string]string{"generated": "true"}
switch keyType {
case X25519:
curve := ecdh.X25519()
privKey, err := curve.GenerateKey(rand.Reader)
if err != nil {
return "", fmt.Errorf("keystore: failed to generate X25519 key: %w", err)
}
keyData = privKey.Bytes()
metadata["public_key"] = fmt.Sprintf("%x", privKey.PublicKey().Bytes())
case ChaCha256, HMAC:
size := keyType.KeySize()
keyData = make([]byte, size)
if _, err := io.ReadFull(rand.Reader, keyData); err != nil {
return "", fmt.Errorf("keystore: failed to generate random key: %w", err)
}
case PGP:
return "", fmt.Errorf("keystore: PGP key generation not yet supported, import instead")
default:
return "", ErrInvalidKeyType
}
entry := &Entry{
ID: id,
Type: keyType,
KeyData: keyData,
Label: label,
Metadata: metadata,
}
if err := s.PutEntry(entry); err != nil {
return "", err
}
return id, nil
}
// Save persists the store to disk as an encrypted Trix container.
func (s *Store) Save() error {
s.mu.RLock()
defer s.mu.RUnlock()
if s.closed {
return ErrStoreClosed
}
// Serialize entries
entries := make([]*Entry, 0, len(s.entries))
for _, e := range s.entries {
entries = append(entries, e)
}
plaintext, err := json.Marshal(entries)
if err != nil {
return fmt.Errorf("keystore: failed to marshal entries: %w", err)
}
// Encrypt with master key
sigil, err := enchantrix.NewChaChaPolySigil(s.master)
if err != nil {
return err
}
ciphertext, err := sigil.In(plaintext)
if err != nil {
return fmt.Errorf("keystore: failed to encrypt store: %w", err)
}
// Marshal Argon2 params for header
paramsJSON, err := json.Marshal(s.params)
if err != nil {
return fmt.Errorf("keystore: failed to marshal params: %w", err)
}
// Marshal salt for header
saltJSON, err := json.Marshal(s.salt)
if err != nil {
return fmt.Errorf("keystore: failed to marshal salt: %w", err)
}
// Build Trix container
container := &trix.Trix{
Header: map[string]interface{}{
"format": "enchantrix-keystore",
"version": "1.0",
"salt": json.RawMessage(saltJSON),
"argon2_params": string(paramsJSON),
},
Payload: ciphertext,
}
encoded, err := trix.Encode(container, storeMagic, nil)
if err != nil {
return fmt.Errorf("keystore: failed to encode store: %w", err)
}
// Write atomically via temp file
tmpPath := s.path + ".tmp"
if err := os.WriteFile(tmpPath, encoded, 0600); err != nil {
return fmt.Errorf("keystore: failed to write store: %w", err)
}
if err := os.Rename(tmpPath, s.path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("keystore: failed to finalize store: %w", err)
}
return nil
}
// Close zeroes the master key and marks the store as closed.
func (s *Store) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
// Zero master key
for i := range s.master {
s.master[i] = 0
}
// Zero all key material
for _, entry := range s.entries {
for i := range entry.KeyData {
entry.KeyData[i] = 0
}
}
s.entries = nil
s.closed = true
return nil
}
// Path returns the file path of the store.
func (s *Store) Path() string {
return s.path
}
// generateID creates a random hex ID suitable for key identification.
func generateID() string {
b := make([]byte, 8)
io.ReadFull(rand.Reader, b)
return fmt.Sprintf("%x", b)
}

455
pkg/keystore/store_test.go Normal file
View file

@ -0,0 +1,455 @@
package keystore
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateAndOpen(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
// Create a new store
store, err := Create(path, "master-password")
require.NoError(t, err)
require.NotNil(t, store)
// Put a key
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
err = store.Put("test-key", ChaCha256, key)
require.NoError(t, err)
// Save and close
err = store.Save()
require.NoError(t, err)
err = store.Close()
require.NoError(t, err)
// Reopen
store2, err := Open(path, "master-password")
require.NoError(t, err)
require.NotNil(t, store2)
// Verify key round-trips
entry, err := store2.Get("test-key")
require.NoError(t, err)
assert.Equal(t, "test-key", entry.ID)
assert.Equal(t, ChaCha256, entry.Type)
assert.Equal(t, key, entry.KeyData)
store2.Close()
}
func TestOpenWrongPassword(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
store, err := Create(path, "correct-password")
require.NoError(t, err)
store.Close()
_, err = Open(path, "wrong-password")
assert.ErrorIs(t, err, ErrDecryptionFailed)
}
func TestCreateExistingFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
store, err := Create(path, "password")
require.NoError(t, err)
store.Close()
_, err = Create(path, "password")
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
}
func TestPutAndGet(t *testing.T) {
store := newMemStore(t)
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
err := store.Put("key1", ChaCha256, key)
require.NoError(t, err)
entry, err := store.Get("key1")
require.NoError(t, err)
assert.Equal(t, "key1", entry.ID)
assert.Equal(t, ChaCha256, entry.Type)
assert.Equal(t, key, entry.KeyData)
}
func TestPutDuplicate(t *testing.T) {
store := newMemStore(t)
key := make([]byte, 32)
err := store.Put("key1", ChaCha256, key)
require.NoError(t, err)
err = store.Put("key1", ChaCha256, key)
assert.ErrorIs(t, err, ErrDuplicateKey)
}
func TestPutInvalidKeyType(t *testing.T) {
store := newMemStore(t)
key := make([]byte, 32)
err := store.Put("key1", KeyType("invalid"), key)
assert.ErrorIs(t, err, ErrInvalidKeyType)
}
func TestPutInvalidKeySize(t *testing.T) {
store := newMemStore(t)
// ChaCha256 requires 32 bytes
key := make([]byte, 16)
err := store.Put("key1", ChaCha256, key)
assert.ErrorIs(t, err, ErrInvalidKeySize)
}
func TestGetNotFound(t *testing.T) {
store := newMemStore(t)
_, err := store.Get("nonexistent")
assert.ErrorIs(t, err, ErrKeyNotFound)
}
func TestGetReturnsClone(t *testing.T) {
store := newMemStore(t)
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
store.Put("key1", ChaCha256, key)
entry, _ := store.Get("key1")
// Mutate the clone
entry.KeyData[0] = 0xFF
// Original should be unchanged
entry2, _ := store.Get("key1")
assert.Equal(t, byte(0), entry2.KeyData[0])
}
func TestDelete(t *testing.T) {
store := newMemStore(t)
key := make([]byte, 32)
store.Put("key1", ChaCha256, key)
err := store.Delete("key1")
require.NoError(t, err)
_, err = store.Get("key1")
assert.ErrorIs(t, err, ErrKeyNotFound)
}
func TestDeleteNotFound(t *testing.T) {
store := newMemStore(t)
err := store.Delete("nonexistent")
assert.ErrorIs(t, err, ErrKeyNotFound)
}
func TestList(t *testing.T) {
store := newMemStore(t)
key := make([]byte, 32)
store.Put("key1", ChaCha256, key)
store.Put("key2", ChaCha256, key)
list := store.List()
assert.Len(t, list, 2)
// Verify no key data exposed
for _, entry := range list {
assert.Nil(t, entry.KeyData, "List() must not expose key data")
}
}
func TestListEmpty(t *testing.T) {
store := newMemStore(t)
list := store.List()
assert.Empty(t, list)
}
func TestImport(t *testing.T) {
store := newMemStore(t)
id, err := store.Import("my-password", "test-import")
require.NoError(t, err)
assert.NotEmpty(t, id)
entry, err := store.Get(id)
require.NoError(t, err)
assert.Equal(t, ChaCha256, entry.Type)
assert.Equal(t, "test-import", entry.Label)
assert.Len(t, entry.KeyData, 32)
assert.Equal(t, "password", entry.Metadata["derived_from"])
// Importing the same password again should produce the same key data
id2, err := store.Import("my-password", "test-import-2")
require.NoError(t, err)
entry2, _ := store.Get(id2)
assert.Equal(t, entry.KeyData, entry2.KeyData, "same password should derive same key")
}
func TestGenerate(t *testing.T) {
t.Run("ChaCha256", func(t *testing.T) {
store := newMemStore(t)
id, err := store.Generate(ChaCha256, "gen-chacha")
require.NoError(t, err)
entry, err := store.Get(id)
require.NoError(t, err)
assert.Equal(t, ChaCha256, entry.Type)
assert.Equal(t, "gen-chacha", entry.Label)
assert.Len(t, entry.KeyData, 32)
assert.Equal(t, "true", entry.Metadata["generated"])
})
t.Run("X25519", func(t *testing.T) {
store := newMemStore(t)
id, err := store.Generate(X25519, "gen-x25519")
require.NoError(t, err)
entry, err := store.Get(id)
require.NoError(t, err)
assert.Equal(t, X25519, entry.Type)
assert.Len(t, entry.KeyData, 32)
assert.NotEmpty(t, entry.Metadata["public_key"])
})
t.Run("HMAC", func(t *testing.T) {
store := newMemStore(t)
id, err := store.Generate(HMAC, "gen-hmac")
require.NoError(t, err)
entry, err := store.Get(id)
require.NoError(t, err)
assert.Equal(t, HMAC, entry.Type)
assert.Len(t, entry.KeyData, 32)
})
t.Run("InvalidType", func(t *testing.T) {
store := newMemStore(t)
_, err := store.Generate(KeyType("bogus"), "")
assert.ErrorIs(t, err, ErrInvalidKeyType)
})
}
func TestClosedStore(t *testing.T) {
store := newMemStore(t)
store.Close()
key := make([]byte, 32)
_, err := store.Get("key1")
assert.ErrorIs(t, err, ErrStoreClosed)
err = store.Put("key1", ChaCha256, key)
assert.ErrorIs(t, err, ErrStoreClosed)
err = store.Delete("key1")
assert.ErrorIs(t, err, ErrStoreClosed)
err = store.Save()
assert.ErrorIs(t, err, ErrStoreClosed)
}
func TestCloseZeroesKeys(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
store, err := Create(path, "password")
require.NoError(t, err)
key := make([]byte, 32)
for i := range key {
key[i] = 0xFF
}
store.Put("key1", ChaCha256, key)
// Keep reference to master key and entry key
masterRef := store.master
store.Close()
// Master key should be zeroed
for _, b := range masterRef {
assert.Equal(t, byte(0), b, "master key not zeroed after Close")
}
}
func TestRoundTripPersistence(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
// Create, add multiple keys, save
store, err := Create(path, "test-password-123")
require.NoError(t, err)
key1 := make([]byte, 32)
for i := range key1 {
key1[i] = byte(i)
}
store.Put("sym-key", ChaCha256, key1)
id, err := store.Generate(X25519, "my-x25519")
require.NoError(t, err)
importID, err := store.Import("user-password", "imported")
require.NoError(t, err)
store.Save()
// Read back the x25519 entry before closing
x25519Entry, _ := store.Get(id)
importEntry, _ := store.Get(importID)
store.Close()
// Reopen and verify all entries
store2, err := Open(path, "test-password-123")
require.NoError(t, err)
// Verify symmetric key
entry1, err := store2.Get("sym-key")
require.NoError(t, err)
assert.Equal(t, key1, entry1.KeyData)
assert.Equal(t, ChaCha256, entry1.Type)
// Verify X25519 key
entry2, err := store2.Get(id)
require.NoError(t, err)
assert.Equal(t, x25519Entry.KeyData, entry2.KeyData)
assert.Equal(t, X25519, entry2.Type)
// Verify imported key
entry3, err := store2.Get(importID)
require.NoError(t, err)
assert.Equal(t, importEntry.KeyData, entry3.KeyData)
// Verify list count
list := store2.List()
assert.Len(t, list, 3)
store2.Close()
}
func TestSaveAfterDelete(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
store, err := Create(path, "password")
require.NoError(t, err)
key := make([]byte, 32)
store.Put("key1", ChaCha256, key)
store.Put("key2", ChaCha256, key)
store.Save()
store.Close()
store2, err := Open(path, "password")
require.NoError(t, err)
store2.Delete("key1")
store2.Save()
store2.Close()
store3, err := Open(path, "password")
require.NoError(t, err)
_, err = store3.Get("key1")
assert.ErrorIs(t, err, ErrKeyNotFound)
entry, err := store3.Get("key2")
require.NoError(t, err)
assert.Equal(t, "key2", entry.ID)
store3.Close()
}
func TestOpenNonExistentFile(t *testing.T) {
_, err := Open("/nonexistent/path/store.trix", "password")
assert.Error(t, err)
}
func TestAtomicSave(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
store, err := Create(path, "password")
require.NoError(t, err)
key := make([]byte, 32)
store.Put("key1", ChaCha256, key)
store.Save()
// Verify no temp file left behind
_, err = os.Stat(path + ".tmp")
assert.True(t, os.IsNotExist(err), "temp file should not exist after save")
store.Close()
}
func TestPutEntry(t *testing.T) {
store := newMemStore(t)
entry := &Entry{
ID: "custom-id",
Type: ChaCha256,
KeyData: make([]byte, 32),
Label: "custom-label",
Metadata: map[string]string{
"purpose": "testing",
},
}
err := store.PutEntry(entry)
require.NoError(t, err)
got, err := store.Get("custom-id")
require.NoError(t, err)
assert.Equal(t, "custom-label", got.Label)
assert.Equal(t, "testing", got.Metadata["purpose"])
assert.False(t, got.CreatedAt.IsZero())
}
func TestFilePermissions(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
store, err := Create(path, "password")
require.NoError(t, err)
store.Close()
info, err := os.Stat(path)
require.NoError(t, err)
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "store file should be 0600")
}
// newMemStore creates a temporary file-backed store for testing.
func newMemStore(t *testing.T) *Store {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "test.trix")
store, err := Create(path, "test-password")
require.NoError(t, err)
t.Cleanup(func() { store.Close() })
return store
}

64
pkg/keystore/types.go Normal file
View file

@ -0,0 +1,64 @@
// Package keystore provides a Trix-based encrypted key store for managing
// cryptographic keys at rest. Keys are encrypted individually within a Trix
// container using Argon2id-derived master key material.
package keystore
import "errors"
// KeyType identifies the type of cryptographic key stored in an entry.
type KeyType string
const (
// ChaCha256 is a 256-bit symmetric key for ChaCha20-Poly1305 encryption.
ChaCha256 KeyType = "chacha256"
// X25519 is an X25519 keypair for Diffie-Hellman key exchange.
X25519 KeyType = "x25519"
// PGP is an OpenPGP key (public, private, or both).
PGP KeyType = "pgp"
// HMAC is a key for HMAC-based message authentication.
HMAC KeyType = "hmac"
)
var (
// ErrKeyNotFound is returned when a key ID does not exist in the store.
ErrKeyNotFound = errors.New("keystore: key not found")
// ErrDuplicateKey is returned when a key ID already exists.
ErrDuplicateKey = errors.New("keystore: key already exists")
// ErrStoreClosed is returned when operating on a closed store.
ErrStoreClosed = errors.New("keystore: store is closed")
// ErrInvalidKeyType is returned when an unsupported key type is specified.
ErrInvalidKeyType = errors.New("keystore: invalid key type")
// ErrInvalidKeySize is returned when key material has an invalid size for its type.
ErrInvalidKeySize = errors.New("keystore: invalid key size")
// ErrDecryptionFailed is returned when the master password is incorrect.
ErrDecryptionFailed = errors.New("keystore: decryption failed (wrong password?)")
)
// ValidKeyTypes returns all supported key types.
func ValidKeyTypes() []KeyType {
return []KeyType{ChaCha256, X25519, PGP, HMAC}
}
// IsValid reports whether the key type is a recognized type.
func (kt KeyType) IsValid() bool {
switch kt {
case ChaCha256, X25519, PGP, HMAC:
return true
}
return false
}
// KeySize returns the expected raw key size in bytes for the type,
// or 0 if the type has variable-length keys (e.g. PGP).
func (kt KeyType) KeySize() int {
switch kt {
case ChaCha256:
return 32
case X25519:
return 32 // private key; public is also 32 bytes
case HMAC:
return 32
default:
return 0
}
}

View file

@ -341,6 +341,7 @@ The following magic numbers are registered for specific applications:
| `SMSG` | Borg | Encrypted message/media container |
| `STIM` | Borg | Encrypted TIM container bundle |
| `STMF` | Borg | Secure To-Me Form (encrypted form data) |
| `KEYS` | Enchantrix | Encrypted key store container (RFC-0005) |
| `TRIX` | Borg | Encrypted DataNode archive |
### 8.3 Allocation Guidelines

View file

@ -0,0 +1,582 @@
# RFC-0005: Keyserver Secure Environment (SE)
**Status:** Standards Track
**Version:** 1.0
**Created:** 2026-02-05
**Author:** Snider
## Abstract
This document specifies the Enchantrix Keyserver, an in-process cryptographic service that holds key material and exposes operations by reference. Callers (including AI agents) interact with keys via opaque IDs and never receive raw key bytes. The specification covers the key store format, the KeyServer interface contract, the session/capability model, audit logging, and the Borg integration conventions for TIM, SMSG, and STMF.
## Table of Contents
1. [Introduction](#1-introduction)
2. [Terminology](#2-terminology)
3. [Key Store Format](#3-key-store-format)
4. [KeyServer Interface](#4-keyserver-interface)
5. [Session Model](#5-session-model)
6. [Audit Log Format](#6-audit-log-format)
7. [Borg Integration](#7-borg-integration)
8. [CLI Commands](#8-cli-commands)
9. [Migration Guide](#9-migration-guide)
10. [Security Considerations](#10-security-considerations)
11. [References](#11-references)
## 1. Introduction
Every crypto operation in Borg today passes raw passwords or keys directly:
- `tim.ToSigil(password)` — raw password flows to SHA-256
- `smsg.Encrypt(msg, password)` — same pattern
- `stmf.Encrypt(data, serverPublicKey)` — raw X25519 public key
- `trix.DeriveKey(password)` — SHA-256(password) with no salt/stretching
This means any caller — including AI agents — can observe raw key material in memory and in function arguments. The Keyserver Secure Environment (SE) addresses this by introducing a service boundary: callers receive an opaque key ID and perform operations by reference. The key material remains inside the keyserver.
### 1.1 Design Goals
- **Key isolation**: Raw key bytes never cross the service boundary
- **Backward compatibility**: Existing password-based APIs remain unchanged
- **Composite operations**: Format-specific encrypt/decrypt (TIM, SMSG, STMF) happen atomically inside the keyserver
- **Capability-based access**: Sessions grant specific operations on specific keys
- **Auditability**: Every crypto operation is logged
- **In-process first**: No network protocol overhead; the keyserver runs in the same Go process
### 1.2 Architecture
```
Borg (Data Ops) Enchantrix (Data Security)
┌──────────────────────┐ ┌─────────────────────────────┐
│ TIM · SMSG · STMF │ │ Keyserver Service │
│ │ keyID │ ┌───────────────────────┐ │
│ tim.ToSigilKS(h,ks) ├──────────►│ │ Key Store (Trix) │ │
│ smsg.EncryptKS(h,m) │ result │ │ [master.trix] │ │
│ stmf.DecryptKS(h,d) │◄──────────┤ │ ├─ sym/chacha/* │ │
│ │ │ │ └─ asym/x25519/* │ │
└──────────────────────┘ │ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Session / ACL │ │
│ │ - capabilities │ │
│ │ - TTL, revocation │ │
│ └───────────────────────┘ │
│ ┌───────────────────────┐ │
│ │ Audit Logger │ │
│ │ - append-only JSONL │ │
│ └───────────────────────┘ │
└─────────────────────────────┘
```
## 2. Terminology
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
**Key Store**: A Trix container (magic `KEYS`) that persists encrypted key entries on disk.
**Key Entry**: A single cryptographic key with metadata (ID, type, label, creation time).
**Key ID**: An opaque hex string (16 characters) that identifies a key entry.
**KeyServer**: An in-process service backed by a Key Store that exposes crypto operations by key ID.
**Session**: A time-limited, capability-scoped authorization token.
**Capability**: A permission string in the form `operation:keyID` (e.g., `encrypt:abc123`).
**Composite Operation**: A format-specific crypto operation (e.g., `EncryptTIM`) that handles the full internal format atomically.
## 3. Key Store Format
### 3.1 Overview
The key store is itself a Trix container with magic number `KEYS`. The outer container is **not** encrypted — it stores the Argon2id salt and parameters in the header. The payload is encrypted with the master key.
```
┌─────────────────────────────────────────┐
│ Trix Container (magic: "KEYS") │
│ ┌─────────────────────────────────────┐ │
│ │ Header (plaintext JSON) │ │
│ │ format: "enchantrix-keystore" │ │
│ │ version: "1.0" │ │
│ │ salt: <base64 bytes> │ │
│ │ argon2_params: { ... } │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ Payload (encrypted) │ │
│ │ XChaCha20-Poly1305( │ │
│ │ key: Argon2id(master, salt), │ │
│ │ data: JSON([Entry, ...]) │ │
│ │ ) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
```
### 3.2 Master Key Derivation
The master key MUST be derived using Argon2id with the following default parameters:
| Parameter | Value |
|-----------|-------|
| Time cost | 1 |
| Memory cost | 65536 (64 MB) |
| Parallelism | 4 |
| Key length | 32 bytes |
| Salt length | 16 bytes (random) |
Implementations MUST store the Argon2id parameters in the container header to support future parameter upgrades. The salt is generated randomly on first `Create` and persisted.
### 3.3 Key Entry Schema
```json
{
"id": "a1b2c3d4e5f6a7b8",
"type": "chacha256",
"key_data": "<base64 encoded key bytes>",
"label": "human-readable label",
"created_at": "2026-02-05T12:00:00Z",
"metadata": {
"derived_from": "password",
"kdf": "sha256"
}
}
```
### 3.4 Key Types
| Type | Key Size | Description |
|------|----------|-------------|
| `chacha256` | 32 bytes | Symmetric key for ChaCha20-Poly1305 |
| `x25519` | 32 bytes (private) | X25519 Diffie-Hellman keypair |
| `hmac` | 32 bytes | HMAC-SHA256 key |
| `pgp` | variable | OpenPGP key material |
For `x25519` keys, the private key is stored in `key_data` and the public key is stored in `metadata["public_key"]` as a hex string.
### 3.5 Store Operations
| Operation | Description |
|-----------|-------------|
| `Create(path, masterPassword)` | Initialize a new store. File MUST NOT exist. |
| `Open(path, masterPassword)` | Open and decrypt existing store. |
| `Put(id, keyType, raw)` | Add a key entry. |
| `Get(id)` | Retrieve a key entry (cloned). |
| `Delete(id)` | Remove and zero key material. |
| `List()` | Return all entries with `KeyData` redacted. |
| `Import(password, label)` | Derive key via SHA-256 and store. |
| `Generate(keyType, label)` | Generate random key and store. |
| `Save()` | Persist to disk atomically (temp file + rename). |
| `Close()` | Zero master key and all key material. |
`Save()` MUST write atomically via a temporary file and rename to prevent corruption on crash.
`Close()` MUST zero all key material (master key and individual entries) before releasing memory.
`List()` MUST NOT include `KeyData` in returned entries.
### 3.6 Password Import
`Import(password, label)` derives a 32-byte key using SHA-256(password). This matches the existing `trix.DeriveKey()` behavior in Borg, ensuring backward compatibility: a password imported via the keyserver produces the same key material as the old direct-password APIs.
The original password is NEVER persisted — only the derived 32-byte key.
## 4. KeyServer Interface
### 4.1 Interface Definition
```go
type KeyServer interface {
// Key lifecycle
GenerateKey(ctx, keyType, label) (keyID, error)
ImportPassword(ctx, password, label) (keyID, error)
DeleteKey(ctx, keyID) error
ListKeys(ctx) ([]Entry, error)
GetPublicKey(ctx, keyID) ([]byte, error)
// Generic crypto operations
Encrypt(ctx, keyID, plaintext) (ciphertext, error)
Decrypt(ctx, keyID, ciphertext) (plaintext, error)
Sign(ctx, keyID, data) (signature, error)
Verify(ctx, keyID, data, signature) error
// TIM composite operations
EncryptTIM(ctx, keyID, config, rootfs) ([]byte, error)
DecryptTIM(ctx, keyID, payload) (config, rootfs, error)
// SMSG composite operations
EncryptSMSG(ctx, keyID, message) ([]byte, error)
DecryptSMSG(ctx, keyID, ciphertext) ([]byte, error)
// STMF operations
DecryptSTMF(ctx, keyID, stmfData) ([]byte, error)
// Stream key operations (SMSG V3)
DeriveStreamKey(ctx, license, date, fingerprint) (keyID, error)
WrapCEK(ctx, streamKeyID, cek) (wrapped, error)
UnwrapCEK(ctx, streamKeyID, wrapped) (cek, error)
}
```
### 4.2 Composite Operations
Composite operations exist because Borg's data formats have internal structure that requires multiple encrypt/decrypt passes with the same key. Rather than exposing the key for multiple calls, the keyserver performs the entire format-specific operation atomically.
**EncryptTIM**: Encrypts config and rootfs separately using the same ChaChaPolySigil instance, then packs them into the TIM binary layout: `[4-byte config_size][encrypted_config][encrypted_rootfs]`.
**DecryptTIM**: Reads the TIM binary layout, decrypts both components, returns them separately.
**EncryptSMSG / DecryptSMSG**: Encrypts/decrypts the SMSG message payload using ChaCha20-Poly1305 with the password-derived key.
**DecryptSTMF**: Performs X25519 ECDH with the server's private key and the client's ephemeral public key (extracted from the STMF header), derives the shared symmetric key, and decrypts the form data. The server's private key never leaves the keyserver.
### 4.3 Stream Key Operations
For SMSG V3 (rolling license-based keys):
1. `DeriveStreamKey(license, date, fingerprint)` computes `SHA256(LTHN(date:license:fingerprint))` and stores the result as a temporary key entry. Returns a key ID.
2. `WrapCEK(streamKeyID, cek)` encrypts the per-message Content Encryption Key (CEK) with the stream key using ChaCha20-Poly1305. Returns base64-encoded wrapped CEK.
3. `UnwrapCEK(streamKeyID, wrapped)` reverses the wrapping.
Callers MUST delete temporary stream keys after use via `DeleteKey`.
## 5. Session Model
### 5.1 Session Structure
```json
{
"id": "ses_a1b2c3d4e5f6a7b8...",
"capabilities": [
{"operation": "encrypt", "key_id": "abc123"},
{"operation": "decrypt", "key_id": "abc123"}
],
"created_at": "2026-02-05T12:00:00Z",
"expires_at": "2026-02-05T13:00:00Z",
"client_id": "agent-worker-1"
}
```
### 5.2 Capability Format
A capability is a string in the form `operation:keyID`.
**Valid operations**: `encrypt`, `decrypt`, `sign`, `verify`, `list`, `derive`, `delete`, `generate`, `import`, `wrap`, `unwrap`.
**Key ID**: A specific key ID, or `*` as a wildcard matching all keys.
Examples:
- `encrypt:abc123` — can encrypt with key abc123
- `decrypt:*` — can decrypt with any key
- `list:*` — can list keys
### 5.3 Session Lifecycle
1. `CreateSession(clientID, capabilities, ttl)` creates a session with a random 128-bit ID (prefixed `ses_`).
2. `ValidateSession(sessionID, op, keyID)` checks existence, expiry, revocation, and capability match.
3. `RevokeSession(sessionID)` immediately invalidates the session.
4. `CleanExpired()` removes expired sessions from memory.
A session MUST be rejected if:
- It does not exist
- It has been revoked
- It has expired (current time > ExpiresAt)
- It lacks a matching capability for the requested operation
### 5.4 Security Properties
- Session IDs are 128-bit random values (cryptographically strong)
- Sessions are in-memory only (not persisted across restarts)
- Revocation is immediate (checked on every operation)
- No session refreshing — create a new session when needed
## 6. Audit Log Format
### 6.1 Event Schema
Each crypto operation produces a JSONL event:
```json
{
"ts": "2026-02-05T12:00:00.123456Z",
"session_id": "ses_abc...",
"op": "encrypt",
"key_id": "abc123",
"ok": true,
"error": "",
"input_size": 1024
}
```
| Field | Type | Description |
|-------|------|-------------|
| `ts` | RFC 3339 timestamp | When the operation occurred |
| `session_id` | string | Session that requested the operation (may be empty) |
| `op` | string | Operation name (encrypt, decrypt, sign, etc.) |
| `key_id` | string | Key ID used |
| `ok` | bool | Whether the operation succeeded |
| `error` | string | Error message if failed |
| `input_size` | int | Size of input data (not the data itself) |
### 6.2 Storage
- Audit events are written as append-only JSONL (one JSON object per line)
- File permissions MUST be 0600
- Events MUST NOT contain key material, plaintext, or ciphertext
- Only the size of the input is logged
- The audit log supports memory-only mode for testing
### 6.3 Querying
The audit log supports filtering by:
- Time range (`since`, `until`)
- Session ID
- Operation
- Key ID
## 7. Borg Integration
### 7.1 TIM Integration
New keyserver-aware functions are added alongside existing APIs:
```go
// Existing (unchanged)
func (m *TIM) ToSigil(password string) ([]byte, error)
func FromSigil(data []byte, password string) (*TIM, error)
// New keyserver variants
func (m *TIM) ToSigilKS(ctx, keyID, ks) ([]byte, error)
func FromSigilKS(data, keyID, ks) (*TIM, error)
func NewCacheKS(dir, keyID, ks) (*CacheKS, error)
func RunEncryptedKS(ctx, stimPath, keyID, ks) error
```
**Format compatibility**: `ToSigilKS` produces identical STIM containers to `ToSigil` when the same derived key is used (via `ImportPassword`). This means a file encrypted with the keyserver can be decrypted with the old password API and vice versa.
### 7.2 SMSG Integration
```go
// V1/V2: keyserver holds the password-derived key
func EncryptKS(ctx, msg, keyID, ks) ([]byte, error)
func DecryptKS(ctx, data, keyID, ks) (*Message, error)
// V3: stream keys derived and CEK wrapped via keyserver
func EncryptV3KS(ctx, msg, params, manifest, ks) ([]byte, error)
func DecryptV3KS(ctx, data, params, ks) (*Message, *Header, error)
```
**V3 flow**:
1. `EncryptV3KS` generates a random CEK (ephemeral, per-message)
2. Derives stream keys for current and next rolling periods via `ks.DeriveStreamKey()`
3. Wraps CEK with each stream key via `ks.WrapCEK()`
4. Encrypts message content with CEK locally (CEK is ephemeral, not a long-lived secret)
5. Deletes temporary stream keys
### 7.3 STMF Integration
```go
// Client-side (unchanged — uses raw public key)
func Encrypt(data *FormData, serverPublicKey []byte) ([]byte, error)
// Server-side via keyserver
func DecryptKS(ctx, stmfData, keyID, ks) (*FormData, error)
func GenerateKeyPairKS(ctx, label, ks) (publicKey, keyID, error)
```
The client encrypts using the server's X25519 public key (distributed out-of-band). The server decrypts via the keyserver, which performs ECDH internally — the server's private key never leaves the keyserver.
**Key rotation**: `GenerateKeyPairKS` creates new keypairs. Old keypairs remain in the store and can still decrypt old form submissions. New forms use the new public key.
## 8. CLI Commands
### 8.1 Key Store Management
```bash
# Initialize a new key store
trix keystore init --path ~/.enchantrix/keys.trix
# (reads master password from stdin or ENCHANTRIX_MASTER env)
# Import a password as a named key
trix keystore import --path keys.trix --label "borg-tim-key"
# (reads master password, then import password from stdin)
# Generate a new random key
trix keystore generate --path keys.trix --label "stmf-server" --type x25519
# List stored keys (no key material shown)
trix keystore list --path keys.trix
# Delete a key
trix keystore delete --path keys.trix --id <key-id>
```
### 8.2 Keyserver Management
```bash
# Start keyserver (blocks until Ctrl+C)
trix keyserver start --store keys.trix --addr :9100
# Create a session token
trix keyserver session create --store keys.trix \
--caps "encrypt:key-abc,decrypt:key-abc" --ttl 1h --client agent-1
```
### 8.3 Password Input
Passwords MUST NOT be passed via CLI flags (to avoid shell history exposure). The CLI reads passwords from:
1. `ENCHANTRIX_MASTER` environment variable (for the master password)
2. stdin (prompted with "Master password: " or "Password to import: ")
## 9. Migration Guide
### 9.1 Before (Password-Based)
```go
// TIM
stim, _ := tim.ToSigil("my-password")
restored, _ := tim.FromSigil(stim, "my-password")
// SMSG
encrypted, _ := smsg.Encrypt(msg, "my-password")
decrypted, _ := smsg.Decrypt(encrypted, "my-password")
// STMF
pub, priv, _ := stmf.GenerateKeyPair()
encrypted, _ := stmf.Encrypt(formData, pub)
decrypted, _ := stmf.Decrypt(encrypted, priv)
```
### 9.2 After (Keyserver-Based)
```go
// One-time setup
store, _ := keystore.Open("keys.trix", masterPassword)
ks := keyserver.NewServer(store)
// Import existing password (produces same derived key)
keyID, _ := ks.ImportPassword(ctx, "my-password", "tim-key")
// TIM — password never visible
stim, _ := tim.ToSigilKS(ctx, keyID, ks)
restored, _ := tim.FromSigilKS(stim, keyID, ks)
// SMSG — password never visible
encrypted, _ := smsg.EncryptKS(ctx, msg, keyID, ks)
decrypted, _ := smsg.DecryptKS(ctx, encrypted, keyID, ks)
// STMF — private key generated and held by keyserver
pub, stmfKeyID, _ := stmf.GenerateKeyPairKS(ctx, "stmf-server", ks)
encrypted, _ := stmf.Encrypt(formData, pub) // client unchanged
decrypted, _ := stmf.DecryptKS(ctx, encrypted, stmfKeyID, ks)
```
### 9.3 Interoperability
Files encrypted with the old API can be decrypted with the keyserver API (and vice versa) as long as the same password was imported. This is guaranteed because `ImportPassword` uses `SHA256(password)` — the same derivation as `trix.DeriveKey()`.
## 10. Security Considerations
### 10.1 Threat Model
**In scope**:
- AI agents that should perform crypto operations but MUST NOT see key material
- Multi-tenant environments where callers need scoped access to specific keys
- Audit compliance requiring operation logging
**Out of scope** (future work):
- Memory forensics attacks (require HSM integration)
- Side-channel attacks on the Argon2id derivation
- Network transport security (keyserver is in-process)
### 10.2 Key Material Lifecycle
1. **At rest**: Keys are encrypted inside a Trix container with Argon2id-derived master key
2. **In memory**: Keys are decrypted when the store is opened and held in Go heap memory
3. **On delete**: Key bytes are zeroed before removal from the map
4. **On close**: Master key and all entry key material are zeroed
### 10.3 Password Handling
- Master passwords are never persisted (only the derived key via Argon2id)
- Imported passwords are never persisted (only SHA-256 derived key)
- CLI reads passwords from stdin, not flags (avoids shell history)
- The `ENCHANTRIX_MASTER` env var is an acceptable alternative for automated environments
### 10.4 Session Security
- Session IDs are 128-bit cryptographically random values
- Sessions are in-memory only — not persisted across process restarts
- Expired and revoked sessions are rejected immediately
- Wildcard capabilities (`decrypt:*`) should be granted sparingly
### 10.5 Audit Log Integrity
- The audit log is append-only with 0600 permissions
- Audit events contain operation metadata, not data content
- Input sizes are logged for anomaly detection
- Deletion of audit records is not supported by the API
### 10.6 Backward Compatibility Risks
The `ImportPassword` function uses SHA-256 to match Borg's existing `DeriveKey`. SHA-256 is a fast hash unsuitable for password derivation. This is a conscious trade-off for backward compatibility. For new deployments without legacy data, `Generate` with random keys is RECOMMENDED.
## 11. References
- RFC-0001: Pre-Obfuscation Layer
- RFC-0002: Trix Container Format
- RFC-0003: Sigil Transformation Framework
- RFC-0004: LTHN Hash Algorithm
- [RFC 2119] Key words for use in RFCs to Indicate Requirement Levels
- [RFC 9106] Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications
---
## Appendix A: Magic Number Registration
This RFC registers the following magic number (per RFC-0002 Section 8):
| Magic | Application | Description |
|-------|-------------|-------------|
| `KEYS` | Enchantrix | Encrypted key store container |
## Appendix B: Key Store Binary Layout
```
┌─────────────────────────────────────────────────────┐
│ "KEYS" │ 0x02 │ HdrLen │ JSON Header │ Payload │
│ (4B) │ (1B) │ (4B) │ (var) │ (var) │
└─────────────────────────────────────────────────────┘
Header:
{ "format": "enchantrix-keystore",
"version": "1.0",
"salt": "<base64>",
"argon2_params": "{\"time\":1,\"memory\":65536,...}" }
Payload = XChaCha20-Poly1305(
key: Argon2id(masterPassword, salt, params),
data: JSON([ Entry, Entry, ... ])
)
```
## Appendix C: Capability String Grammar
```
capability = operation ":" key-selector
operation = "encrypt" | "decrypt" | "sign" | "verify"
| "list" | "derive" | "delete" | "generate"
| "import" | "wrap" | "unwrap"
key-selector = key-id | "*"
key-id = 1*HEXDIG
capabilities = capability *("," capability)
```
## Appendix D: Reference Implementation
- Key Store: `github.com/Snider/Enchantrix/pkg/keystore/`
- KeyServer: `github.com/Snider/Enchantrix/pkg/keyserver/`
- CLI: `github.com/Snider/Enchantrix/cmd/trix/` (keystore.go, keyserver.go)
- Borg TIM integration: `github.com/Snider/Borg/pkg/tim/keyserver.go`
- Borg SMSG integration: `github.com/Snider/Borg/pkg/smsg/keyserver.go`
- Borg STMF integration: `github.com/Snider/Borg/pkg/stmf/keyserver.go`
## Appendix E: Changelog
- **1.0** (2026-02-05): Initial specification