669bad52af
Add `membershipd bot add --handle <name> --out <path> [--role] [--store]` to provision a bus identity for an automated process in a single step: mint a fresh Ed25519+X25519 identity (cs.GenerateIdentity, the same derivation worker/chat use), register its signing key in the allowlist, and write the credentials to a 0600 file. The file is the canonical identity format read by client.LoadIdentity, so a worker/clientcheck binary pointed at --out connects as the new user with no extra conversion. Shares the sqlite/kv store plumbing with `user add`. New exported pkg/client.WriteNewIdentity writes an identity in that format but refuses to overwrite an existing file (never silently clobber private keys). provisionBot ordering guarantees no half-provisioned bot: refuse an existing --out before touching the store, register (an already-registered key is a clear error, not a panic), then write credentials. Tests cover the golden path (register + 0600 file + LoadIdentity round-trip), default role, the already-registered error path (no file written), and the out-exists error path (no orphan user). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
136 lines
4.7 KiB
Go
136 lines
4.7 KiB
Go
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 <path>`): 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
|
|
}
|