Merge 447f3ccaca into 86f4e33b1a
This commit is contained in:
commit
746ca5b5e3
21 changed files with 3985 additions and 0 deletions
144
cmd/trix/keyserver.go
Normal file
144
cmd/trix/keyserver.go
Normal 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
|
||||
}
|
||||
69
cmd/trix/keyserver_test.go
Normal file
69
cmd/trix/keyserver_test.go
Normal 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
262
cmd/trix/keystore.go
Normal 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
207
cmd/trix/keystore_test.go
Normal 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
143
pkg/keyserver/audit.go
Normal 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
162
pkg/keyserver/audit_test.go
Normal 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
|
||||
}
|
||||
82
pkg/keyserver/capability.go
Normal file
82
pkg/keyserver/capability.go
Normal 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
|
||||
}
|
||||
87
pkg/keyserver/interface.go
Normal file
87
pkg/keyserver/interface.go
Normal 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
340
pkg/keyserver/ops.go
Normal 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
87
pkg/keyserver/server.go
Normal 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
|
||||
}
|
||||
310
pkg/keyserver/server_test.go
Normal file
310
pkg/keyserver/server_test.go
Normal 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
153
pkg/keyserver/session.go
Normal 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
|
||||
}
|
||||
208
pkg/keyserver/session_test.go
Normal file
208
pkg/keyserver/session_test.go
Normal 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
62
pkg/keystore/argon2.go
Normal 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
|
||||
}
|
||||
80
pkg/keystore/argon2_test.go
Normal file
80
pkg/keystore/argon2_test.go
Normal 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
54
pkg/keystore/entry.go
Normal 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
433
pkg/keystore/store.go
Normal 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), ¶ms); err != nil {
|
||||
return nil, fmt.Errorf("keystore: invalid argon2 params: %w", err)
|
||||
}
|
||||
|
||||
// Derive master key
|
||||
master, _, err := DeriveKey(masterPassword, salt, ¶ms)
|
||||
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: ¶ms,
|
||||
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
455
pkg/keystore/store_test.go
Normal 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
64
pkg/keystore/types.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
582
rfcs/RFC-0005-Keyserver-Secure-Environment.md
Normal file
582
rfcs/RFC-0005-Keyserver-Secure-Environment.md
Normal 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
|
||||
Loading…
Add table
Reference in a new issue