package client import ( "crypto/rand" "encoding/base64" "encoding/json" "fmt" "os" "path/filepath" cs "fn-registry/functions/cybersecurity" "github.com/oklog/ulid/v2" ) // newULID returns a fresh, lexicographically-sortable message id with // crypto/rand entropy. func newULID() string { return ulid.MustNew(ulid.Now(), rand.Reader).String() } // identityFile is the on-disk JSON representation of an Identity. The four key // fields are base64-encoded. // // SECURITY: this file contains the peer's long-term PRIVATE keys (SignPriv and // KexPriv). It is written 0600. Losing it means losing the ability to decrypt // any message addressed to this endpoint — there is no recovery. Treat it like // an SSH private key. (Hardening with OS keyrings/HSM is a later phase.) type identityFile struct { SignPub string `json:"sign_pub"` SignPriv string `json:"sign_priv"` KexPub string `json:"kex_pub"` 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. 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 _, statErr := os.Stat(path); statErr == nil { return LoadIdentity(path) } id, err := cs.GenerateIdentity() if err != nil { return cs.Identity{}, fmt.Errorf("client: generate identity: %w", err) } if err := saveIdentity(path, id); err != nil { return cs.Identity{}, err } return id, nil } // WriteNewIdentity writes id to path in the canonical identity-file format read // by LoadIdentity, but REFUSES to overwrite an existing file: provisioning a new // identity must never silently clobber another process's private keys. The file // is created 0600 (it holds private keys). It is the write half of one-command // bot provisioning (`membershipd bot add --out `): the freshly minted // identity it writes is exactly what LoadIdentity reconstructs, so a bot binary // (worker/clientcheck) consumes the credentials with no extra conversion step. func WriteNewIdentity(path string, id cs.Identity) error { if _, err := os.Stat(path); err == nil { return fmt.Errorf("client: identity file %q already exists; refusing to overwrite", path) } else if !os.IsNotExist(err) { return fmt.Errorf("client: stat identity %q: %w", path, err) } return saveIdentity(path, id) } func saveIdentity(path string, id cs.Identity) error { if dir := filepath.Dir(path); dir != "" { if err := os.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("client: mkdir for identity: %w", err) } } f := identityFile{ SignPub: base64.StdEncoding.EncodeToString(id.SignPub), SignPriv: base64.StdEncoding.EncodeToString(id.SignPriv), KexPub: base64.StdEncoding.EncodeToString(id.KexPub), KexPriv: base64.StdEncoding.EncodeToString(id.KexPriv), } data, err := json.MarshalIndent(f, "", " ") if err != nil { return fmt.Errorf("client: marshal identity: %w", err) } if err := os.WriteFile(path, data, 0o600); err != nil { return fmt.Errorf("client: write identity %q: %w", path, err) } return nil } func (f identityFile) toIdentity() (cs.Identity, error) { dec := func(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) } signPub, err := dec(f.SignPub) if err != nil { return cs.Identity{}, err } signPriv, err := dec(f.SignPriv) if err != nil { return cs.Identity{}, err } kexPub, err := dec(f.KexPub) if err != nil { return cs.Identity{}, err } kexPriv, err := dec(f.KexPriv) if err != nil { return cs.Identity{}, err } return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil }