@uuaid/agent-commons 0.0.0 → 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 +26 -0
- package/LICENSE +201 -0
- package/NOTICE +22 -0
- package/PROVENANCE.json +24 -0
- package/README.md +404 -5
- package/bin/agent-commons.mjs +76 -0
- package/examples/Dockerfile +12 -0
- package/examples/agent.mjs +17 -0
- package/examples/global-policy.json +9 -0
- package/examples/run-cohort.mjs +68 -0
- package/package.json +31 -8
- package/profiles/agentnet.chat.json +11 -0
- package/profiles/local.json +11 -0
- package/profiles/zilligon.com.json +11 -0
- package/profiles/zilligons.com.json +11 -0
- package/src/carrier.mjs +84 -0
- package/src/channels.d.mts +75 -0
- package/src/channels.mjs +158 -0
- package/src/cohort.mjs +61 -0
- package/src/collaboration.mjs +875 -0
- package/src/config.mjs +40 -0
- package/src/continuity.d.mts +24 -0
- package/src/continuity.mjs +601 -0
- package/src/governance-scope.d.mts +46 -0
- package/src/governance-scope.mjs +139 -0
- package/src/governance.mjs +355 -0
- package/src/index.d.ts +111 -0
- package/src/index.mjs +15 -0
- package/src/loop.mjs +71 -0
- package/src/offline.d.mts +7 -0
- package/src/offline.mjs +29 -0
- package/src/pillar.mjs +59 -0
- package/src/profiles.d.mts +12 -0
- package/src/profiles.mjs +92 -0
- 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 +359 -0
- 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/store.mjs +73 -0
- package/src/transport.d.mts +76 -0
- package/src/transport.mjs +267 -0
- package/src/trust.mjs +104 -0
- package/src/uuaid-namespace.d.mts +6 -0
- package/src/uuaid-namespace.mjs +48 -0
- package/src/vendor/pillar/blob/blob.mjs +508 -0
- package/src/vendor/pillar/crypto/e2e.mjs +97 -0
- package/src/vendor/pillar/groups-v2.mjs +1108 -0
- package/src/vendor/pillar/groups.mjs +175 -0
- package/src/vendor/pillar/identity/jcs.mjs +34 -0
- package/src/vendor/pillar/identity/keychain.mjs +241 -0
- package/src/vendor/pillar/identity/tier.mjs +130 -0
- package/src/vendor/pillar/net/carrier-client.mjs +219 -0
- package/src/vendor/pillar/net/delegation.mjs +209 -0
- package/src/vendor/pillar/net/envelope.mjs +258 -0
- package/src/vendor/pillar/net/refusals.mjs +129 -0
- package/src/vendor/pillar/net/seed-tier.mjs +185 -0
package/src/loop.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
const DEFAULT_BUDGET={maxSends:100,maxBytesOut:256000,maxRepliesPerPeer:8,maxWallClockMs:900000,windowMs:3600000,windowMaxSends:200,windowMaxBytes:2000000,windowMaxRepliesPerPeer:40};
|
|
4
|
+
const SPONTANEOUS_KEYS=new Set(["recipient","profileId","namespace","body","thread","kind"]);
|
|
5
|
+
/**
|
|
6
|
+
* Bounded agent loop. Budget (all enforced BEFORE a send):
|
|
7
|
+
* - per-run: maxSends, maxBytesOut, maxRepliesPerPeer (stops reply ping-pong), maxWallClockMs
|
|
8
|
+
* - per-window, persisted in runtime.store (survives loop restarts): windowMaxSends/Bytes/RepliesPerPeer
|
|
9
|
+
* The agent cannot raise these: callers pass `budget`; values are clamped to hard ceilings.
|
|
10
|
+
* Spontaneous actions may only be plain "message" sends to a peer already in policy.
|
|
11
|
+
*/
|
|
12
|
+
export async function runAgentLoop({runtime,respond,spontaneous=null,maxTurns=16,intervalMs=2000,signal,onEvent=()=>{},budget={}}){
|
|
13
|
+
if(!runtime||typeof respond!=="function"||!Number.isInteger(maxTurns)||maxTurns<1||maxTurns>100||!Number.isInteger(intervalMs)||intervalMs<500)throw new Error("Invalid bounded agent loop");
|
|
14
|
+
const limits={...DEFAULT_BUDGET};
|
|
15
|
+
for(const [k,v] of Object.entries(budget??{})){if(!(k in DEFAULT_BUDGET)||!Number.isSafeInteger(v)||v<1||v>DEFAULT_BUDGET[k])throw new Error(`Invalid loop budget ${k}`);limits[k]=v}
|
|
16
|
+
const started=Date.now(),run={sends:0,bytes:0,peers:{}},store=runtime.store&&typeof runtime.store.get==="function"?runtime.store:null;
|
|
17
|
+
const record=(reason,extra={})=>{try{store?.append?.(randomUUID(),{kind:"loop-budget-stop",reason,...extra})}catch{}};
|
|
18
|
+
// Returns a refusal reason or null, and reserves the budget when allowed.
|
|
19
|
+
const reserve=(peer,bytes)=>{
|
|
20
|
+
if(Date.now()-started>=limits.maxWallClockMs)return "wall-clock";
|
|
21
|
+
if(run.sends+1>limits.maxSends)return "run-sends";
|
|
22
|
+
if(run.bytes+bytes>limits.maxBytesOut)return "run-bytes";
|
|
23
|
+
if((run.peers[peer]??0)+1>limits.maxRepliesPerPeer)return "run-peer-replies";
|
|
24
|
+
let meter=null;
|
|
25
|
+
if(store){
|
|
26
|
+
meter=store.get("loopMeter",null);const now=Date.now();
|
|
27
|
+
if(!meter||now-meter.start>=limits.windowMs)meter={start:now,sends:0,bytes:0,peers:{}};
|
|
28
|
+
if(meter.sends+1>limits.windowMaxSends)return "window-sends";
|
|
29
|
+
if(meter.bytes+bytes>limits.windowMaxBytes)return "window-bytes";
|
|
30
|
+
if((meter.peers[peer]??0)+1>limits.windowMaxRepliesPerPeer)return "window-peer-replies";
|
|
31
|
+
meter.sends++;meter.bytes+=bytes;meter.peers[peer]=(meter.peers[peer]??0)+1;
|
|
32
|
+
if(Object.keys(meter.peers).length>1000)return "window-peers";
|
|
33
|
+
store.set("loopMeter",meter);
|
|
34
|
+
}
|
|
35
|
+
run.sends++;run.bytes+=bytes;run.peers[peer]=(run.peers[peer]??0)+1;return null;
|
|
36
|
+
};
|
|
37
|
+
let turns=0,stopped=null;
|
|
38
|
+
const send=async(action)=>{
|
|
39
|
+
const reason=reserve(String(action.recipient),Buffer.byteLength(String(action.body??"")));
|
|
40
|
+
if(reason){stopped=`budget:${reason}`;record(reason,{peer:String(action.recipient).slice(0,120)});onEvent({rejected:true,state:"budget-refused",reason});return false}
|
|
41
|
+
onEvent(await runtime.send(action));return true;
|
|
42
|
+
};
|
|
43
|
+
while(turns<maxTurns&&!signal?.aborted&&!stopped){
|
|
44
|
+
const packets=await runtime.poll();
|
|
45
|
+
for(const packet of packets){
|
|
46
|
+
if(packet.duplicate||packet.rejected||packet.deferred||typeof packet.body!=="string"){onEvent(packet);continue}
|
|
47
|
+
let reply;
|
|
48
|
+
try{reply=await respond({packet,status:runtime.status(),signal})}
|
|
49
|
+
catch(error){turns++;onEvent({rejected:true,state:"responder-failed",reason:String(error?.message??error).slice(0,200)});if(turns>=maxTurns||signal?.aborted)break;continue}
|
|
50
|
+
turns++;
|
|
51
|
+
if(reply!==null&&reply!==undefined){
|
|
52
|
+
if(typeof reply!=="string")throw new Error("Agent responder must return bounded plain text or null");
|
|
53
|
+
if(!await send({recipient:packet.sender,profileId:packet.profileId,thread:packet.thread,body:reply}))break;
|
|
54
|
+
}
|
|
55
|
+
if(turns>=maxTurns||signal?.aborted)break;
|
|
56
|
+
}
|
|
57
|
+
if(stopped)break;
|
|
58
|
+
if(!packets.length&&spontaneous&&turns<maxTurns){
|
|
59
|
+
const action=await spontaneous({status:runtime.status(),signal});
|
|
60
|
+
turns++;
|
|
61
|
+
if(action){
|
|
62
|
+
const allowedPeers=runtime.policy?.agents;
|
|
63
|
+
if(typeof action!=="object"||Object.keys(action).some(k=>!SPONTANEOUS_KEYS.has(k))||(action.kind!==undefined&&action.kind!=="message")||typeof action.recipient!=="string"||typeof action.body!=="string"||(allowedPeers&&!Object.hasOwn(allowedPeers,action.recipient))){onEvent({rejected:true,state:"spontaneous-action-refused"});record("spontaneous-action-refused")}
|
|
64
|
+
else if(!await send(action))break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
await runtime.flush();
|
|
68
|
+
if(turns<maxTurns&&!signal?.aborted)try{await delay(intervalMs,null,{signal})}catch(error){if(error.name!=="AbortError")throw error}
|
|
69
|
+
}
|
|
70
|
+
return {turns,stopped:stopped??(signal?.aborted?"aborted":"turn-limit"),usage:{...run,peers:undefined}};
|
|
71
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const OFFLINE_ENV: "AGENT_COMMONS_OFFLINE";
|
|
2
|
+
export function isLoopbackHost(hostname: string): boolean;
|
|
3
|
+
export function isLoopbackUrl(url: string): boolean;
|
|
4
|
+
export function isOffline(
|
|
5
|
+
env?: Record<string, string | undefined>,
|
|
6
|
+
policy?: { mode?: string; offline?: boolean },
|
|
7
|
+
): boolean;
|
package/src/offline.mjs
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Offline mode. Default on for a local run.
|
|
2
|
+
// AGENT_COMMONS_OFFLINE=1 forces offline, including policy.mode "global".
|
|
3
|
+
// AGENT_COMMONS_OFFLINE=0 is the only opt-in that allows outbound calls.
|
|
4
|
+
// Unset: offline, unless policy.offline is false, or policy.mode is "global"
|
|
5
|
+
// and the policy does not set offline. Loopback is a local carrier, not egress.
|
|
6
|
+
|
|
7
|
+
export const OFFLINE_ENV = "AGENT_COMMONS_OFFLINE";
|
|
8
|
+
|
|
9
|
+
export function isLoopbackHost(hostname) {
|
|
10
|
+
const host = String(hostname ?? "").toLowerCase().replace(/^\[|\]$/g, "");
|
|
11
|
+
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isLoopbackUrl(url) {
|
|
15
|
+
try {
|
|
16
|
+
return isLoopbackHost(new URL(String(url)).hostname);
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isOffline(env = process.env, policy) {
|
|
23
|
+
const flag = env?.[OFFLINE_ENV];
|
|
24
|
+
if (flag === "1") return true;
|
|
25
|
+
if (flag === "0") return false;
|
|
26
|
+
if (policy && Object.prototype.hasOwnProperty.call(policy, "offline")) return policy.offline !== false;
|
|
27
|
+
if (policy?.mode === "global") return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
package/src/pillar.mjs
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Unmodified upstream modules are covered by NOTICE and PROVENANCE.json.
|
|
2
|
+
// CarrierClient is wrapped here. The vendored module stays byte-identical to
|
|
3
|
+
// @uuaid/pillar 2.2.0. Offline refusal happens before any vendored method runs.
|
|
4
|
+
import { CarrierClient as VendoredCarrierClient } from "./vendor/pillar/net/carrier-client.mjs";
|
|
5
|
+
import { isLoopbackUrl, isOffline } from "./offline.mjs";
|
|
6
|
+
|
|
7
|
+
export { Keychain, localIdFromKey } from "./vendor/pillar/identity/keychain.mjs";
|
|
8
|
+
export { jcs } from "./vendor/pillar/identity/jcs.mjs";
|
|
9
|
+
export { seal, open, decrypt, envelopeSha, ENVELOPE_VERSION } from "./vendor/pillar/net/envelope.mjs";
|
|
10
|
+
|
|
11
|
+
function refusedCarrier(urls, env) {
|
|
12
|
+
if (!isOffline(env)) return null;
|
|
13
|
+
for (const url of urls ?? []) {
|
|
14
|
+
if (!isLoopbackUrl(url)) return url;
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function offlineError(url) {
|
|
20
|
+
const error = new Error(`offline: refused outbound carrier call to ${url}`);
|
|
21
|
+
error.code = "offline";
|
|
22
|
+
error.carrier = url;
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class CarrierClient extends VendoredCarrierClient {
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
super(options);
|
|
29
|
+
// Upstream pillar 2.2.0 does not store env. The package seam does.
|
|
30
|
+
this._offlineEnv = options.env ?? process.env;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async deliver(envelope, options) {
|
|
34
|
+
const blocked = refusedCarrier(this.carriers, this._offlineEnv);
|
|
35
|
+
if (blocked) throw offlineError(blocked);
|
|
36
|
+
return super.deliver(envelope, options);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async fetchInbox(base, options) {
|
|
40
|
+
const blocked = refusedCarrier([base], this._offlineEnv);
|
|
41
|
+
if (blocked) throw offlineError(blocked);
|
|
42
|
+
return super.fetchInbox(base, options);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
startPolling(options = {}) {
|
|
46
|
+
const blocked = refusedCarrier(this.carriers, this._offlineEnv);
|
|
47
|
+
if (blocked) {
|
|
48
|
+
const error = offlineError(blocked);
|
|
49
|
+
options.onError?.(error, { carrier: blocked });
|
|
50
|
+
const cursors = { ...(options.cursors ?? {}) };
|
|
51
|
+
return {
|
|
52
|
+
stop() {},
|
|
53
|
+
cursors: () => ({ ...cursors }),
|
|
54
|
+
done: Promise.resolve([]),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return super.startPolling(options);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Profile, Keychain } from "./index.d.ts";
|
|
2
|
+
export function createProfile(options:{namespace:string;name:string;fixtures:string[];scope?:"local"|"tenant"|"global";lexicon?:Record<string,string>;parent?:string|null;revision?:number;quorum?:number}):Profile;
|
|
3
|
+
export function verifyProfile(profile:Profile):Profile;
|
|
4
|
+
export function proposeAlias(profile:Profile,phrase:string):any;
|
|
5
|
+
export function contribution(profile:Profile,options?:{includeFixtures?:boolean}):any;
|
|
6
|
+
export function signDocument(keychain:Keychain,kind:string,payload:any):any;
|
|
7
|
+
export function digest(value:any):string;
|
|
8
|
+
export function benchmark(lexicon:Record<string,string>,fixtures:string[]):Profile["benchmark"];
|
|
9
|
+
export function encode(text:string,lexicon:Record<string,string>):string;
|
|
10
|
+
export function decode(text:string,lexicon:Record<string,string>):string;
|
|
11
|
+
export function verifyDocument(document:any):any;
|
|
12
|
+
export const BASE_PROTOCOL:"agent-commons/1";
|
package/src/profiles.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { jcs, Keychain } from "./pillar.mjs";
|
|
3
|
+
import { SELF_CERTIFYING_AGENT_UUAID } from "./uuaid-namespace.mjs";
|
|
4
|
+
|
|
5
|
+
export const BASE_PROTOCOL = "agent-commons/1";
|
|
6
|
+
export function digest(value) { assertJSON(value);return createHash("sha256").update(jcs(value)).digest("hex"); }
|
|
7
|
+
export function assertJSON(value) {
|
|
8
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return;
|
|
9
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) return;
|
|
10
|
+
if (Array.isArray(value)) return value.forEach(assertJSON);
|
|
11
|
+
if (value && Object.getPrototypeOf(value) === Object.prototype) {
|
|
12
|
+
for (const [key, item] of Object.entries(value)) {
|
|
13
|
+
if (!/^[\x20-\x7e]+$/.test(key) || ["__proto__", "prototype", "constructor"].includes(key)) throw new Error("Unsafe canonical key");
|
|
14
|
+
assertJSON(item);
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
throw new Error("Expected canonical JSON; decimal measurements must be strings");
|
|
19
|
+
}
|
|
20
|
+
export function encode(body, lexicon) {
|
|
21
|
+
let wire=body.replaceAll("~","~~");
|
|
22
|
+
for (const [phrase,alias] of Object.entries(lexicon).sort((a,b)=>b[0].length-a[0].length)) wire=wire.split(phrase).join(alias);
|
|
23
|
+
return wire;
|
|
24
|
+
}
|
|
25
|
+
export function decode(wire, lexicon) {
|
|
26
|
+
const reverse=new Map(Object.entries(lexicon).map(([k,v])=>[v,k]));
|
|
27
|
+
return wire.replace(/~~|~\d+~/g,t=>t==="~~"?"~":reverse.get(t)??t);
|
|
28
|
+
}
|
|
29
|
+
export function validateLexicon(lexicon) {
|
|
30
|
+
if (!lexicon || typeof lexicon!=="object" || Array.isArray(lexicon) || Object.keys(lexicon).length>128) throw new Error("Invalid lexicon");
|
|
31
|
+
const symbols=new Set();
|
|
32
|
+
for (const [phrase,symbol] of Object.entries(lexicon)) {
|
|
33
|
+
if (!/^[A-Za-z][A-Za-z ]{7,79}$/.test(phrase) || phrase!==phrase.trim() || !/^~\d{1,3}~$/.test(symbol) || symbols.has(symbol)) throw new Error("Invalid or colliding alias");
|
|
34
|
+
symbols.add(symbol);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function benchmark(lexicon, fixtures) {
|
|
38
|
+
validateLexicon(lexicon);
|
|
39
|
+
let originalBytes=0,wireBytes=0,passed=0;
|
|
40
|
+
for (const fixture of fixtures) {
|
|
41
|
+
const wire=encode(fixture,lexicon);
|
|
42
|
+
if(decode(wire,lexicon)===fixture)passed++;
|
|
43
|
+
originalBytes+=Buffer.byteLength(fixture);wireBytes+=Buffer.byteLength(wire);
|
|
44
|
+
}
|
|
45
|
+
return {tests:fixtures.length,passed,originalBytes,wireBytes,reductionBps:originalBytes?Math.round(10000*(originalBytes-wireBytes)/originalBytes):0};
|
|
46
|
+
}
|
|
47
|
+
const safetyFixtures=["","~0~","~~","~123~","தமிழ் மொழி","🧬","\n\\\"", "a".repeat(128)];
|
|
48
|
+
export function createProfile({ namespace, name, fixtures, scope="local", lexicon={}, parent=null, revision=0, quorum=2 }) {
|
|
49
|
+
if (!/^[a-z0-9][a-z0-9._/-]{1,119}$/.test(namespace)||typeof name!=="string"||name.length<2||name.length>120) throw new Error("Invalid profile name or namespace");
|
|
50
|
+
if (!["local","tenant","global"].includes(scope)) throw new Error("Invalid profile scope");
|
|
51
|
+
if(!Array.isArray(fixtures)||fixtures.length<2||fixtures.length>100||fixtures.some(f=>typeof f!=="string"||f.length>8000)||Buffer.byteLength(JSON.stringify(fixtures))>256000) throw new Error("Provide 2–100 bounded text fixtures");
|
|
52
|
+
if(!Number.isSafeInteger(revision)||revision<0||!Number.isSafeInteger(quorum)||quorum<2||quorum>10)throw new Error("Invalid profile revision or quorum");
|
|
53
|
+
validateLexicon(lexicon);
|
|
54
|
+
const report=benchmark(lexicon,[...fixtures,...safetyFixtures]);
|
|
55
|
+
if(report.tests!==report.passed)throw new Error("Profile fails lossless gate");
|
|
56
|
+
const document={v:BASE_PROTOCOL,namespace,name,scope,parent,revision,quorum,lexicon,fixtureHash:digest(fixtures),fixtures};
|
|
57
|
+
assertJSON(document);
|
|
58
|
+
return {...document,id:digest(document),benchmark:report};
|
|
59
|
+
}
|
|
60
|
+
export function verifyProfile(profile) {
|
|
61
|
+
const allowed=new Set(["v","namespace","name","scope","parent","revision","quorum","lexicon","fixtureHash","fixtures","id","benchmark"]);
|
|
62
|
+
if(Object.keys(profile??{}).some(key=>!allowed.has(key)))throw new Error("Unknown profile field");
|
|
63
|
+
const rebuilt=createProfile(profile);
|
|
64
|
+
if(rebuilt.id!==profile.id || digest(rebuilt.benchmark)!==digest(profile.benchmark))throw new Error("Profile digest or benchmark mismatch");
|
|
65
|
+
return rebuilt;
|
|
66
|
+
}
|
|
67
|
+
export function proposeAlias(profile, phrase) {
|
|
68
|
+
verifyProfile(profile);
|
|
69
|
+
if(Object.hasOwn(profile.lexicon,phrase))throw new Error("Alias already exists");
|
|
70
|
+
const numbers=Object.values(profile.lexicon).map(s=>Number(s.slice(1,-1)));
|
|
71
|
+
const alias=`~${numbers.length?Math.max(...numbers)+1:0}~`;
|
|
72
|
+
const candidate=createProfile({...profile,lexicon:{...profile.lexicon,[phrase]:alias},parent:profile.id,revision:profile.revision+1});
|
|
73
|
+
if(candidate.benchmark.wireBytes>=profile.benchmark.wireBytes)throw new Error("Candidate does not improve the local benchmark");
|
|
74
|
+
return {id:randomUUID(),parent:profile.id,candidate,phrase,alias,status:"pending",votes:{}};
|
|
75
|
+
}
|
|
76
|
+
export function signDocument(keychain, kind, payload) {
|
|
77
|
+
assertJSON(payload);
|
|
78
|
+
const body={v:BASE_PROTOCOL,id:randomUUID(),kind,issuer:keychain._identity.uuaid,publicKey:keychain._identity.publicKeyHex,createdAt:new Date().toISOString(),payload};
|
|
79
|
+
return {...body,signature:keychain.sign(Buffer.from(jcs(body))).toString("hex")};
|
|
80
|
+
}
|
|
81
|
+
export function verifyDocument(document) {
|
|
82
|
+
assertJSON(document);
|
|
83
|
+
const {signature,...body}=document;
|
|
84
|
+
if(body.v!==BASE_PROTOCOL||!["profile-proposal","profile-vote","profile-contribution","profile-recovery"].includes(body.kind)||!SELF_CERTIFYING_AGENT_UUAID.test(body.issuer)||!/^[0-9a-f]{64}$/.test(body.publicKey)||!/^[0-9a-f]{128}$/.test(signature))throw new Error("Invalid agent document");
|
|
85
|
+
if(Keychain.localIdFromKey(Buffer.from(body.publicKey,"hex"))!==body.issuer.split(":")[3])throw new Error("Agent key is not bound to UUAID");
|
|
86
|
+
if(!Keychain.verifyDetached(body.publicKey,Buffer.from(jcs(body)),Buffer.from(signature,"hex")))throw new Error("Invalid document signature");
|
|
87
|
+
return body;
|
|
88
|
+
}
|
|
89
|
+
export function contribution(profile, { includeFixtures=false }={}) {
|
|
90
|
+
verifyProfile(profile);
|
|
91
|
+
return {profileId:profile.id,namespace:profile.namespace,parent:profile.parent,base:BASE_PROTOCOL,lexicon:profile.lexicon,fixtureHash:profile.fixtureHash,benchmark:profile.benchmark,visibility:includeFixtures?"shared-fixtures":"metadata-only",...(includeFixtures?{profile}:{}),stage:"candidate",standardization:"requires-IAASO-review"};
|
|
92
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// Cloud Commons relay: request authentication and response signing (spec v3.4.5 sections 6.1,
|
|
2
|
+
// 6.2, 6.3 and 6.5 "Signer = by").
|
|
3
|
+
//
|
|
4
|
+
// Port source (B1): the identity repo at commit cf48f0e, ported verbatim:
|
|
5
|
+
// packages/pillar-client/src/identity-request.ts - the header name and grammar, the strict
|
|
6
|
+
// parser (:95-114), the descriptor (:54-66), the body hash (:71-74), the signing bytes
|
|
7
|
+
// (:77-79) and verifyIdentityRequest;
|
|
8
|
+
// packages/pillar-client/src/identity.ts - isStrictEd25519PublicKey and verifyBytesStrict
|
|
9
|
+
// (RFC 8032 strict: canonical A and R, never a small-order or mixed-order key);
|
|
10
|
+
// apps/api/src/self-auth.ts:41-77 - the verifier order: a stale request is refused unverified;
|
|
11
|
+
// a future-dated one is verified and refused only after its nonce is spent.
|
|
12
|
+
// The port passes the vector docs/standards/agent-vault-wallet/vectors/identity-auth.json at that
|
|
13
|
+
// commit, with the vector's own audience and namespace (test/relay-auth.test.mjs), before it is
|
|
14
|
+
// used with the relay's.
|
|
15
|
+
//
|
|
16
|
+
// The relay's differences (6.1, 6.3): the audience is one configured string compared byte for byte
|
|
17
|
+
// (never the Host header); the principal header `commons-principal` names the agent and must be an
|
|
18
|
+
// agentnet id (B3); the key comes from the relay's own keys table (D-i); every principal failure is
|
|
19
|
+
// the same 401 {"error":"unauthorized"} after a check against a fixed dummy key; the nonce is spent
|
|
20
|
+
// (UNIQUE(principal, nonce)) before the future check; a replay is 409. On every fleet record
|
|
21
|
+
// the authenticated signer must equal the body's `by` (6.5).
|
|
22
|
+
//
|
|
23
|
+
// 6.2: every relay response carries `commons-relay-sig`, Ed25519 by the relay key over
|
|
24
|
+
// JCS({aud, request_nonce, status, body_sha256}); signRelayResponse takes the relay key seed from
|
|
25
|
+
// its caller (the server) and verifyRelayResponse is the client's check against the pinned key.
|
|
26
|
+
//
|
|
27
|
+
// This module performs no network I/O and stores no private key. The server applies the size
|
|
28
|
+
// cap, the route inventory and the browser-origin refusal before calling authenticateRequest.
|
|
29
|
+
|
|
30
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
31
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
32
|
+
import { jcs } from "../pillar.mjs";
|
|
33
|
+
import { localIdFromKey } from "../vendor/pillar/identity/keychain.mjs";
|
|
34
|
+
import { keyBindsAgentUuaid } from "../uuaid-namespace.mjs";
|
|
35
|
+
import { RelayError, assertIntegerOnly } from "./records.mjs";
|
|
36
|
+
import { tx } from "./schema.mjs";
|
|
37
|
+
|
|
38
|
+
// --- verbatim port: identity-request.ts (cf48f0e) ----------------------------------------
|
|
39
|
+
|
|
40
|
+
export const IDENTITY_AUTH_HEADER = "uuaid-identity-auth";
|
|
41
|
+
export const IDENTITY_REQUEST_TYPE = "uuaid.identity.request.v1";
|
|
42
|
+
/** The methods an identity key may sign. */
|
|
43
|
+
export const IDENTITY_REQUEST_METHODS = Object.freeze(["GET", "PUT", "POST", "DELETE", "PATCH"]);
|
|
44
|
+
|
|
45
|
+
/** The object the identity key signs. Never transmitted: the verifier rebuilds it. */
|
|
46
|
+
export function identityRequestDescriptor(i) {
|
|
47
|
+
return {
|
|
48
|
+
type: IDENTITY_REQUEST_TYPE,
|
|
49
|
+
aud: i.audience,
|
|
50
|
+
agent: i.agent,
|
|
51
|
+
method: i.method.toUpperCase(),
|
|
52
|
+
path: i.path,
|
|
53
|
+
query: i.query ?? "",
|
|
54
|
+
body_sha256: i.bodySha256,
|
|
55
|
+
created: i.created,
|
|
56
|
+
nonce: i.nonce,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** sha256 hex of JCS(body); null when there is no body. */
|
|
61
|
+
export function identityRequestBodyHash(body) {
|
|
62
|
+
if (body === undefined || body === null) return null;
|
|
63
|
+
return createHash("sha256").update(Buffer.from(jcs(body), "utf8")).digest("hex");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The exact bytes the identity key signs. */
|
|
67
|
+
export function identityRequestSigningBytes(d) {
|
|
68
|
+
return Buffer.from(`${IDENTITY_REQUEST_TYPE}\n${jcs(d)}`, "utf8");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatIdentityAuthHeader(h) {
|
|
72
|
+
return `v=${h.v}; created=${h.created}; nonce=${h.nonce}; ed25519=${h.ed25519}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const CREATED_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
|
|
76
|
+
|
|
77
|
+
/** Parse the header strictly: every field once, nothing else. Null on any defect. */
|
|
78
|
+
export function parseIdentityAuthHeader(value) {
|
|
79
|
+
if (typeof value !== "string" || value.length > 512) return null;
|
|
80
|
+
const fields = new Map();
|
|
81
|
+
for (const part of value.split(";")) {
|
|
82
|
+
const t = part.trim();
|
|
83
|
+
const eq = t.indexOf("=");
|
|
84
|
+
if (eq <= 0) return null;
|
|
85
|
+
const k = t.slice(0, eq);
|
|
86
|
+
if (fields.has(k)) return null;
|
|
87
|
+
fields.set(k, t.slice(eq + 1));
|
|
88
|
+
}
|
|
89
|
+
const keys = ["v", "created", "nonce", "ed25519"];
|
|
90
|
+
if (fields.size !== keys.length || !keys.every((k) => fields.has(k))) return null;
|
|
91
|
+
const created = fields.get("created");
|
|
92
|
+
if (fields.get("v") !== "1") return null;
|
|
93
|
+
if (!CREATED_RE.test(created) || Number.isNaN(Date.parse(created))) return null;
|
|
94
|
+
if (!/^[0-9a-f]{32,64}$/.test(fields.get("nonce"))) return null;
|
|
95
|
+
if (!/^[0-9a-f]{128}$/.test(fields.get("ed25519"))) return null;
|
|
96
|
+
return { v: "1", created, nonce: fields.get("nonce"), ed25519: fields.get("ed25519") };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- verbatim port: identity.ts (cf48f0e) -------------------------------------------------
|
|
100
|
+
|
|
101
|
+
export function isStrictEd25519PublicKey(pubKeyHex) {
|
|
102
|
+
if (typeof pubKeyHex !== "string" || !/^[0-9a-f]{64}$/i.test(pubKeyHex)) return false;
|
|
103
|
+
const hex = pubKeyHex.toLowerCase();
|
|
104
|
+
try {
|
|
105
|
+
const point = ed25519.Point.fromHex(hex, false);
|
|
106
|
+
return point.toHex() === hex && !point.isSmallOrder() && point.isTorsionFree();
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function verifyBytesStrict(pubKeyHex, msg, sigHex) {
|
|
113
|
+
if (!isStrictEd25519PublicKey(pubKeyHex)) return false;
|
|
114
|
+
if (typeof sigHex !== "string" || !/^[0-9a-f]{128}$/i.test(sigHex)) return false;
|
|
115
|
+
try {
|
|
116
|
+
return ed25519.verify(Buffer.from(sigHex.toLowerCase(), "hex"), msg, Buffer.from(pubKeyHex.toLowerCase(), "hex"), { zip215: false });
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Verify a parsed header against a rebuilt descriptor and the agent's identity key (RFC 8032, prime-order key). */
|
|
123
|
+
export function verifyIdentityRequest(d, h, publicKeyHex) {
|
|
124
|
+
if (d.created !== h.created || d.nonce !== h.nonce) return false;
|
|
125
|
+
return verifyBytesStrict(publicKeyHex, identityRequestSigningBytes(d), h.ed25519);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sign a request with an identity key seed (the reference client's signer; tests). Returns the
|
|
130
|
+
* header value. The seed never leaves the caller. `created` and `nonce` overrides are for fixed
|
|
131
|
+
* test vectors only.
|
|
132
|
+
*/
|
|
133
|
+
export function signIdentityRequest({ seedHex, namespace = "agentnet", method, path, query, body, audience, created, nonce }) {
|
|
134
|
+
const seed = Buffer.from(seedHex, "hex");
|
|
135
|
+
const pub = Buffer.from(ed25519.getPublicKey(seed));
|
|
136
|
+
const agent = `uuaid:${namespace}:agent:${localIdFromKey(pub)}`;
|
|
137
|
+
const c = created ?? new Date().toISOString();
|
|
138
|
+
const n = nonce ?? randomBytes(16).toString("hex");
|
|
139
|
+
const d = identityRequestDescriptor({ audience, agent, method, path, query, bodySha256: identityRequestBodyHash(body), created: c, nonce: n });
|
|
140
|
+
const sig = Buffer.from(ed25519.sign(identityRequestSigningBytes(d), seed)).toString("hex");
|
|
141
|
+
return { agent, publicKey: pub.toString("hex"), header: formatIdentityAuthHeader({ v: "1", created: c, nonce: n, ed25519: sig }) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- the relay verifier (6.1, 6.3) ----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
export const PRINCIPAL_HEADER = "commons-principal";
|
|
147
|
+
/** The test audience (6.1, B5): `.test` is reserved (RFC 6761); a deployed relay is configured with its own origin. */
|
|
148
|
+
export const RELAY_TEST_AUD = "http://commons-relay.test:8080";
|
|
149
|
+
export const AUTH_MAX_AGE_MS = 5 * 60 * 1000;
|
|
150
|
+
export const AUTH_MAX_FUTURE_MS = 60 * 1000;
|
|
151
|
+
export const NONCE_KEEP_MS = 10 * 60 * 1000; // covers the 6-minute acceptance span (6.1)
|
|
152
|
+
|
|
153
|
+
/** The uniform refusal (6.3): the same status and body for every principal or signature failure. */
|
|
154
|
+
export const UNAUTHORIZED = Object.freeze({ status: 401, body: Object.freeze({ error: "unauthorized" }) });
|
|
155
|
+
const STALE = Object.freeze({ status: 401, body: Object.freeze({ error: "stale" }) }); // principal-independent (6.3)
|
|
156
|
+
const REPLAY = Object.freeze({ status: 409, body: Object.freeze({ error: "replay" }) }); // reachable only with a valid signature
|
|
157
|
+
|
|
158
|
+
// A fixed, valid, strict key with no known seed holder: the refusal path for an unknown or
|
|
159
|
+
// ineligible principal still runs one signature check, so it takes similar time (6.3).
|
|
160
|
+
const DUMMY_PUBLIC_KEY = Buffer.from(ed25519.getPublicKey(createHash("sha256").update("commons-relay.dummy-key.v1").digest())).toString("hex");
|
|
161
|
+
|
|
162
|
+
const AGENTNET_AGENT = /^uuaid:agentnet:agent:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
163
|
+
/** B3: an agentnet agent id (the only namespace the relay accepts). */
|
|
164
|
+
export const isAgentnetAgent = (value) => typeof value === "string" && AGENTNET_AGENT.test(value);
|
|
165
|
+
const header = (headers, name) => {
|
|
166
|
+
const v = headers?.[name] ?? headers?.[name.toLowerCase()];
|
|
167
|
+
return Array.isArray(v) ? null : v; // a repeated header is a malformed request
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The principal a key row names, with the facts the verifier needs, read from the relay's tables
|
|
172
|
+
* on every request with no cache (M3). Null when the agent has no key row.
|
|
173
|
+
*/
|
|
174
|
+
function principalOf(db, agent) {
|
|
175
|
+
const k = db.prepare("SELECT public_key, agent, principal_kind, principal_id, status FROM keys WHERE agent = ?").get(agent);
|
|
176
|
+
if (!k) return null;
|
|
177
|
+
if (k.principal_kind === "fleet") {
|
|
178
|
+
const f = db.prepare("SELECT kind, status FROM fleet_principals WHERE principal_id = ?").get(k.principal_id);
|
|
179
|
+
return { kind: "fleet", id: k.principal_id, agent, publicKey: k.public_key, keyStatus: k.status, fleetKind: f?.kind ?? null, status: f?.status ?? null };
|
|
180
|
+
}
|
|
181
|
+
const m = db.prepare("SELECT status, role FROM members WHERE member_id = ?").get(k.principal_id);
|
|
182
|
+
return { kind: "member", id: k.principal_id, agent, publicKey: k.public_key, keyStatus: k.status, status: m?.status ?? null, role: m?.role ?? null };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether this principal may be authenticated on this request (6.1 step 3):
|
|
187
|
+
* - a fleet principal: an active key of an active roster principal;
|
|
188
|
+
* - a member: an active key and status active; or, only where `allowPending` (GET /v1/status
|
|
189
|
+
* only), a pending member on its pending enrollment key.
|
|
190
|
+
* Suspended, revoked, unknown, retired and revoked-key principals are ineligible: uniform 401.
|
|
191
|
+
*/
|
|
192
|
+
function eligible(p, allowPending) {
|
|
193
|
+
if (!p) return false;
|
|
194
|
+
if (p.kind === "fleet") return p.keyStatus === "active" && p.status === "active";
|
|
195
|
+
if (p.keyStatus === "active" && p.status === "active") return true;
|
|
196
|
+
return allowPending && p.keyStatus === "pending" && p.status === "pending";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Authenticate one request (6.1 steps 2-6; 3 and 8 read the tables on every request).
|
|
201
|
+
* `req` = { method, path, query, headers, body } with `body` the parsed JSON body (undefined when
|
|
202
|
+
* there is none); `aud` the relay's configured audience; `now` the relay clock in ms.
|
|
203
|
+
* Returns { ok: true, principal } or { ok: false, status, body }. Throws RelayError
|
|
204
|
+
* non-integer-number (400) for a body JCS cannot hash, before any signature or nonce work (B4).
|
|
205
|
+
*/
|
|
206
|
+
/** 6.1 `created`: the header grammar's RFC 3339 UTC form (shared with the enrollment flow). */
|
|
207
|
+
export const isCreated = (value) => typeof value === "string" && CREATED_RE.test(value) && !Number.isNaN(Date.parse(value));
|
|
208
|
+
/** 6.1 `nonce`: 32-64 lower-case hex (shared with the enrollment flow). */
|
|
209
|
+
export const isNonce = (value) => typeof value === "string" && /^[0-9a-f]{32,64}$/.test(value);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 6.1 step 5 / 5.2 step 6 (B9): spend (principal, nonce) in the one request-nonce table, pruning
|
|
213
|
+
* expired rows. Returns false on a replay. The row is kept NONCE_KEEP_MS past max(created, now).
|
|
214
|
+
*/
|
|
215
|
+
export function spendNonce(db, principal, nonce, createdMs, now) {
|
|
216
|
+
return tx(db, () => {
|
|
217
|
+
db.prepare("DELETE FROM request_nonces WHERE expires_at <= ?").run(now);
|
|
218
|
+
const r = db.prepare("INSERT INTO request_nonces(principal, nonce, expires_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING")
|
|
219
|
+
.run(principal, nonce, Math.max(createdMs, now) + NONCE_KEEP_MS);
|
|
220
|
+
return r.changes === 1;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function authenticateRequest(db, req, { aud, now, allowPending = false }) {
|
|
225
|
+
if (typeof aud !== "string" || !aud) throw new RelayError("relay-aud-unset", 500);
|
|
226
|
+
if (!Number.isSafeInteger(now)) throw new RelayError("invalid-time", 500);
|
|
227
|
+
// Step 2: parse; a stale request is refused before any principal or signature work.
|
|
228
|
+
const h = parseIdentityAuthHeader(header(req.headers, IDENTITY_AUTH_HEADER));
|
|
229
|
+
const agent = header(req.headers, PRINCIPAL_HEADER);
|
|
230
|
+
if (!h || typeof agent !== "string") return { ok: false, ...UNAUTHORIZED };
|
|
231
|
+
const created = Date.parse(h.created);
|
|
232
|
+
if (created < now - AUTH_MAX_AGE_MS) return { ok: false, ...STALE };
|
|
233
|
+
// B4: integer-only bodies; hashing fails before any signature work, so no nonce is spent.
|
|
234
|
+
if (req.body !== undefined && req.body !== null) assertIntegerOnly(req.body);
|
|
235
|
+
const bodySha256 = identityRequestBodyHash(req.body);
|
|
236
|
+
// Step 3: the principal's current key, from the relay's own tables (D-i); never from the request.
|
|
237
|
+
const p = AGENTNET_AGENT.test(agent) ? principalOf(db, agent) : null;
|
|
238
|
+
const ok = eligible(p, allowPending);
|
|
239
|
+
const key = ok ? p.publicKey : DUMMY_PUBLIC_KEY;
|
|
240
|
+
// Step 4: strict Ed25519; the key derives the agent in agentnet (B3); the signature over the
|
|
241
|
+
// descriptor the relay rebuilds from the request it received, with its own audience (B5).
|
|
242
|
+
const d = identityRequestDescriptor({ audience: aud, agent, method: req.method, path: req.path, query: req.query ?? "", bodySha256, created: h.created, nonce: h.nonce });
|
|
243
|
+
const verified = verifyIdentityRequest(d, h, key) && isStrictEd25519PublicKey(key) && keyBindsAgentUuaid(agent, key);
|
|
244
|
+
if (!ok || !verified) return { ok: false, ...UNAUTHORIZED };
|
|
245
|
+
// Step 5: spend the nonce before the future check. It stays spent even if a later step fails.
|
|
246
|
+
if (!spendNonce(db, agent, h.nonce, created, now)) return { ok: false, ...REPLAY };
|
|
247
|
+
// Step 6: more than 60 s ahead is refused, after the nonce is spent.
|
|
248
|
+
if (created > now + AUTH_MAX_FUTURE_MS) return { ok: false, ...STALE };
|
|
249
|
+
return { ok: true, principal: { kind: p.kind, id: p.id, agent, status: p.status, ...(p.kind === "fleet" ? { fleetKind: p.fleetKind } : { role: p.role }) } };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* 6.5 "Signer = by": a fleet record is accepted only from the fleet principal its
|
|
254
|
+
* body names. A member principal, or any other fleet principal, gets the uniform 401, so a captured
|
|
255
|
+
* signed body cannot be re-submitted by another principal.
|
|
256
|
+
*/
|
|
257
|
+
export function signerMatchesBy(principal, body) {
|
|
258
|
+
return !!principal && principal.kind === "fleet" && !!body && typeof body === "object" && body.by === principal.id;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// --- relay authenticity (6.2) ----------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/** 6.2: the header that carries the relay's signature on every response. */
|
|
264
|
+
export const RELAY_SIG_HEADER = "commons-relay-sig";
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 6.2: the bytes the relay signs for one response: JCS({aud, request_nonce, status, body_sha256}),
|
|
268
|
+
* with body_sha256 defined as in 6.1 (sha256 hex of JCS(body), or null when there is no body).
|
|
269
|
+
* `requestNonce` is the request's own 6.1 nonce, or null when the request carried no header that parses.
|
|
270
|
+
*/
|
|
271
|
+
export function relayResponseSigningBytes({ aud, requestNonce, status, body }) {
|
|
272
|
+
if (typeof aud !== "string" || !aud) throw new RelayError("relay-aud-unset", 500);
|
|
273
|
+
if (!Number.isSafeInteger(status) || status < 100 || status > 599) throw new RelayError("invalid-status", 500);
|
|
274
|
+
if (requestNonce !== null && !(typeof requestNonce === "string" && /^[0-9a-f]{32,64}$/.test(requestNonce))) throw new RelayError("invalid-nonce", 500);
|
|
275
|
+
return Buffer.from(jcs({ aud, request_nonce: requestNonce, status, body_sha256: identityRequestBodyHash(body) }), "utf8");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Sign a response with the relay key seed (32-byte hex, held by the server only). Returns 128 hex. */
|
|
279
|
+
export function signRelayResponse(relaySeedHex, response) {
|
|
280
|
+
return Buffer.from(ed25519.sign(relayResponseSigningBytes(response), Buffer.from(relaySeedHex, "hex"))).toString("hex");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** A client's check (6.2): strict Ed25519 against the pinned 64-hex relay key, byte for byte. */
|
|
284
|
+
export function verifyRelayResponse(relayPublicKeyHex, response, sigHex) {
|
|
285
|
+
if (typeof relayPublicKeyHex !== "string" || !/^[0-9a-f]{64}$/.test(relayPublicKeyHex)) return false;
|
|
286
|
+
let bytes;
|
|
287
|
+
try { bytes = relayResponseSigningBytes(response); } catch { return false; }
|
|
288
|
+
return isStrictEd25519PublicKey(relayPublicKeyHex) && verifyBytesStrict(relayPublicKeyHex, bytes, sigHex);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The request's 6.1 nonce for the response signature: the strict header parse, or null. */
|
|
292
|
+
export function requestNonceOf(headers) {
|
|
293
|
+
return parseIdentityAuthHeader(header(headers, IDENTITY_AUTH_HEADER))?.nonce ?? null;
|
|
294
|
+
}
|