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 } 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 }