@uuaid/agent-commons 0.2.0-alpha.2 → 0.2.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONFORMANCE.md +1 -1
- package/NOTICE +4 -7
- package/PROVENANCE.json +12 -17
- package/README.md +103 -12
- package/bin/agent-commons.mjs +8 -3
- package/examples/Dockerfile +1 -1
- package/package.json +5 -2
- package/src/carrier.mjs +7 -2
- package/src/channels.mjs +1 -1
- package/src/config.mjs +2 -1
- package/src/continuity.mjs +14 -7
- package/src/governance-scope.mjs +1 -1
- package/src/governance.mjs +4 -2
- package/src/index.d.ts +3 -1
- package/src/pillar.mjs +2 -2
- package/src/profiles.mjs +2 -1
- package/src/relay/auth.mjs +294 -0
- package/src/relay/board.mjs +223 -0
- package/src/relay/claims.mjs +25 -0
- package/src/relay/cli.mjs +49 -0
- package/src/relay/client.mjs +437 -0
- package/src/relay/docs/reconnect-v1.md +27 -0
- package/src/relay/docs/working-rules-v1.md +42 -0
- package/src/relay/enroll.mjs +232 -0
- package/src/relay/export-cli.mjs +47 -0
- package/src/relay/governance.mjs +1328 -0
- package/src/relay/mailbox.mjs +103 -0
- package/src/relay/records.mjs +146 -0
- package/src/relay/render.mjs +38 -0
- package/src/relay/roster.mjs +134 -0
- package/src/relay/routes.mjs +87 -0
- package/src/relay/schema.mjs +1081 -0
- package/src/relay/seed.mjs +89 -0
- package/src/relay/serve.mjs +116 -0
- package/src/relay/server.mjs +633 -0
- package/src/relay/signing.mjs +693 -0
- package/src/relay/test-mode.mjs +73 -0
- package/src/runtime.mjs +25 -12
- package/src/spaces/manifest.mjs +245 -0
- package/src/spaces/pillar-group.mjs +12 -0
- package/src/spaces/policy.mjs +229 -0
- package/src/spaces/store.mjs +325 -0
- package/src/trust.mjs +44 -5
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +12 -14
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +4 -5
- package/src/vendor/pillar/identity/keychain.mjs +51 -2
- package/src/vendor/pillar/net/carrier-client.mjs +29 -7
- package/src/vendor/pillar/net/refusals.mjs +4 -2
- package/test/channels.test.mjs +0 -268
- package/test/cohort.test.mjs +0 -36
- package/test/collaboration.test.mjs +0 -459
- package/test/continuity.test.mjs +0 -229
- package/test/custody.negative.test.mjs +0 -165
- package/test/derivation-parity.test.mjs +0 -19
- package/test/fixtures/derivation-vectors.json +0 -26
- package/test/fixtures/ed25519-small-order.vectors.json +0 -15
- package/test/governance-expected-red.expected-red.mjs +0 -241
- package/test/governance-scope.test.mjs +0 -142
- package/test/governance-t1-t10.test.mjs +0 -477
- package/test/governance.test.mjs +0 -514
- package/test/hold-admission.test.mjs +0 -1185
- package/test/keychain-strict-ed25519.test.mjs +0 -240
- package/test/offline.test.mjs +0 -152
- package/test/runtime-channels.test.mjs +0 -1140
- package/test/runtime.test.mjs +0 -201
- package/test/security-runtime.test.mjs +0 -104
- package/test/strict-ed25519-consumers.test.mjs +0 -234
- package/test/transport.test.mjs +0 -353
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Cloud Commons relay: the seed bundle (spec v3.4.5 section 8, B5-B7, R5; N22 custody).
|
|
2
|
+
//
|
|
3
|
+
// The seed bundle is the public bootstrap document a member reads with a signed GET /v1/seed. It
|
|
4
|
+
// holds no secret: never a key seed, private key, invite secret, passphrase, provider key, operator
|
|
5
|
+
// token or another member's id. It is signed by the relay key (Ed25519 over JCS of every field but
|
|
6
|
+
// `sig`) and expires within 30 days; status is still checked on every request, so expiry is a hygiene
|
|
7
|
+
// bound, not the revocation mechanism. Only these four values cross a reset: the key seed, `agent`,
|
|
8
|
+
// `relay.origin` and `relay.public_key`; everything else comes back from GET /v1/seed.
|
|
9
|
+
//
|
|
10
|
+
// `rules` and `reconnect` name the two documents in ./docs by sha256. The agent acts only on a
|
|
11
|
+
// rules document whose hash matches the bundle (the reference client ships the same files).
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
16
|
+
import { jcs } from "../pillar.mjs";
|
|
17
|
+
import { RelayError } from "./records.mjs";
|
|
18
|
+
import { ROUTES } from "./routes.mjs";
|
|
19
|
+
import { verifyBytesStrict } from "./auth.mjs";
|
|
20
|
+
|
|
21
|
+
export const SEED_BUNDLE_V = "commons-relay-seed-bundle/1";
|
|
22
|
+
export const RELAY_PROTOCOL = "commons-relay/1";
|
|
23
|
+
export const SEED_BUNDLE_TTL_MS = 30 * 86400000;
|
|
24
|
+
const doc = (name) => readFileSync(new URL(`./docs/${name}`, import.meta.url));
|
|
25
|
+
const sha = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
26
|
+
/** The working-rules document (R2 and R4 restated, the authority invariant, the dedicated-key rule). */
|
|
27
|
+
export const RULES = Object.freeze({ ref: "commons-relay/working-rules/1", version: 1, file: "working-rules-v1.md", sha256: sha(doc("working-rules-v1.md")) });
|
|
28
|
+
/** The reconnect procedure (11.5). */
|
|
29
|
+
export const RECONNECT = Object.freeze({ ref: "commons-relay/reconnect/1", file: "reconnect-v1.md", sha256: sha(doc("reconnect-v1.md")) });
|
|
30
|
+
/** The member endpoints (6.5): every signed route outside /v1/fleet/. */
|
|
31
|
+
export const MEMBER_ENDPOINTS = Object.freeze(ROUTES.filter(r => r.auth === "signed" && r.principal === "member").map(r => `${r.method} ${r.path}`));
|
|
32
|
+
/** Every field a seed bundle may hold (N22: a custody test covers each). */
|
|
33
|
+
export const SEED_BUNDLE_FIELDS = Object.freeze(["v", "relay", "namespace", "member", "rules", "board", "reconnect", "issued_at", "expires_at", "sig"]);
|
|
34
|
+
|
|
35
|
+
/** The unsigned bundle for an active member, read from the relay's own tables. */
|
|
36
|
+
export function seedBundleBody(db, memberId, { aud, relayPublicKey, now }) {
|
|
37
|
+
const m = db.prepare(`SELECT m.member_id, m.status, m.role, p.lane FROM members m JOIN member_profiles p USING(member_id) WHERE m.member_id = ?`).get(memberId);
|
|
38
|
+
if (!m || m.status !== "active") throw new RelayError("unauthorized", 401);
|
|
39
|
+
const keys = db.prepare("SELECT agent FROM keys WHERE principal_kind = 'member' AND principal_id = ? AND status = 'active'").all(memberId);
|
|
40
|
+
if (keys.length !== 1) throw new RelayError("unauthorized", 401);
|
|
41
|
+
return {
|
|
42
|
+
v: SEED_BUNDLE_V,
|
|
43
|
+
relay: { origin: aud, public_key: relayPublicKey, protocol: RELAY_PROTOCOL },
|
|
44
|
+
namespace: "agentnet",
|
|
45
|
+
member: { member_id: m.member_id, agent: keys[0].agent, role: m.role },
|
|
46
|
+
rules: { sha256: RULES.sha256, ref: RULES.ref, version: RULES.version },
|
|
47
|
+
board: { lane: m.lane, endpoints: [...MEMBER_ENDPOINTS] },
|
|
48
|
+
reconnect: { sha256: RECONNECT.sha256, ref: RECONNECT.ref },
|
|
49
|
+
issued_at: now,
|
|
50
|
+
expires_at: now + SEED_BUNDLE_TTL_MS,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Sign a bundle body with the relay key seed (held by the relay only). */
|
|
55
|
+
export function signSeedBundle(body, relaySeedHex) {
|
|
56
|
+
const sig = Buffer.from(ed25519.sign(Buffer.from(jcs(body), "utf8"), Buffer.from(relaySeedHex, "hex"))).toString("hex");
|
|
57
|
+
return { ...body, sig };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The client's check (8, B7): the relay key pinned in the invite or carried across a reset, byte for
|
|
62
|
+
* byte; strict Ed25519 over JCS(bundle minus sig); the version, protocol and namespace; the origin; not
|
|
63
|
+
* expired; the expiry within 30 days of issue; and, when given, the expected member and agent.
|
|
64
|
+
*/
|
|
65
|
+
export function verifySeedBundle(bundle, relayPublicKey, { aud, now, member_id, agent } = {}) {
|
|
66
|
+
const problems = [];
|
|
67
|
+
if (!bundle || typeof bundle !== "object") return { ok: false, problems: ["not-an-object"] };
|
|
68
|
+
for (const k of Object.keys(bundle)) if (!SEED_BUNDLE_FIELDS.includes(k)) problems.push(`unknown-field:${k}`);
|
|
69
|
+
const { sig, ...body } = bundle;
|
|
70
|
+
let bytes = null;
|
|
71
|
+
try { bytes = Buffer.from(jcs(body), "utf8"); } catch { problems.push("not-jcs"); }
|
|
72
|
+
if (!bytes || !verifyBytesStrict(relayPublicKey, bytes, sig)) problems.push("bad-signature");
|
|
73
|
+
if (bundle.relay?.public_key !== relayPublicKey) problems.push("relay-key-mismatch");
|
|
74
|
+
if (bundle.v !== SEED_BUNDLE_V || bundle.relay?.protocol !== RELAY_PROTOCOL || bundle.namespace !== "agentnet") problems.push("version");
|
|
75
|
+
if (aud !== undefined && bundle.relay?.origin !== aud) problems.push("origin-mismatch");
|
|
76
|
+
if (!Number.isSafeInteger(bundle.issued_at) || !Number.isSafeInteger(bundle.expires_at) || bundle.expires_at <= bundle.issued_at
|
|
77
|
+
|| bundle.expires_at - bundle.issued_at > SEED_BUNDLE_TTL_MS) problems.push("validity");
|
|
78
|
+
if (now !== undefined && !(now < bundle.expires_at)) problems.push("expired");
|
|
79
|
+
if (member_id !== undefined && bundle.member?.member_id !== member_id) problems.push("member-mismatch");
|
|
80
|
+
if (agent !== undefined && bundle.member?.agent !== agent) problems.push("agent-mismatch");
|
|
81
|
+
return { ok: problems.length === 0, problems };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The local copy of a document the bundle names, accepted only if its sha256 matches (the agent acts on nothing else). */
|
|
85
|
+
export function trustedDocument(bundleEntry, which = RULES) {
|
|
86
|
+
const bytes = doc(which.file);
|
|
87
|
+
if (!bundleEntry || bundleEntry.sha256 !== sha(bytes) || bundleEntry.ref !== which.ref) throw new RelayError("document-mismatch", 400, which.ref);
|
|
88
|
+
return bytes.toString("utf8");
|
|
89
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Cloud Commons relay: the PRODUCTION entrypoint (security invariant: it has no test flag). It is what
|
|
2
|
+
// `agent-commons relay serve` runs. It is a separate module from the test entrypoint
|
|
3
|
+
// (server.mjs's command line) and it has no test flag: --test-mode, --test-dir and --test-interface are
|
|
4
|
+
// unknown arguments here, and the test audience (or any non-https, non-canonical or `.test`
|
|
5
|
+
// audience) is refused. Every refusal exits with status 2 before anything listens, and the aud and
|
|
6
|
+
// bind are checked before any file is read.
|
|
7
|
+
//
|
|
8
|
+
// agent-commons relay serve --aud https://<host>[:port] --bind <loopback ip> [--port <n>]
|
|
9
|
+
// --db <dir>/relay*.db --roster <signed roster file> --policy <signed policy file>
|
|
10
|
+
// --owner-public-key <64 hex> --relay-key-file <key seed file> [--access-log <file>]
|
|
11
|
+
//
|
|
12
|
+
// Files the owner custodies (formats only; this module mints no key):
|
|
13
|
+
// --relay-key-file one 64-hex Ed25519 key seed (RFC 8032), optionally newline-terminated;
|
|
14
|
+
// --roster the owner-signed roster {body: {v: "commons-relay-roster/1", ...}, sig};
|
|
15
|
+
// --policy the owner-signed policy {body: {v: "commons-relay-policy/1", ...}, sig}.
|
|
16
|
+
// Each must be a regular file (never a symlink) owned by this user with no group or other access
|
|
17
|
+
// (mode 0600 or stricter); anything else is refused. The access log, if named, must not exist yet or
|
|
18
|
+
// be such a file too. The process umask is 077, so relay.db, its WAL and the access log are private.
|
|
19
|
+
//
|
|
20
|
+
// The relay listens on plain HTTP on loopback only; the host's reverse proxy terminates TLS for the
|
|
21
|
+
// configured origin and sets X-Real-IP (server.mjs, production mode).
|
|
22
|
+
|
|
23
|
+
import { lstatSync, readFileSync, appendFileSync, realpathSync } from "node:fs";
|
|
24
|
+
import { pathToFileURL } from "node:url";
|
|
25
|
+
import { StartRefused, checkStart, startRelay } from "./server.mjs";
|
|
26
|
+
import { openRelay } from "./roster.mjs";
|
|
27
|
+
|
|
28
|
+
/** The production flags. Security invariant: no test flag exists here. */
|
|
29
|
+
export const SERVE_FLAGS = Object.freeze(["--aud", "--bind", "--port", "--db", "--roster", "--policy", "--owner-public-key", "--relay-key-file", "--access-log"]);
|
|
30
|
+
const REQUIRED = Object.freeze(["aud", "bind", "db", "roster", "policy", "owner-public-key", "relay-key-file"]);
|
|
31
|
+
|
|
32
|
+
export function parseServeArgs(argv) {
|
|
33
|
+
const out = {};
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const flag = argv[i];
|
|
36
|
+
if (!SERVE_FLAGS.includes(flag)) throw new StartRefused([`unknown argument ${JSON.stringify(String(flag).slice(0, 40))}`]);
|
|
37
|
+
const key = flag.slice(2);
|
|
38
|
+
if (Object.hasOwn(out, key)) throw new StartRefused([`${flag} given twice`]);
|
|
39
|
+
if (i + 1 >= argv.length || argv[i + 1].startsWith("--")) throw new StartRefused([`${flag} needs a value`]);
|
|
40
|
+
out[key] = argv[++i];
|
|
41
|
+
}
|
|
42
|
+
for (const k of REQUIRED) if (typeof out[k] !== "string") throw new StartRefused([`--${k} is required`]);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An owner-custodied file: a regular file of this user, never a symlink, no group or other access. */
|
|
47
|
+
export function readOwnerFile(path, what) {
|
|
48
|
+
let st;
|
|
49
|
+
try { st = lstatSync(path); } catch { throw new StartRefused([`${what} missing`]); }
|
|
50
|
+
if (st.isSymbolicLink() || !st.isFile()) throw new StartRefused([`${what} must be a regular file, not a symlink`]);
|
|
51
|
+
if ((st.mode & 0o077) !== 0) throw new StartRefused([`${what} must have no group or other access (mode 0600)`]);
|
|
52
|
+
if (typeof process.getuid === "function" && st.uid !== process.getuid()) throw new StartRefused([`${what} must be owned by this user`]);
|
|
53
|
+
return readFileSync(path, "utf8");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The relay key seed file: exactly one 64-hex seed. */
|
|
57
|
+
export function loadRelayKeySeed(path) {
|
|
58
|
+
const seed = readOwnerFile(path, "--relay-key-file").replace(/\n$/, "");
|
|
59
|
+
if (!/^[0-9a-f]{64}$/.test(seed)) throw new StartRefused(["--relay-key-file must hold exactly one 64-hex key seed"]);
|
|
60
|
+
return seed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A signed roster or policy file: JSON {body, sig}; its signature and contents are checked by openRelay. */
|
|
64
|
+
export function loadSignedFile(path, what) {
|
|
65
|
+
let parsed;
|
|
66
|
+
try { parsed = JSON.parse(readOwnerFile(path, what)); } catch (error) { if (error instanceof StartRefused) throw error; throw new StartRefused([`${what} is not JSON`]); }
|
|
67
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).sort().join(",") !== "body,sig")
|
|
68
|
+
throw new StartRefused([`${what} must be a signed file {body, sig}`]);
|
|
69
|
+
return parsed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** An access log path: absent, or an owner-only regular file. */
|
|
73
|
+
function checkLogPath(path) {
|
|
74
|
+
let st;
|
|
75
|
+
try { st = lstatSync(path); } catch { return; }
|
|
76
|
+
if (st.isSymbolicLink() || !st.isFile() || (st.mode & 0o077) !== 0 || (typeof process.getuid === "function" && st.uid !== process.getuid()))
|
|
77
|
+
throw new StartRefused(["--access-log must be absent or a private regular file of this user"]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The production command line. Exit status 2 on any refusal, before anything listens. */
|
|
81
|
+
export async function serveMain(argv = process.argv.slice(2), { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
82
|
+
let relay, db;
|
|
83
|
+
try {
|
|
84
|
+
const o = parseServeArgs(argv);
|
|
85
|
+
// The aud and the bind first: nothing is read before they pass.
|
|
86
|
+
checkStart({ aud: o.aud, host: o.bind, mode: "production" });
|
|
87
|
+
const port = o.port === undefined ? 0 : /^(0|[1-9][0-9]{0,4})$/.test(o.port) && Number(o.port) <= 65535 ? Number(o.port) : NaN;
|
|
88
|
+
if (Number.isNaN(port)) throw new StartRefused(["--port must be 0-65535"]);
|
|
89
|
+
if (!/^[0-9a-f]{64}$/.test(o["owner-public-key"])) throw new StartRefused(["--owner-public-key must be 64 lower-case hex"]);
|
|
90
|
+
process.umask(0o077);
|
|
91
|
+
const seed = loadRelayKeySeed(o["relay-key-file"]);
|
|
92
|
+
const roster = loadSignedFile(o.roster, "--roster"), policy = loadSignedFile(o.policy, "--policy");
|
|
93
|
+
if (o["access-log"] !== undefined) checkLogPath(o["access-log"]);
|
|
94
|
+
db = openRelay({ path: o.db, roster, policy, ownerPublicKey: o["owner-public-key"], now: Date.now() });
|
|
95
|
+
const log = o["access-log"];
|
|
96
|
+
const accessLog = (entry) => {
|
|
97
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
98
|
+
if (log) appendFileSync(log, line, { mode: 0o600 }); else stderr.write(`access ${line}`);
|
|
99
|
+
};
|
|
100
|
+
relay = await startRelay({ db, aud: o.aud, host: o.bind, port, relayKeySeedHex: seed, accessLog, mode: "production",
|
|
101
|
+
onError: (error) => stderr.write(`relay error: ${String(error?.stack ?? error).slice(0, 2000)}\n`) });
|
|
102
|
+
} catch (error) {
|
|
103
|
+
stderr.write(`${error instanceof StartRefused ? error.message : `relay start refused: ${String(error?.message ?? error).slice(0, 500)}`}\n`);
|
|
104
|
+
try { db?.close(); } catch { /* already closed */ }
|
|
105
|
+
process.exitCode = 2;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
stdout.write(`${JSON.stringify({ listening: relay.url, relay_public_key: relay.publicKey, mode: relay.mode, aud: argv[argv.indexOf("--aud") + 1] })}\n`);
|
|
109
|
+
const shutdown = async () => { await relay.close(); db.close(); process.exit(0); };
|
|
110
|
+
process.once("SIGTERM", shutdown);
|
|
111
|
+
process.once("SIGINT", shutdown);
|
|
112
|
+
return relay;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const invoked = (() => { try { return process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href; } catch { return false; } })();
|
|
116
|
+
if (invoked) await serveMain();
|