From 02c2004ebd57705ed7709c71be57bcc044b0d28f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 19:41:38 +0200 Subject: [PATCH] feat(membershipd): user add/list/revoke --store kv against a live cluster Closes the most valuable 0011 deploy gap: adding users to the running cluster's replicated allowlist with no stop-seed-restart. Under enforce the per-subject ACL confines every bus user to its own rooms, so no ordinary identity may write the control-plane KV buckets; the only identity the authenticator grants full JetStream permissions is membershipd's internal service identity. - main.go: --internal-id-file persists that identity (load-or-create, 0600) instead of a fresh ephemeral key, so the same nkey is available out of process. Empty keeps the ephemeral default (single-node/dev unchanged). - users_kv.go: connectKVStore loads the persisted identity, presents its nkey (recognized as internal -> full perms), opens the KV store and writes. Defaults assume an on-node loopback invocation; a remote target without --ca is refused (allowlist must not travel cleartext, audit N6). Prints KV_UNIBUS_users replication (followers_current) after a write. - users_cli.go: --store kv on add/list/revoke. Re-adding a key is an explicit ErrUserExists (no silent overwrite / role flip); revoke is a status flip. - pkg/client: LoadIdentity (load-only) extracted from LoadOrCreateIdentity, preserving its "corrupt file is an error, not silently regenerated" guard. - kv_useradd_test.go: golden write under enforce, idempotency, unreachable endpoint, and remote-without-CA refusal against an embedded node. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/membershipd/kv_useradd_test.go | 152 +++++++++++++++++++++++++++++ cmd/membershipd/main.go | 30 +++++- cmd/membershipd/users_cli.go | 90 +++++++++++++++-- cmd/membershipd/users_kv.go | 151 ++++++++++++++++++++++++++++ pkg/client/identity.go | 38 +++++--- 5 files changed, 439 insertions(+), 22 deletions(-) create mode 100644 cmd/membershipd/kv_useradd_test.go create mode 100644 cmd/membershipd/users_kv.go diff --git a/cmd/membershipd/kv_useradd_test.go b/cmd/membershipd/kv_useradd_test.go new file mode 100644 index 0000000..60828bd --- /dev/null +++ b/cmd/membershipd/kv_useradd_test.go @@ -0,0 +1,152 @@ +package main + +// Integration tests for issue 0011 GAP A: `membershipd user add --store kv` +// adds users to a RUNNING cluster's replicated allowlist via the privileged +// internal connection, instead of the stop-seed-restart procedure the 0011 +// deploy required. These exercise the real connectKVStore path (load the +// persisted internal identity from a file, present its nkey, open the KV store, +// write the user) against an embedded enforce node, plus the idempotency and +// error semantics the DoD calls for. Multi-node replication and node-down quorum +// are validated against the live cluster (report 0012). + +import ( + "encoding/hex" + "errors" + "path/filepath" + "testing" + "time" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/busauth" + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/embeddednats" + "github.com/enmanuel/unibus/pkg/membership" +) + +// startEnforceKVNode boots a single embedded enforce node whose authenticator +// recognizes internalPubHex as the privileged internal identity, bootstraps the +// KV control-plane store over the in-process internal connection, and publishes +// it into the holder — the exact sequence main.go performs for --store kv. It +// returns the client URL the CLI connects to. +func startEnforceKVNode(t *testing.T, internalID cs.Identity) string { + t.Helper() + holder := &storeHolder{} + auth := busauth.NewNkeyAuthenticatorACLInternal( + holder.IsAuthorized, + busauth.PermissionsFromSubjects(holder.subjectACL), + hex.EncodeToString(internalID.SignPub), + ) + ns, err := embeddednats.StartServer(embeddednats.ServerConfig{ + StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), Auth: auth, + }) + if err != nil { + t.Fatalf("start enforce node: %v", err) + } + t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() }) + + intNC, js, err := connectInternalJS(ns, internalID, true) + if err != nil { + t.Fatalf("bootstrap internal connection: %v", err) + } + t.Cleanup(intNC.Close) + kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second}) + if err != nil { + t.Fatalf("bootstrap KV store: %v", err) + } + holder.set(kvStore) + return ns.ClientURL() +} + +// TestUserAddStoreKV_GoldenAndIdempotent is the GAP A golden + edge-1: the CLI +// connection (real connectKVStore, loading the internal identity from a file and +// presenting its nkey) writes a user into the live KV allowlist, the user is +// authorized afterward, and re-adding the same key is an explicit ErrUserExists +// with no corruption (the unchanged row is still authorized). +func TestUserAddStoreKV_GoldenAndIdempotent(t *testing.T) { + idFile := filepath.Join(t.TempDir(), "internal.id") + internalID, err := client.LoadOrCreateIdentity(idFile) // persists 0600 + if err != nil { + t.Fatalf("persist internal identity: %v", err) + } + url := startEnforceKVNode(t, internalID) + + // Golden: connect as the privileged internal identity (loopback, no TLS) and + // add a new user, exactly as `user add --store kv` does. + kv, err := connectKVStore(url, idFile, "", 1) + if err != nil { + t.Fatalf("connectKVStore (privileged): %v", err) + } + defer kv.Close() + + newUser, err := cs.GenerateIdentity() + if err != nil { + t.Fatalf("new user identity: %v", err) + } + pub := hex.EncodeToString(newUser.SignPub) + if err := kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember); err != nil { + t.Fatalf("add user to live KV: %v", err) + } + if !kv.store.IsAuthorized(pub) { + t.Fatalf("user added to KV must be authorized") + } + + // Edge 1: re-adding the same key is a clean, non-destructive ErrUserExists. + err = kv.store.AddUser(pub, "gapcheck_user", membership.RoleMember) + if !errors.Is(err, membership.ErrUserExists) { + t.Fatalf("re-add must return ErrUserExists (idempotent), got %v", err) + } + // A different handle/role with the SAME key is also rejected — the row is not + // silently overwritten (no role flip). + if err := kv.store.AddUser(pub, "impostor", membership.RoleAdmin); !errors.Is(err, membership.ErrUserExists) { + t.Fatalf("re-add with a different role must NOT overwrite; want ErrUserExists, got %v", err) + } + u, err := kv.store.GetUser(pub) + if err != nil { + t.Fatalf("get user: %v", err) + } + if u.Handle != "gapcheck_user" || u.Role != membership.RoleMember || u.Status != membership.StatusActive { + t.Fatalf("idempotent re-add corrupted the row: %+v", u) + } +} + +// TestUserAddStoreKV_RequiresInternalIdentity: --store kv without a usable +// internal identity file fails loudly (missing file, empty path) rather than +// silently connecting unprivileged. +func TestUserAddStoreKV_RequiresInternalIdentity(t *testing.T) { + if _, err := connectKVStore("nats://127.0.0.1:4250", "", "", 1); err == nil { + t.Fatalf("empty --internal-id-file must be an error") + } + missing := filepath.Join(t.TempDir(), "nope.id") + if _, err := connectKVStore("nats://127.0.0.1:4250", missing, "", 1); err == nil { + t.Fatalf("missing internal identity file must be an error") + } +} + +// TestUserAddStoreKV_UnreachableKV is the GAP A error case: pointing --store kv +// at a dead endpoint yields a clear, handled error (no crash, no silent success). +func TestUserAddStoreKV_UnreachableKV(t *testing.T) { + idFile := filepath.Join(t.TempDir(), "internal.id") + if _, err := client.LoadOrCreateIdentity(idFile); err != nil { + t.Fatalf("persist internal identity: %v", err) + } + // A loopback port with nothing listening: connect must fail fast and wrapped. + _, err := connectKVStore("nats://127.0.0.1:1/", idFile, "", 1) + if err == nil { + t.Fatalf("connecting to a dead endpoint must error") + } +} + +// TestUserAddStoreKV_RemoteWithoutCARefused: a non-loopback target without --ca +// is refused so the allowlist write never travels in cleartext (audit 0008 N6, +// same guard as migrate-to-kv). +func TestUserAddStoreKV_RemoteWithoutCARefused(t *testing.T) { + idFile := filepath.Join(t.TempDir(), "internal.id") + if _, err := client.LoadOrCreateIdentity(idFile); err != nil { + t.Fatalf("persist internal identity: %v", err) + } + _, err := connectKVStore("nats://203.0.113.1:4250", idFile, "", 1) + if err == nil { + t.Fatalf("remote target without --ca must be refused") + } +} diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index a789c01..7637c0f 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -24,6 +24,7 @@ import ( "github.com/enmanuel/unibus/pkg/blobstore" "github.com/enmanuel/unibus/pkg/busauth" + "github.com/enmanuel/unibus/pkg/client" "github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/membership" ) @@ -83,6 +84,17 @@ func main() { // "kv" puts rooms/members/keys/users in replicated JetStream KV so any node // in the cluster serves the same state. storeBackend = flag.String("store", "sqlite", "control-plane store backend: sqlite (default, single-node) | kv (replicated JetStream, decentralized)") + // Persisted internal service identity (issue 0011 gaps, GAP A): when set, the + // privileged internal identity used to manage JetStream is LOADED from this + // file (generated and persisted on first start) instead of being a fresh + // ephemeral key each boot. Persisting it is what lets `membershipd user add + // --store kv` write the replicated allowlist of a LIVE cluster: that CLI, + // run over loopback on a node, loads the SAME identity and presents the nkey + // this node's authenticator already grants full permissions. Empty keeps the + // ephemeral-per-process behavior (single-node/dev default, unchanged). The + // file holds a private key: it is written 0600 and belongs next to the node's + // TLS keys (deploy keeps it under secrets/, gitignored). + internalIDFile = flag.String("internal-id-file", "", "path to a persisted internal service identity (JSON); enables `membershipd user add --store kv` against the live cluster. Empty = ephemeral per-process identity (dev default)") ) flag.Parse() @@ -136,9 +148,21 @@ func main() { var internalID cs.Identity var internalPubHex string if needJS && enforce && *natsURL == "" { - internalID, err = cs.GenerateIdentity() - if err != nil { - log.Fatalf("generate internal identity: %v", err) + if *internalIDFile != "" { + // Persisted identity: load it, generating + writing it (0600) on first + // start. A stable internal key is what `user add --store kv` presents to + // add users to a live cluster (GAP A); rotate it by deleting the file and + // restarting. + internalID, err = client.LoadOrCreateIdentity(*internalIDFile) + if err != nil { + log.Fatalf("load internal service identity %q: %v", *internalIDFile, err) + } + log.Printf("internal service identity: persisted (%s)", *internalIDFile) + } else { + internalID, err = cs.GenerateIdentity() + if err != nil { + log.Fatalf("generate internal identity: %v", err) + } } internalPubHex = hex.EncodeToString(internalID.SignPub) } diff --git a/cmd/membershipd/users_cli.go b/cmd/membershipd/users_cli.go index 5dedeff..b44ea1b 100644 --- a/cmd/membershipd/users_cli.go +++ b/cmd/membershipd/users_cli.go @@ -2,6 +2,7 @@ package main import ( "encoding/hex" + "errors" "flag" "fmt" "os" @@ -50,13 +51,26 @@ commands: list List all registered users revoke Revoke a user (denies access on both planes immediately) +store backends (--store): + sqlite local SQLite database (default; seeds the first admin offline) + kv the RUNNING cluster's replicated JetStream KV allowlist, via the + privileged internal connection — add users with the cluster live, + no stop-seed-restart needed (run over loopback/SSH on a node) + examples: membershipd user add --handle alice --sign-pub <64-hex> --role admin - membershipd user list + membershipd user add --store kv --handle bob --sign-pub <64-hex> --role member + membershipd user list --store kv membershipd user revoke <64-hex> common flags: - --db SQLite database path (default ./local_files/unibus.db) + --db SQLite database path (--store sqlite; default ./local_files/unibus.db) + +--store kv flags (defaults assume an on-node invocation): + --nats-url cluster NATS (default nats://127.0.0.1:4250) + --internal-id-file persisted internal service identity (default /opt/unibus/secrets/internal.id) + --ca CA cert pinning the data-plane TLS (default /opt/unibus/tls/ca.crt) + --kv-replicas KV replication factor, match the cluster (default 3) `) } @@ -88,12 +102,59 @@ func validateSignPubHex(signPub string) error { return nil } +// kvFlags holds the connection flags shared by the --store kv path of the user +// subcommands. registerKVFlags wires them onto a flag set so add and list expose +// an identical interface. +type kvFlags struct { + store *string + natsURL *string + internalID *string + ca *string + replicas *int +} + +func registerKVFlags(fs *flag.FlagSet) kvFlags { + return kvFlags{ + store: fs.String("store", "sqlite", "user store backend: sqlite (local DB) | kv (the live cluster's replicated allowlist)"), + natsURL: fs.String("nats-url", defaultClusterNatsURL, "cluster NATS url for --store kv"), + internalID: fs.String("internal-id-file", defaultInternalIDFile, "persisted internal service identity for --store kv"), + ca: fs.String("ca", defaultClusterCAFile, "CA cert pinning TLS on the --store kv NATS connection"), + replicas: fs.Int("kv-replicas", 3, "KV replication factor for --store kv (match the cluster)"), + } +} + +// resolveStore returns the membership store for the chosen backend plus a cleanup +// func. For --store kv it opens the privileged connection to the live cluster; for +// sqlite it opens the local file. It exits the process with a clear message on any +// failure (a dead NATS, a missing identity file), so a broken --store kv add fails +// loudly instead of silently — Error case of the GAP A DoD. The returned *kvConn +// is non-nil only for the kv backend (so the caller can report replication). +func resolveStore(cmd string, kf kvFlags, dbPath string) (membership.Store, *kvConn, func()) { + switch *kf.store { + case "sqlite": + store := openStore(dbPath) + return store, nil, func() { store.Close() } + case "kv": + kv, err := connectKVStore(*kf.natsURL, *kf.internalID, *kf.ca, *kf.replicas) + if err != nil { + fmt.Fprintf(os.Stderr, "membershipd %s: --store kv: %v\n", cmd, err) + os.Exit(1) + } + return kv.store, kv, kv.Close + default: + fmt.Fprintf(os.Stderr, "membershipd %s: --store must be \"sqlite\" or \"kv\", got %q\n", cmd, *kf.store) + os.Exit(2) + return nil, nil, func() {} + } +} + func userAdd(args []string) { fs := flag.NewFlagSet("user add", flag.ExitOnError) handle := fs.String("handle", "", "human-readable user name (required)") signPub := fs.String("sign-pub", "", "Ed25519 signing public key in hex (required)") role := fs.String("role", membership.RoleMember, "role: admin or member") dbPath := fs.String("db", defaultDBPath, "SQLite database path") + kf := registerKVFlags(fs) _ = fs.Parse(args) if *handle == "" || *signPub == "" { @@ -105,23 +166,35 @@ func userAdd(args []string) { os.Exit(2) } - store := openStore(*dbPath) - defer store.Close() + store, kv, closeStore := resolveStore("user add", kf, *dbPath) + defer closeStore() if err := store.AddUser(*signPub, *handle, *role); err != nil { + if errors.Is(err, membership.ErrUserExists) { + // Idempotency contract (GAP A): re-adding the same key is an EXPLICIT, + // non-destructive error — the existing row is left untouched (no silent + // upsert that could flip a role or clobber status, which would corrupt the + // allowlist). To replace a user, `user revoke ` then add again. + fmt.Fprintf(os.Stderr, "membershipd user add: user %s already registered (unchanged); revoke it first to replace\n", *signPub) + os.Exit(1) + } fmt.Fprintf(os.Stderr, "membershipd user add: %v\n", err) os.Exit(1) } fmt.Printf("added user %q (%s) role=%s\n", *handle, *signPub, *role) + if kv != nil { + reportKVReplication(kv.js) + } } func userList(args []string) { fs := flag.NewFlagSet("user list", flag.ExitOnError) dbPath := fs.String("db", defaultDBPath, "SQLite database path") + kf := registerKVFlags(fs) _ = fs.Parse(args) - store := openStore(*dbPath) - defer store.Close() + store, _, closeStore := resolveStore("user list", kf, *dbPath) + defer closeStore() users, err := store.ListUsers() if err != nil { @@ -143,6 +216,7 @@ func userList(args []string) { func userRevoke(args []string) { fs := flag.NewFlagSet("user revoke", flag.ExitOnError) dbPath := fs.String("db", defaultDBPath, "SQLite database path") + kf := registerKVFlags(fs) // Go's flag package stops at the first non-flag argument, so `revoke // --db path` would otherwise leave --db unparsed. Pull a leading positional @@ -167,8 +241,8 @@ func userRevoke(args []string) { os.Exit(2) } - store := openStore(*dbPath) - defer store.Close() + store, _, closeStore := resolveStore("user revoke", kf, *dbPath) + defer closeStore() if err := store.RevokeUser(signPub); err != nil { fmt.Fprintf(os.Stderr, "membershipd user revoke: %v\n", err) diff --git a/cmd/membershipd/users_kv.go b/cmd/membershipd/users_kv.go new file mode 100644 index 0000000..bdcbb57 --- /dev/null +++ b/cmd/membershipd/users_kv.go @@ -0,0 +1,151 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/enmanuel/unibus/pkg/busauth" + "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/membership" + "github.com/nats-io/nats.go" + "github.com/nats-io/nats.go/jetstream" +) + +// users_kv.go is the `--store kv` half of the user administration CLI (issue 0011 +// gaps, GAP A): adding and listing bus users directly against the RUNNING +// cluster's replicated JetStream KV allowlist, with no need to stop the cluster, +// seed a standalone node, and restart (the procedure the 0011 deploy required). +// +// The mechanism is the cluster's own privileged internal connection. Under +// enforce every bus user is confined by the per-subject ACL to the JetStream API +// of its own rooms, so no ordinary identity may touch the control-plane buckets +// (KV_UNIBUS_*). The ONLY identity the authenticator grants full JetStream +// permissions is membershipd's internal service identity. By persisting that +// identity to a file (membershipd --internal-id-file) the same key becomes +// available to this CLI, which presents it as its NATS nkey and is therefore +// recognized as the privileged internal client and allowed to read/write the KV. +// +// Intended invocation is over loopback on a cluster node (SSH): the data-plane +// TLS certificate's SAN covers 127.0.0.1/localhost and the internal identity file +// lives 0600 next to the node's TLS keys. Using the file requires root on the +// node, which already implies full control of that node — so co-locating it adds +// no practical exposure beyond what the TLS server key and cluster password +// already represent. + +// defaultClusterNatsURL is the node-local NATS listener. The CLI is meant to run +// on a cluster node over SSH, talking to that node's own embedded server. +const defaultClusterNatsURL = "nats://127.0.0.1:4250" + +// Deploy-default paths for the privileged identity and the data-plane CA, so an +// on-node invocation needs only --handle/--sign-pub/--role. Override for other +// layouts. +const ( + defaultInternalIDFile = "/opt/unibus/secrets/internal.id" + defaultClusterCAFile = "/opt/unibus/tls/ca.crt" +) + +// kvConn bundles the privileged NATS connection to a live cluster and the +// KV-backed control-plane store opened over it. Close releases both. +type kvConn struct { + nc *nats.Conn + js jetstream.JetStream + store membership.Store +} + +func (k *kvConn) Close() { + if k == nil { + return + } + if k.store != nil { + _ = k.store.Close() + } + if k.nc != nil { + k.nc.Close() + } +} + +// connectKVStore opens the privileged internal connection to the cluster's NATS +// and the JetStream KV control-plane store on top of it. internalIDFile is the +// membershipd-persisted internal service identity whose nkey the authenticator +// grants full permissions; caPath pins the data-plane TLS (empty only for a +// non-TLS dev cluster). A non-loopback target without --ca is refused, mirroring +// migrate-to-kv (audit 0008 N6): the allowlist write must not travel in cleartext. +func connectKVStore(natsURL, internalIDFile, caPath string, replicas int) (*kvConn, error) { + if internalIDFile == "" { + return nil, fmt.Errorf("--internal-id-file is required for --store kv (the privileged identity membershipd persists with --internal-id-file)") + } + // Confidentiality guard: a remote NATS without TLS would expose the allowlist + // (handles/roles/sign-pubs) and the privileged nkey handshake in cleartext. + if !isLoopbackURL(natsURL) && caPath == "" { + return nil, fmt.Errorf("refusing to connect to remote %q without --ca: the allowlist write would travel in cleartext — pin TLS with --ca, or run over a loopback --nats-url on a node", natsURL) + } + + id, err := client.LoadIdentity(internalIDFile) + if err != nil { + return nil, fmt.Errorf("load internal identity: %w", err) + } + nkeyPub, nkeySign, err := busauth.ClientNkey(id.SignPriv) + if err != nil { + return nil, fmt.Errorf("derive nkey from internal identity: %w", err) + } + opts := []nats.Option{ + nats.Name("membershipd-user-cli"), + nats.Nkey(nkeyPub, nkeySign), + } + if caPath != "" { + tlsCfg, err := busauth.LoadCATLSConfig(caPath) + if err != nil { + return nil, fmt.Errorf("load CA %q: %w", caPath, err) + } + opts = append(opts, nats.Secure(tlsCfg)) + } + nc, err := nats.Connect(natsURL, opts...) + if err != nil { + return nil, fmt.Errorf("connect cluster NATS %q: %w", natsURL, err) + } + js, err := jetstream.New(nc) + if err != nil { + nc.Close() + return nil, fmt.Errorf("jetstream: %w", err) + } + store, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: replicas}) + if err != nil { + nc.Close() + return nil, fmt.Errorf("open KV control-plane store: %w", err) + } + return &kvConn{nc: nc, js: js, store: store}, nil +} + +// reportKVReplication prints the replication status of the allowlist bucket +// stream (KV_UNIBUS_users) right after a write, so the operator sees the add +// landed on a quorum and replicated to the followers — executable evidence that +// the live-cluster add is HA, not single-node. Best-effort: a read failure is a +// note, not an error (the write itself already succeeded). +func reportKVReplication(js jetstream.JetStream) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + st, err := js.Stream(ctx, "KV_UNIBUS_users") + if err != nil { + fmt.Fprintf(os.Stderr, "note: could not read KV_UNIBUS_users stream info: %v\n", err) + return + } + info, err := st.Info(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "note: could not read KV_UNIBUS_users stream info: %v\n", err) + return + } + if info.Cluster == nil { + fmt.Printf("KV_UNIBUS_users: standalone (R1, no cluster replication); msgs=%d\n", info.State.Msgs) + return + } + current := 0 + for _, r := range info.Cluster.Replicas { + if r.Current { + current++ + } + } + fmt.Printf("KV_UNIBUS_users: leader=%s followers_current=%d/%d msgs=%d\n", + info.Cluster.Leader, current, len(info.Cluster.Replicas), info.State.Msgs) +} diff --git a/pkg/client/identity.go b/pkg/client/identity.go index 24c181b..2a2aa53 100644 --- a/pkg/client/identity.go +++ b/pkg/client/identity.go @@ -33,20 +33,36 @@ type identityFile struct { KexPriv string `json:"kex_priv"` } +// LoadIdentity loads an existing identity from path. Unlike LoadOrCreateIdentity +// it NEVER creates one: a missing or unreadable file is an error. It is for +// callers that must consume a specific, pre-provisioned identity rather than mint +// a fresh one — for example membershipd's persisted internal service identity, +// which `membershipd user add --store kv` reads to present the privileged nkey +// the cluster authenticator recognizes. +func LoadIdentity(path string) (cs.Identity, error) { + data, err := os.ReadFile(path) + if err != nil { + return cs.Identity{}, fmt.Errorf("client: read identity %q: %w", path, err) + } + var f identityFile + if err := json.Unmarshal(data, &f); err != nil { + return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err) + } + id, err := f.toIdentity() + if err != nil { + return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err) + } + return id, nil +} + // LoadOrCreateIdentity loads the identity at path, or generates and persists a // new one if the file does not exist. The file is written with 0600 -// permissions because it holds private keys. +// permissions because it holds private keys. A file that exists but is +// unreadable or corrupt is an error (NOT silently regenerated), so a damaged +// identity surfaces instead of minting a new key that cannot decrypt old data. func LoadOrCreateIdentity(path string) (cs.Identity, error) { - if data, err := os.ReadFile(path); err == nil { - var f identityFile - if err := json.Unmarshal(data, &f); err != nil { - return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err) - } - id, err := f.toIdentity() - if err != nil { - return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err) - } - return id, nil + if _, statErr := os.Stat(path); statErr == nil { + return LoadIdentity(path) } id, err := cs.GenerateIdentity()