Files
sso-manager-node/nodejs/utils/agent_keys.js
T
wmantly d27763e556 feat(multi-site): live catalog replication + identical-directory signing key
The shipped join flow (v2.2.0-v2.3.0) was a one-time snapshot: a spoke's
catalog never updated after joining. This adds the two pieces that were
explicitly designed but missing:

- Live replication: a spoke registers its own endpoint with the master
  right after joining (POST /api/site/spokes, Bearer join-key), receiving
  a pushToken. Every successful catalog write on the master now fires a
  fire-and-forget resync ping (utils/site_replicate.js) at every known
  spoke, concurrently -- one unreachable spoke never blocks or delays
  another (wired into the existing write-gate middleware in
  api_directory_admin.js). The spoke's POST /api/site/resync handler
  reuses the already-tested export+import path rather than applying a
  partial diff.

- Identical directories: POST /api/site/export now best-effort includes
  the master's agent-signing key; a spoke adopts it via agent_keys.adopt()
  on both join and every resync, so every site's sso-manager can validly
  sign a command for any agent enrolled anywhere -- the accepted tradeoff
  discussed for this deployment's scale (blast radius for simplicity).

New SiteSpoke model tracks registered spokes (endpoint + pushToken);
registered it in models/index.js (a real bug the e2e test below caught --
SiteSpoke.list() 500'd with "Cannot read properties of null (reading
'adapter')" until the model was added to initORM's model list).

Verified end-to-end against docker-compose.multisite-e2e.yml: mint join
key -> join with selfUrl -> write a NEW resource on master post-join ->
poll the spoke -> it shows up within a few seconds via the resync push,
no manual re-join needed. MULTISITE E2E PASS.

Unit tests: nodejs/tests/site_replicate.test.js (concurrent fan-out, one
failing spoke doesn't block another, empty-registry and list()-throws
edge cases).
2026-08-10 16:34:38 -04:00

124 lines
4.8 KiB
JavaScript

'use strict';
// The Ed25519 key pair the SSO signs high-risk agent commands with, stored in
// OpenBao at `secret/agent/signing-key`.
//
// This used to be generated in the AgentManager constructor and kept only in
// memory, which made the whole signing scheme decorative: every SSO restart
// produced a new key, so the `public_key` pinned in an agent's agent.yml stopped
// matching and the agent either rejected everything or (because it skips
// verification when no key is configured) executed everything unverified. A
// trust anchor that changes on restart is not a trust anchor.
//
// Requires the sso-broker OpenBao policy to grant `secret/agent/*`
// (theta-suite setup.sh). Without it the load fails and signing is reported as
// unavailable -- we deliberately do NOT fall back to an ephemeral key, because
// signing with a key no agent has ever seen is worse than refusing: it looks
// like it worked.
const crypto = require('crypto');
const baoConf = require('@simpleworkjs/bao-conf');
const PATH = 'agent/signing-key'; // baoConf adds the secret/data prefix
let cached = null; // { privateKeyPem, publicKeyPem, publicKeyBase64 }
let loadError = null;
// Agents pin the raw 32-byte Ed25519 public key, base64-encoded (see the Go
// client's verifySignature, which base64-decodes cfg.public_key and expects
// ed25519.PublicKeySize bytes). Node hands us SPKI PEM, so strip the 12-byte
// DER prefix to get the raw key the agent actually wants.
function rawPublicKeyBase64(publicKeyPem) {
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
return Buffer.from(der.subarray(der.length - 32)).toString('base64');
}
function generate() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
return { privateKeyPem: privateKey, publicKeyPem: publicKey };
}
// Load the stored key pair, generating and persisting one on first run.
// Idempotent and safe to call repeatedly; the result is cached in-process.
async function load() {
if (cached) return cached;
let stored = null;
try {
stored = await baoConf.get(PATH);
} catch (err) {
loadError = `could not read ${PATH} from OpenBao: ${err.message}`;
console.error(`[agent_keys] ${loadError}`);
return null;
}
if (stored && stored.privateKeyPem && stored.publicKeyPem) {
cached = {
privateKeyPem: stored.privateKeyPem,
publicKeyPem: stored.publicKeyPem,
publicKeyBase64: rawPublicKeyBase64(stored.publicKeyPem)
};
loadError = null;
return cached;
}
// First run: mint one and persist it before use, so a crash between
// generating and storing can't leave agents pinned to a key we forgot.
const fresh = generate();
try {
await baoConf.set(PATH, fresh);
} catch (err) {
loadError = `could not persist a signing key to ${PATH}: ${err.message}. `
+ 'Re-run ./setup.sh so the sso-broker policy grants secret/agent/*.';
console.error(`[agent_keys] ${loadError}`);
return null;
}
cached = {
...fresh,
publicKeyBase64: rawPublicKeyBase64(fresh.publicKeyPem)
};
loadError = null;
console.log('[agent_keys] generated and stored a new agent signing key');
return cached;
}
function status() {
return { available: !!cached, error: loadError };
}
// Overwrite the stored key with material handed over by a master (multi-site
// "identical directories" — MULTI_SITE_SPEC.md §2). Every site sharing one
// signing key is what lets any site's sso-manager validly sign a command for
// an agent enrolled at any other site, at the accepted cost that compromising
// ANY one site's OpenBao is equivalent to compromising all of them for agent
// command authority. That tradeoff was deliberately accepted for this
// deployment's scale (a handful of trusted sites) -- do not call this to sync
// keys across a boundary where sites don't trust each other equally.
//
// Idempotent: adopting the same key material twice (e.g. on every resync
// ping) is a no-op past the first call.
async function adopt({ privateKeyPem, publicKeyPem }) {
if (!privateKeyPem || !publicKeyPem) throw new Error('adopt() requires both privateKeyPem and publicKeyPem');
if (cached && cached.privateKeyPem === privateKeyPem && cached.publicKeyPem === publicKeyPem) {
return cached; // already holding this exact key -- nothing to do
}
const material = { privateKeyPem, publicKeyPem };
await baoConf.set(PATH, material);
cached = { ...material, publicKeyBase64: rawPublicKeyBase64(publicKeyPem) };
loadError = null;
console.log('[agent_keys] adopted signing key from master (multi-site identical-directory sync)');
return cached;
}
// Test seam: drop the in-process cache.
function _reset() {
cached = null;
loadError = null;
}
module.exports = { load, status, adopt, rawPublicKeyBase64, _reset, PATH };