@staix/agent-hub 0.19.0 → 0.21.0
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/CHANGELOG.md +70 -3
- package/README.md +2 -2
- package/docs/agent-notes/README.md +11 -0
- package/docs/agent-notes/adapters.md +10 -5
- package/docs/agent-notes/benchmarks.md +27 -0
- package/docs/agent-notes/budget.md +2 -1
- package/docs/agent-notes/bus.md +3 -3
- package/docs/agent-notes/daemon.md +20 -7
- package/docs/agent-notes/local-worker.md +5 -0
- package/docs/agent-notes/models.md +2 -0
- package/docs/agent-notes/tasks.md +20 -10
- package/docs/agent-notes/tests.md +13 -5
- package/docs/bench.md +1 -1
- package/docs/data-policy.md +2 -0
- package/docs/dogfood.md +41 -5
- package/docs/events.md +2 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +598 -63
- package/docs/profiles.md +13 -1
- package/docs/quickstart.md +18 -9
- package/docs/research.md +7 -4
- package/docs/sdlc.md +66 -10
- package/docs/security.md +397 -35
- package/docs/smoke.md +127 -0
- package/docs/specs/2026-09-19-agent-hub-design.md +1149 -19
- package/docs/verification/2026-10-27-0.19.0.md +5 -5
- package/docs/verification/2026-10-27-0.20.0.md +75 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-bash-permission.json +635 -0
- package/docs/verification/2026-10-28-claude-2.1.311-2.1.312-permission-transport.json +403 -0
- package/docs/verification/2026-10-28-claude-implementer-permission-triage.md +123 -0
- package/docs/verification/2026-10-28-jcode-0.99.1-usage.md +41 -0
- package/docs/verification/2026-10-28-realpath-name-audit.md +222 -0
- package/docs/verification/2026-10-28-routing-f0bb8ad0.md +1000 -0
- package/docs/verification/2026-10-29-0.21.0.md +61 -0
- package/docs/verification/2026-10-29-model-effort-capabilities.md +123 -0
- package/docs/verification/2026-10-29-modelrouting-b152ddb9.md +953 -0
- package/docs/verification/claude-implementer-permission-triage.ts +160 -0
- package/docs/verification/claude-permission-transport-native.py +71 -14
- package/docs/verification/profiles/YYYY-MM-DD-research.md +1 -1
- package/docs/verified.json +26 -18
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +1116 -284
- package/src/adapters/acp.ts +87 -3
- package/src/adapters/channel-peer.ts +11 -5
- package/src/adapters/claude-channel.ts +13 -6
- package/src/adapters/claude-implementer.ts +61 -7
- package/src/adapters/codex-appserver.ts +88 -5
- package/src/adapters/conformance/enrollment.ts +4 -4
- package/src/adapters/conformance/factories.ts +2 -1
- package/src/adapters/conformance/fakes/jcode-agent.ts +6 -2
- package/src/adapters/conformance/kit.ts +10 -6
- package/src/adapters/definitions/builtin-metadata.ts +3 -114
- package/src/adapters/definitions/native-config-defaults.ts +1 -1
- package/src/adapters/definitions/native-config-policy.ts +3 -2
- package/src/adapters/definitions/native-context.ts +12 -0
- package/src/adapters/external-metadata.ts +48 -0
- package/src/adapters/external-modules.ts +63 -3
- package/src/adapters/jcode.ts +6 -2
- package/src/adapters/local-worker.ts +42 -25
- package/src/adapters/native-proof-ports.ts +10 -0
- package/src/adapters/pi.ts +7 -0
- package/src/adapters/registry-types.ts +4 -0
- package/src/adapters/reviewer-print.ts +1 -0
- package/src/broker/protocol.ts +203 -0
- package/src/broker/server.ts +314 -0
- package/src/broker/store.ts +548 -0
- package/src/cli/bench.ts +3 -2
- package/src/cli/broker.ts +58 -0
- package/src/cli/conductor-recovery.ts +142 -0
- package/src/cli/configured-acp-entry.ts +26 -6
- package/src/cli/console-state.ts +59 -17
- package/src/cli/console.ts +17 -2
- package/src/cli/facts-hook.ts +3 -1
- package/src/cli/help.ts +28 -16
- package/src/cli/identity.ts +23 -10
- package/src/cli/init.ts +34 -7
- package/src/cli/intake-output.ts +33 -0
- package/src/cli/main.ts +314 -71
- package/src/cli/output.ts +41 -1
- package/src/cli/recovery-package.ts +18 -1
- package/src/cli/role-holders.ts +35 -0
- package/src/cli/room.ts +304 -0
- package/src/cli/say-input.ts +16 -0
- package/src/cli/schedule-output.ts +49 -0
- package/src/cli/sdlc-output.ts +44 -6
- package/src/cli/smoke-pty-bridge.ts +33 -0
- package/src/cli/smoke-pty.ts +145 -0
- package/src/cli/smoke-run.ts +139 -0
- package/src/cli/smoke-target.ts +130 -0
- package/src/cli/source-output.ts +29 -0
- package/src/cli/task-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +98 -16
- package/src/cli/upgrade.ts +66 -7
- package/src/hub/ask.ts +76 -12
- package/src/hub/board.ts +132 -22
- package/src/hub/bus.ts +399 -56
- package/src/hub/child-process.ts +2 -0
- package/src/hub/ci-watch.ts +162 -0
- package/src/hub/claude-failure-stage.ts +85 -0
- package/src/hub/claude-launch-proof.ts +67 -2
- package/src/hub/claude-shell-wrapper.ts +11 -5
- package/src/hub/conductor.ts +17 -4
- package/src/hub/config-apply.ts +32 -5
- package/src/hub/config-reload.ts +7 -5
- package/src/hub/config-trust.ts +8 -1
- package/src/hub/context-window.ts +33 -3
- package/src/hub/control-client.ts +14 -2
- package/src/hub/corpus-config.ts +34 -0
- package/src/hub/corpus-evidence.ts +184 -0
- package/src/hub/corpus.ts +373 -0
- package/src/hub/cost-governance.ts +26 -0
- package/src/hub/daemon.ts +1228 -242
- package/src/hub/deliverable-files.ts +8 -2
- package/src/hub/delivery-journal.ts +123 -17
- package/src/hub/dogfood-profile.ts +32 -5
- package/src/hub/dogfood-read.ts +6 -2
- package/src/hub/envelope.ts +11 -0
- package/src/hub/eval-gate.ts +131 -0
- package/src/hub/events.ts +1 -1
- package/src/hub/external-instance.ts +69 -0
- package/src/hub/facts.ts +15 -5
- package/src/hub/folder-trigger.ts +223 -0
- package/src/hub/hub-tools.ts +2 -1
- package/src/hub/inference.ts +20 -5
- package/src/hub/instance-config.ts +69 -3
- package/src/hub/instance-execution.ts +6 -2
- package/src/hub/instances.ts +32 -4
- package/src/hub/intake.ts +125 -0
- package/src/hub/learning.ts +85 -0
- package/src/hub/lifecycle.ts +7 -3
- package/src/hub/limits.ts +12 -5
- package/src/hub/native-permission-request.ts +77 -0
- package/src/hub/operational-authority.ts +63 -5
- package/src/hub/operational-settings.ts +76 -0
- package/src/hub/operational-view.ts +1 -1
- package/src/hub/packs.ts +132 -0
- package/src/hub/path-policy.ts +7 -0
- package/src/hub/peer-doing.ts +130 -0
- package/src/hub/peers.ts +46 -0
- package/src/hub/queue-backpressure.ts +58 -0
- package/src/hub/read-tools.ts +2 -0
- package/src/hub/recovery-authority.ts +151 -0
- package/src/hub/recovery-policy.ts +220 -0
- package/src/hub/research.ts +24 -14
- package/src/hub/restart.ts +7 -0
- package/src/hub/routing.ts +54 -11
- package/src/hub/schedules.ts +266 -0
- package/src/hub/sdlc-merge.ts +38 -14
- package/src/hub/sdlc-metrics.ts +32 -10
- package/src/hub/sdlc-queue.ts +7 -1
- package/src/hub/sdlc-release.ts +1 -1
- package/src/hub/sdlc.ts +37 -7
- package/src/hub/security-baseline.ts +38 -0
- package/src/hub/settings.ts +7 -1
- package/src/hub/shipped-packs.ts +8 -0
- package/src/hub/snapshots.ts +72 -6
- package/src/hub/source-read.ts +160 -0
- package/src/hub/sources.ts +311 -0
- package/src/hub/task-shell-default.ts +3 -2
- package/src/hub/tasks.ts +1047 -115
- package/src/hub/workflow.ts +1 -1
- package/src/local/deny.ts +21 -0
- package/src/local/doc-read.ts +69 -0
- package/src/local/proxy.ts +4 -13
- package/src/local/sandbox.ts +3 -1
- package/src/local/tools.ts +37 -3
- package/src/local/web-fetch-config.ts +43 -0
- package/src/local/web-fetch.ts +183 -0
- package/src/memory/brief.ts +85 -13
- package/src/memory/capture.ts +1 -1
- package/src/rooms/application.ts +114 -0
- package/src/rooms/boundary.ts +103 -0
- package/src/rooms/commands.ts +294 -0
- package/src/rooms/crypto.ts +371 -0
- package/src/rooms/link.ts +584 -0
- package/src/rooms/policy.ts +82 -0
- package/src/rooms/remote-peer.ts +80 -0
- package/src/rooms/service.ts +426 -0
- package/src/rooms/status.ts +146 -0
- package/src/rooms/store.ts +1210 -0
- package/src/rooms/wire.ts +465 -0
- package/src/ui/index.html +91 -4
- package/templates/packs/example.pack.json +13 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
/** Rooms v1 primitives. No transport, storage writes, or module-import self-test. */
|
|
2
|
+
export interface PublicHubIdentity {
|
|
3
|
+
readonly fingerprint: string;
|
|
4
|
+
readonly signingKey: string;
|
|
5
|
+
readonly agreementKey: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
declare const identityBrand: unique symbol;
|
|
9
|
+
export interface HubIdentity {
|
|
10
|
+
readonly public: PublicHubIdentity;
|
|
11
|
+
readonly [identityBrand]: true;
|
|
12
|
+
}
|
|
13
|
+
declare const epochBrand: unique symbol;
|
|
14
|
+
export interface EpochKey { readonly [epochBrand]: true }
|
|
15
|
+
|
|
16
|
+
/** Explicit machine-local serialization only; these private bytes are not encrypted. */
|
|
17
|
+
export interface LocalIdentityRecord {
|
|
18
|
+
v: 1;
|
|
19
|
+
public: PublicHubIdentity;
|
|
20
|
+
signingPrivateKey: string;
|
|
21
|
+
agreementPrivateKey: string;
|
|
22
|
+
}
|
|
23
|
+
export interface WrappedEpochKey {
|
|
24
|
+
v: 1;
|
|
25
|
+
owner: string;
|
|
26
|
+
recipient: string;
|
|
27
|
+
ephemeralKey: string;
|
|
28
|
+
salt: string;
|
|
29
|
+
nonce: string;
|
|
30
|
+
ciphertext: string;
|
|
31
|
+
signature: string;
|
|
32
|
+
}
|
|
33
|
+
export type RoomCryptoCode = "unavailable" | "invalid_input" | "invalid_identity" | "invalid_key" | "authentication_failed";
|
|
34
|
+
export class RoomCryptoError extends Error {
|
|
35
|
+
constructor(readonly code: RoomCryptoCode) {
|
|
36
|
+
super(`Room crypto: ${code}`);
|
|
37
|
+
this.name = "RoomCryptoError";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface PrivateIdentity {
|
|
42
|
+
signing: CryptoKey;
|
|
43
|
+
agreement: CryptoKey;
|
|
44
|
+
// Runtime imports are non-extractable. Only this explicit storage record retains secrets.
|
|
45
|
+
// Same-user code can read persisted secrets; this is not a process-isolation boundary.
|
|
46
|
+
record: LocalIdentityRecord;
|
|
47
|
+
}
|
|
48
|
+
const identities = new WeakMap<HubIdentity, PrivateIdentity>();
|
|
49
|
+
const epochKeys = new WeakMap<EpochKey, Uint8Array>();
|
|
50
|
+
const utf8 = new TextEncoder();
|
|
51
|
+
const DOMAIN = "agent-hub.rooms.v1";
|
|
52
|
+
let readiness: Promise<void> | undefined;
|
|
53
|
+
|
|
54
|
+
const fail = (code: RoomCryptoCode): never => { throw new RoomCryptoError(code); };
|
|
55
|
+
const buf = (bytes: Uint8Array): ArrayBuffer => Uint8Array.from(bytes).buffer;
|
|
56
|
+
function bytes(value: Uint8Array, length?: number): Uint8Array {
|
|
57
|
+
if (!(value instanceof Uint8Array) || (length !== undefined && value.length !== length)) fail("invalid_input");
|
|
58
|
+
return Uint8Array.from(value);
|
|
59
|
+
}
|
|
60
|
+
function equal(a: Uint8Array, b: Uint8Array): boolean {
|
|
61
|
+
if (a.length !== b.length) return false;
|
|
62
|
+
let diff = 0;
|
|
63
|
+
for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;
|
|
64
|
+
return diff === 0;
|
|
65
|
+
}
|
|
66
|
+
function hex(value: string): Uint8Array {
|
|
67
|
+
return Uint8Array.from(value.match(/../g)!.map(pair => parseInt(pair, 16)));
|
|
68
|
+
}
|
|
69
|
+
function toHex(value: Uint8Array): string {
|
|
70
|
+
return Array.from(value, byte => byte.toString(16).padStart(2, "0")).join("");
|
|
71
|
+
}
|
|
72
|
+
function transcript(value: unknown[]): Uint8Array { return utf8.encode(JSON.stringify(value)); }
|
|
73
|
+
|
|
74
|
+
export function encodeBase64url(value: Uint8Array): string {
|
|
75
|
+
return Buffer.from(bytes(value)).toString("base64url");
|
|
76
|
+
}
|
|
77
|
+
export function decodeBase64url(value: string, expectedLength?: number): Uint8Array {
|
|
78
|
+
if (expectedLength !== undefined && (!Number.isSafeInteger(expectedLength) || expectedLength < 0
|
|
79
|
+
|| typeof value !== "string" || value.length !== Math.ceil(expectedLength * 4 / 3))) fail("invalid_input");
|
|
80
|
+
if (typeof value !== "string" || value.length > 16 * 1024 * 1024 || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) fail("invalid_input");
|
|
81
|
+
const decoded = Uint8Array.from(Buffer.from(value, "base64url"));
|
|
82
|
+
if (encodeBase64url(decoded) !== value || (expectedLength !== undefined && decoded.length !== expectedLength)) fail("invalid_input");
|
|
83
|
+
return decoded;
|
|
84
|
+
}
|
|
85
|
+
export function randomBytes(length: number): Uint8Array {
|
|
86
|
+
if (!Number.isInteger(length) || length < 1 || length > 65536) fail("invalid_input");
|
|
87
|
+
try { return globalThis.crypto.getRandomValues(new Uint8Array(length)); }
|
|
88
|
+
catch { return fail("unavailable"); }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function operation<T>(fn: () => Promise<T>, code: RoomCryptoCode = "authentication_failed"): Promise<T> {
|
|
92
|
+
await assertRoomCryptoReady();
|
|
93
|
+
try { return await fn(); }
|
|
94
|
+
catch (error) { if (error instanceof RoomCryptoError) throw error; return fail(code); }
|
|
95
|
+
}
|
|
96
|
+
async function importPrivate(algorithm: "Ed25519" | "X25519", value: Uint8Array): Promise<CryptoKey> {
|
|
97
|
+
return crypto.subtle.importKey("pkcs8", buf(value), algorithm, false, algorithm === "Ed25519" ? ["sign"] : ["deriveBits"]);
|
|
98
|
+
}
|
|
99
|
+
async function importPublic(algorithm: "Ed25519" | "X25519", value: Uint8Array): Promise<CryptoKey> {
|
|
100
|
+
return crypto.subtle.importKey("raw", buf(value), algorithm, false, algorithm === "Ed25519" ? ["verify"] : []);
|
|
101
|
+
}
|
|
102
|
+
async function generatePair(algorithm: "Ed25519" | "X25519", extractable: boolean): Promise<CryptoKeyPair> {
|
|
103
|
+
const result = await crypto.subtle.generateKey(algorithm, extractable, algorithm === "Ed25519" ? ["sign", "verify"] : ["deriveBits"]);
|
|
104
|
+
if ("publicKey" in result && "privateKey" in result) return result;
|
|
105
|
+
return fail("unavailable");
|
|
106
|
+
}
|
|
107
|
+
async function agreement(privateKey: CryptoKey, publicKey: CryptoKey): Promise<Uint8Array> {
|
|
108
|
+
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "X25519", public: publicKey }, privateKey, 256));
|
|
109
|
+
if (shared.every(byte => byte === 0)) fail("invalid_key");
|
|
110
|
+
return shared;
|
|
111
|
+
}
|
|
112
|
+
async function hkdf(raw: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise<CryptoKey> {
|
|
113
|
+
const key = await crypto.subtle.importKey("raw", buf(raw), "HKDF", false, ["deriveKey"]);
|
|
114
|
+
// Bind the full versioned transcript without exceeding the provider's HKDF info limit.
|
|
115
|
+
// AES AAD and signatures still authenticate the complete, unhashed transcript.
|
|
116
|
+
const infoHash = await crypto.subtle.digest("SHA-256", buf(info));
|
|
117
|
+
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: buf(salt), info: infoHash }, key,
|
|
118
|
+
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
|
119
|
+
}
|
|
120
|
+
async function aes(encrypt: boolean, key: CryptoKey, nonce: Uint8Array, aad: Uint8Array, input: Uint8Array): Promise<Uint8Array> {
|
|
121
|
+
const algorithm = { name: "AES-GCM", iv: buf(nonce), additionalData: buf(aad), tagLength: 128 };
|
|
122
|
+
return new Uint8Array(await (encrypt ? crypto.subtle.encrypt(algorithm, key, buf(input)) : crypto.subtle.decrypt(algorithm, key, buf(input))));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const ROOM_MIN_BUN_VERSION = "1.4.2";
|
|
126
|
+
|
|
127
|
+
/** Cached actual built-in known-answer checks. Failure disables all async room crypto. */
|
|
128
|
+
export function assertRoomCryptoReady(): Promise<void> {
|
|
129
|
+
readiness ??= (async () => {
|
|
130
|
+
try {
|
|
131
|
+
if (typeof Bun === "undefined" || !/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(Bun.version)) fail("unavailable");
|
|
132
|
+
const version = Bun.version.split(".").map(part => Number.parseInt(part, 10));
|
|
133
|
+
const minimum = ROOM_MIN_BUN_VERSION.split(".").map(Number);
|
|
134
|
+
for (let i = 0; i < 3; i++) {
|
|
135
|
+
if (version[i]! < minimum[i]!) fail("unavailable");
|
|
136
|
+
if (version[i]! > minimum[i]!) break;
|
|
137
|
+
}
|
|
138
|
+
await selfTest();
|
|
139
|
+
} catch { fail("unavailable"); }
|
|
140
|
+
})();
|
|
141
|
+
return readiness;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function selfTest(): Promise<void> {
|
|
145
|
+
const check = (actual: Uint8Array, expected: string) => { if (!equal(actual, hex(expected))) fail("unavailable"); };
|
|
146
|
+
// RFC 8032 section 7.1, TEST 1. PKCS8 prefixes encode RFC 8410 private keys.
|
|
147
|
+
const ed = await importPrivate("Ed25519", hex("302e020100300506032b6570042204209d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60"));
|
|
148
|
+
const edPublic = await importPublic("Ed25519", hex("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"));
|
|
149
|
+
const signature = new Uint8Array(await crypto.subtle.sign("Ed25519", ed, new Uint8Array()));
|
|
150
|
+
check(signature, "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b");
|
|
151
|
+
if (!await crypto.subtle.verify("Ed25519", edPublic, buf(signature), new Uint8Array())) fail("unavailable");
|
|
152
|
+
signature[0] = signature[0]! ^ 1;
|
|
153
|
+
if (await crypto.subtle.verify("Ed25519", edPublic, buf(signature), new Uint8Array())) fail("unavailable");
|
|
154
|
+
// RFC 7748 section 6.1.
|
|
155
|
+
const x = await importPrivate("X25519", hex("302e020100300506032b656e0422042077076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"));
|
|
156
|
+
const xPublic = await importPublic("X25519", hex("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f"));
|
|
157
|
+
check(await agreement(x, xPublic), "4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
|
|
158
|
+
let rejected = false;
|
|
159
|
+
try { await agreement(x, await importPublic("X25519", new Uint8Array(32))); } catch { rejected = true; }
|
|
160
|
+
if (!rejected) fail("unavailable");
|
|
161
|
+
// RFC 5869 appendix A.1, SHA-256, 42-byte output.
|
|
162
|
+
const hk = await crypto.subtle.importKey("raw", new Uint8Array(22).fill(0x0b), "HKDF", false, ["deriveBits"]);
|
|
163
|
+
check(new Uint8Array(await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: buf(hex("000102030405060708090a0b0c")), info: buf(hex("f0f1f2f3f4f5f6f7f8f9")) }, hk, 336)),
|
|
164
|
+
"3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865");
|
|
165
|
+
// GCM specification test case 14, AES-256, zero key/IV/plaintext.
|
|
166
|
+
// https://csrc.nist.gov/groups/ST/toolkit/BCM/documents/proposedmodes/gcm/gcm-spec.pdf
|
|
167
|
+
const aesKey = await crypto.subtle.importKey("raw", new Uint8Array(32), "AES-GCM", false, ["encrypt", "decrypt"]);
|
|
168
|
+
const ciphertext = await aes(true, aesKey, new Uint8Array(12), new Uint8Array(), new Uint8Array(16));
|
|
169
|
+
check(ciphertext, "cea7403d4d606b6e074ec5d3baf39d18d0d1c8a799996bf0265b98b5d48ab919");
|
|
170
|
+
if (!equal(await aes(false, aesKey, new Uint8Array(12), new Uint8Array(), ciphertext), new Uint8Array(16))) fail("unavailable");
|
|
171
|
+
rejected = false;
|
|
172
|
+
ciphertext[0] = ciphertext[0]! ^ 1;
|
|
173
|
+
try { await aes(false, aesKey, new Uint8Array(12), new Uint8Array(), ciphertext); } catch { rejected = true; }
|
|
174
|
+
if (!rejected) fail("unavailable");
|
|
175
|
+
// RFC 4231 section 4.2, test case 1.
|
|
176
|
+
const hm = await crypto.subtle.importKey("raw", new Uint8Array(20).fill(0x0b), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]);
|
|
177
|
+
const mac = new Uint8Array(await crypto.subtle.sign("HMAC", hm, utf8.encode("Hi There")));
|
|
178
|
+
check(mac, "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7");
|
|
179
|
+
if (!await crypto.subtle.verify("HMAC", hm, buf(mac), utf8.encode("Hi There"))) fail("unavailable");
|
|
180
|
+
mac[0] = mac[0]! ^ 1;
|
|
181
|
+
if (await crypto.subtle.verify("HMAC", hm, buf(mac), utf8.encode("Hi There"))) fail("unavailable");
|
|
182
|
+
check(new Uint8Array(await crypto.subtle.digest("SHA-256", utf8.encode("abc"))), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
|
183
|
+
// A KAT-only implementation might still lack generation, used by identity and wraps.
|
|
184
|
+
await generatePair("Ed25519", true);
|
|
185
|
+
await generatePair("X25519", true);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function publicCopy(value: PublicHubIdentity): PublicHubIdentity {
|
|
189
|
+
if (!value || typeof value !== "object" || Object.keys(value).sort().join(",") !== "agreementKey,fingerprint,signingKey"
|
|
190
|
+
|| typeof value.fingerprint !== "string" || !/^[a-f0-9]{64}$/.test(value.fingerprint)) fail("invalid_identity");
|
|
191
|
+
decodeBase64url(value.signingKey, 32);
|
|
192
|
+
decodeBase64url(value.agreementKey, 32);
|
|
193
|
+
return Object.freeze({ fingerprint: value.fingerprint, signingKey: value.signingKey, agreementKey: value.agreementKey });
|
|
194
|
+
}
|
|
195
|
+
async function checkedPublic(value: PublicHubIdentity): Promise<PublicHubIdentity> {
|
|
196
|
+
const copy = publicCopy(value);
|
|
197
|
+
if (toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", buf(decodeBase64url(copy.signingKey, 32))))) !== copy.fingerprint) fail("invalid_identity");
|
|
198
|
+
return copy;
|
|
199
|
+
}
|
|
200
|
+
/** Reject unusable/low-order X25519 public keys before admitting a member. */
|
|
201
|
+
export async function assertAgreementKeyUsable(identity: PublicHubIdentity): Promise<void> {
|
|
202
|
+
const pub = publicCopy(identity);
|
|
203
|
+
return operation(async () => {
|
|
204
|
+
await checkedPublic(pub);
|
|
205
|
+
const probe = await generatePair("X25519", false);
|
|
206
|
+
await agreement(probe.privateKey, await importPublic("X25519", decodeBase64url(pub.agreementKey, 32)));
|
|
207
|
+
}, "invalid_key");
|
|
208
|
+
}
|
|
209
|
+
function identityState(identity: HubIdentity): PrivateIdentity {
|
|
210
|
+
return identities.get(identity) ?? fail("invalid_identity");
|
|
211
|
+
}
|
|
212
|
+
function rootState(key: EpochKey): Uint8Array { return epochKeys.get(key) ?? fail("invalid_key"); }
|
|
213
|
+
|
|
214
|
+
export async function generateHubIdentity(): Promise<HubIdentity> {
|
|
215
|
+
return operation(async () => {
|
|
216
|
+
const signing = await generatePair("Ed25519", true);
|
|
217
|
+
const agreementKeys = await generatePair("X25519", true);
|
|
218
|
+
const signingRaw = new Uint8Array(await crypto.subtle.exportKey("raw", signing.publicKey));
|
|
219
|
+
return importHubIdentity({ v: 1, public: {
|
|
220
|
+
fingerprint: toHex(new Uint8Array(await crypto.subtle.digest("SHA-256", buf(signingRaw)))),
|
|
221
|
+
signingKey: encodeBase64url(signingRaw),
|
|
222
|
+
agreementKey: encodeBase64url(new Uint8Array(await crypto.subtle.exportKey("raw", agreementKeys.publicKey))),
|
|
223
|
+
}, signingPrivateKey: encodeBase64url(new Uint8Array(await crypto.subtle.exportKey("pkcs8", signing.privateKey))),
|
|
224
|
+
agreementPrivateKey: encodeBase64url(new Uint8Array(await crypto.subtle.exportKey("pkcs8", agreementKeys.privateKey))) });
|
|
225
|
+
}, "invalid_key");
|
|
226
|
+
}
|
|
227
|
+
export async function importHubIdentity(record: LocalIdentityRecord): Promise<HubIdentity> {
|
|
228
|
+
if (!record || record.v !== 1 || Object.keys(record).sort().join(",") !== "agreementPrivateKey,public,signingPrivateKey,v") fail("invalid_identity");
|
|
229
|
+
const pub = publicCopy(record.public);
|
|
230
|
+
const signingPrivateKey = record.signingPrivateKey;
|
|
231
|
+
const agreementPrivateKey = record.agreementPrivateKey;
|
|
232
|
+
const signingRaw = decodeBase64url(signingPrivateKey, 48);
|
|
233
|
+
const agreementRaw = decodeBase64url(agreementPrivateKey, 48);
|
|
234
|
+
return operation(async () => {
|
|
235
|
+
await checkedPublic(pub);
|
|
236
|
+
const signing = await importPrivate("Ed25519", signingRaw);
|
|
237
|
+
const privateAgreement = await importPrivate("X25519", agreementRaw);
|
|
238
|
+
const challenge = randomBytes(32);
|
|
239
|
+
const signature = await crypto.subtle.sign("Ed25519", signing, buf(challenge));
|
|
240
|
+
if (!await crypto.subtle.verify("Ed25519", await importPublic("Ed25519", decodeBase64url(pub.signingKey, 32)), signature, buf(challenge))) fail("invalid_identity");
|
|
241
|
+
const probe = await generatePair("X25519", false);
|
|
242
|
+
const left = await agreement(privateAgreement, probe.publicKey);
|
|
243
|
+
const right = await agreement(probe.privateKey, await importPublic("X25519", decodeBase64url(pub.agreementKey, 32)));
|
|
244
|
+
if (!equal(left, right)) fail("invalid_identity");
|
|
245
|
+
const identity = Object.freeze({ public: pub }) as HubIdentity;
|
|
246
|
+
identities.set(identity, { signing, agreement: privateAgreement, record: { v: 1, public: pub, signingPrivateKey, agreementPrivateKey } });
|
|
247
|
+
return identity;
|
|
248
|
+
}, "invalid_identity");
|
|
249
|
+
}
|
|
250
|
+
export async function exportHubIdentityForStorage(identity: HubIdentity): Promise<LocalIdentityRecord> {
|
|
251
|
+
return operation(async () => {
|
|
252
|
+
const record = identityState(identity).record;
|
|
253
|
+
return { ...record, public: { ...record.public } };
|
|
254
|
+
}, "invalid_identity");
|
|
255
|
+
}
|
|
256
|
+
export async function generateEpochKey(): Promise<EpochKey> { return importEpochKey(randomBytes(32)); }
|
|
257
|
+
export async function importEpochKey(raw: Uint8Array): Promise<EpochKey> {
|
|
258
|
+
const copy = bytes(raw, 32);
|
|
259
|
+
return operation(async () => {
|
|
260
|
+
const key = Object.freeze({}) as EpochKey;
|
|
261
|
+
epochKeys.set(key, copy);
|
|
262
|
+
return key;
|
|
263
|
+
}, "invalid_key");
|
|
264
|
+
}
|
|
265
|
+
export async function exportEpochKeyForStorage(key: EpochKey): Promise<Uint8Array> {
|
|
266
|
+
return operation(async () => Uint8Array.from(rootState(key)), "invalid_key");
|
|
267
|
+
}
|
|
268
|
+
export async function sha256(input: Uint8Array): Promise<Uint8Array> {
|
|
269
|
+
const copy = bytes(input);
|
|
270
|
+
return operation(async () => new Uint8Array(await crypto.subtle.digest("SHA-256", buf(copy))));
|
|
271
|
+
}
|
|
272
|
+
export async function signBytes(identity: HubIdentity, input: Uint8Array): Promise<Uint8Array> {
|
|
273
|
+
const copy = bytes(input);
|
|
274
|
+
return operation(async () => new Uint8Array(await crypto.subtle.sign("Ed25519", identityState(identity).signing, buf(copy))), "invalid_identity");
|
|
275
|
+
}
|
|
276
|
+
export async function verifyBytes(identity: PublicHubIdentity, input: Uint8Array, signature: Uint8Array): Promise<boolean> {
|
|
277
|
+
const pub = publicCopy(identity), copy = bytes(input), sig = bytes(signature);
|
|
278
|
+
return operation(async () => {
|
|
279
|
+
await checkedPublic(pub);
|
|
280
|
+
if (sig.length !== 64) return false;
|
|
281
|
+
return crypto.subtle.verify("Ed25519", await importPublic("Ed25519", decodeBase64url(pub.signingKey, 32)), buf(sig), buf(copy));
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
export async function hmac(rawKey: Uint8Array, input: Uint8Array): Promise<Uint8Array> {
|
|
285
|
+
const key = bytes(rawKey), copy = bytes(input);
|
|
286
|
+
if (key.length === 0) fail("invalid_key");
|
|
287
|
+
return operation(async () => {
|
|
288
|
+
const imported = await crypto.subtle.importKey("raw", buf(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
289
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", imported, buf(copy)));
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
export async function verifyHmac(rawKey: Uint8Array, input: Uint8Array, mac: Uint8Array): Promise<boolean> {
|
|
293
|
+
const key = bytes(rawKey), copy = bytes(input), tag = bytes(mac);
|
|
294
|
+
if (key.length === 0) fail("invalid_key");
|
|
295
|
+
return operation(async () => {
|
|
296
|
+
if (tag.length !== 32) return false;
|
|
297
|
+
const imported = await crypto.subtle.importKey("raw", buf(key), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
|
|
298
|
+
return crypto.subtle.verify("HMAC", imported, buf(tag), buf(copy));
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export type EpochPurpose = "frame" | "roster" | "invite";
|
|
303
|
+
export interface EpochCipherInput {
|
|
304
|
+
key: EpochKey;
|
|
305
|
+
purpose: EpochPurpose;
|
|
306
|
+
context: Uint8Array;
|
|
307
|
+
nonce: Uint8Array;
|
|
308
|
+
/** Defaults to context. Frame callers separate stable sender-key context from per-frame AAD. */
|
|
309
|
+
aad?: Uint8Array;
|
|
310
|
+
}
|
|
311
|
+
async function epochCipher(encrypt: boolean, input: EpochCipherInput, data: Uint8Array): Promise<Uint8Array> {
|
|
312
|
+
const context = bytes(input.context), nonce = bytes(input.nonce, 12), aad = bytes(input.aad ?? context), copy = bytes(data);
|
|
313
|
+
const purpose = input.purpose, root = input.key;
|
|
314
|
+
if (!["frame", "roster", "invite"].includes(purpose)) fail("invalid_input");
|
|
315
|
+
return operation(async () => {
|
|
316
|
+
const key = await hkdf(rootState(root), new Uint8Array(32), transcript([DOMAIN, purpose, encodeBase64url(context)]));
|
|
317
|
+
return aes(encrypt, key, nonce, aad, copy);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
export async function encryptEpoch(input: EpochCipherInput & { plaintext: Uint8Array }): Promise<Uint8Array> {
|
|
321
|
+
return epochCipher(true, input, input.plaintext);
|
|
322
|
+
}
|
|
323
|
+
export async function decryptEpoch(input: EpochCipherInput & { ciphertext: Uint8Array }): Promise<Uint8Array> {
|
|
324
|
+
return epochCipher(false, input, input.ciphertext);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function pubTuple(identity: PublicHubIdentity): string[] { return [identity.fingerprint, identity.signingKey, identity.agreementKey]; }
|
|
328
|
+
function wrapContext(context: Uint8Array, owner: PublicHubIdentity, recipient: PublicHubIdentity, ephemeral: string): Uint8Array {
|
|
329
|
+
return transcript([DOMAIN, "wrap", encodeBase64url(context), pubTuple(owner), pubTuple(recipient), ephemeral]);
|
|
330
|
+
}
|
|
331
|
+
function wrapSignature(context: Uint8Array, owner: PublicHubIdentity, recipient: PublicHubIdentity, wrapped: Omit<WrappedEpochKey, "signature">): Uint8Array {
|
|
332
|
+
return transcript([DOMAIN, "wrap-signature", encodeBase64url(context), pubTuple(owner), pubTuple(recipient), wrapped.v,
|
|
333
|
+
wrapped.owner, wrapped.recipient, wrapped.ephemeralKey, wrapped.salt, wrapped.nonce, wrapped.ciphertext]);
|
|
334
|
+
}
|
|
335
|
+
export async function wrapEpochKey(input: { owner: HubIdentity; recipient: PublicHubIdentity; context: Uint8Array; key: EpochKey }): Promise<WrappedEpochKey> {
|
|
336
|
+
const context = bytes(input.context), recipient = publicCopy(input.recipient), owner = input.owner, root = input.key;
|
|
337
|
+
return operation(async () => {
|
|
338
|
+
const ownerState = identityState(owner);
|
|
339
|
+
await checkedPublic(recipient);
|
|
340
|
+
const ephemeral = await generatePair("X25519", true);
|
|
341
|
+
const ephemeralKey = encodeBase64url(new Uint8Array(await crypto.subtle.exportKey("raw", ephemeral.publicKey)));
|
|
342
|
+
const salt = randomBytes(32), nonce = randomBytes(12);
|
|
343
|
+
const info = wrapContext(context, owner.public, recipient, ephemeralKey);
|
|
344
|
+
const shared = await agreement(ephemeral.privateKey, await importPublic("X25519", decodeBase64url(recipient.agreementKey, 32)));
|
|
345
|
+
const key = await hkdf(shared, salt, info);
|
|
346
|
+
const wrapped: Omit<WrappedEpochKey, "signature"> = { v: 1, owner: owner.public.fingerprint, recipient: recipient.fingerprint,
|
|
347
|
+
ephemeralKey, salt: encodeBase64url(salt), nonce: encodeBase64url(nonce),
|
|
348
|
+
ciphertext: encodeBase64url(await aes(true, key, nonce, info, rootState(root))) };
|
|
349
|
+
const signature = new Uint8Array(await crypto.subtle.sign("Ed25519", ownerState.signing, buf(wrapSignature(context, owner.public, recipient, wrapped))));
|
|
350
|
+
return { ...wrapped, signature: encodeBase64url(signature) };
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
export async function unwrapEpochKey(input: { recipient: HubIdentity; owner: PublicHubIdentity; context: Uint8Array; wrapped: WrappedEpochKey }): Promise<EpochKey> {
|
|
354
|
+
const context = bytes(input.context), owner = publicCopy(input.owner), recipient = input.recipient;
|
|
355
|
+
const source = input.wrapped;
|
|
356
|
+
if (!source || Object.keys(source).sort().join(",") !== "ciphertext,ephemeralKey,nonce,owner,recipient,salt,signature,v" || source.v !== 1) fail("invalid_input");
|
|
357
|
+
const wrapped = { ...source };
|
|
358
|
+
const ephemeral = decodeBase64url(wrapped.ephemeralKey, 32), salt = decodeBase64url(wrapped.salt, 32), nonce = decodeBase64url(wrapped.nonce, 12);
|
|
359
|
+
const ciphertext = decodeBase64url(wrapped.ciphertext, 48), signature = decodeBase64url(wrapped.signature, 64);
|
|
360
|
+
return operation(async () => {
|
|
361
|
+
const recipientState = identityState(recipient);
|
|
362
|
+
await checkedPublic(owner);
|
|
363
|
+
if (wrapped.owner !== owner.fingerprint || wrapped.recipient !== recipient.public.fingerprint) fail("authentication_failed");
|
|
364
|
+
if (!await crypto.subtle.verify("Ed25519", await importPublic("Ed25519", decodeBase64url(owner.signingKey, 32)), buf(signature),
|
|
365
|
+
buf(wrapSignature(context, owner, recipient.public, wrapped)))) fail("authentication_failed");
|
|
366
|
+
const info = wrapContext(context, owner, recipient.public, wrapped.ephemeralKey);
|
|
367
|
+
const shared = await agreement(recipientState.agreement, await importPublic("X25519", ephemeral));
|
|
368
|
+
const key = await hkdf(shared, salt, info);
|
|
369
|
+
return importEpochKey(await aes(false, key, nonce, info, ciphertext));
|
|
370
|
+
});
|
|
371
|
+
}
|