Bifrost/tools/persona-discord-permit.mjs

39 lines
8.4 KiB
JavaScript

import {createHash,randomBytes} from "node:crypto";
import {createSocket} from "node:dgram";
import {createRequire} from "node:module";
import {existsSync} from "node:fs";
import {readFile} from "node:fs/promises";
import {resolve} from "node:path";
import {loadDomainIdentity,signDomainPurpose,verifyDomainPurpose} from "./bifrost-signed-identity.mjs";
export const permitRequestSchema="bifrost.persona_discord_delivery_permit_request.v0";
export const permitSchema="epiphany.persona_discord_delivery_permit.v0";
export const permitRequestPurpose="bifrost.persona-discord.delivery-permit-request.v0";
export const permitPurpose="epiphany.persona-discord.delivery-permit.v0";
export const permitRequestIdentityDomain="bifrost.persona-discord-delivery-permit-request.identity.v0\0";
export const permitRequestSignatureDomain="bifrost.persona-discord-delivery-permit-request.signature.v0\0";
export const permitIdentityDomain="epiphany.persona-discord-delivery-permit.identity.v0\0";
export const permitSignatureDomain="epiphany.persona-discord-delivery-permit.signature.v0\0";
const connectionId=0xe91f0002;
export const permitRequestTuple=v=>[v.schemaVersion,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.issuedAt,v.expiresAt,v.requesterIdentityId,v.privateStateExposed];
export const permitTuple=v=>[v.schemaVersion,v.permitId,v.requestId,v.requestPayloadSha256,v.targetRuntimeId,v.nonce,v.requesterIdentityId,v.brakeStateDocumentId,v.brakeStateDocumentSha256,v.brakeObservedAt,v.issuedAt,v.expiresAt,v.providerIdentityId,v.privateStateExposed];
export const permitRequestWireTuple=v=>[...permitRequestTuple(v),v.requesterSignature];
export function parsePermitWireTuple(v){if(!Array.isArray(v)||v.length!==15)throw new Error("Epiphany permit must be an exact Rust wire tuple");const [schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature]=v;return {schemaVersion,permitId,requestId,requestPayloadSha256,targetRuntimeId,nonce,requesterIdentityId,brakeStateDocumentId,brakeStateDocumentSha256,brakeObservedAt,issuedAt,expiresAt,providerIdentityId,privateStateExposed,providerSignature};}
export async function requestPersonaDiscordPermit({endpoint,requestId,requestPayloadSha256,targetRuntimeId,nonce,issuedAt,expiresAt,privateKeyPath,epiphanyPermitAnchorPath,timeoutMs=5000,runtime:provided,socketFactory=createSocket,connectionFactory}){
const runtime=provided??loadRuntime(),identity=await loadDomainIdentity(privateKeyPath,"Bifrost Persona Discord permit requester identity",permitRequestIdentityDomain),anchor=await readAnchor(runtime,epiphanyPermitAnchorPath,targetRuntimeId),now=Date.now();
const request={schemaVersion:permitRequestSchema,requestId,requestPayloadSha256,targetRuntimeId,nonce:nonce??randomBytes(16).toString("hex"),issuedAt:issuedAt??new Date(now).toISOString(),expiresAt:expiresAt??new Date(now+5000).toISOString(),requesterIdentityId:identity.identityId,privateStateExposed:false,requesterSignature:new Uint8Array()};
request.requesterSignature=new Uint8Array(signDomainPurpose(identity,permitRequestSignatureDomain,permitRequestPurpose,runtime.msgpack.encode(permitRequestTuple(request))));
const {host,port}=parseEndpoint(endpoint),socket=socketFactory("udp4");await bind(socket);const connection=(connectionFactory??(o=>new runtime.cultnet.CultNetRudpSocketTransportConnection(o)))({runtimeId:"bifrost-discord-yggdrasil",socket,mode:"client",remoteHost:host,remotePort:port,connectionId,resendDelayMs:50,resendPollMs:10});
try{connection.connect();await connected(connection,timeoutMs);const response=wait(connection,runtime.msgpack,timeoutMs,request);const message={schemaVersion:"cultnet.document_put_raw.v0",messageId:`permit-request-${requestId}`,document:{schemaId:permitRequestSchema,recordKey:requestId,storedAt:request.issuedAt,payloadEncoding:"messagepack",payload:runtime.msgpack.encode(permitRequestWireTuple(request)),sourceRuntimeId:"bifrost-discord-yggdrasil",sourceAgentId:identity.identityId,sourceRole:"bifrost-persona-discord-delivery",tags:["cultnet.transport.rudp.v0"]}};connection.send("schema",runtime.msgpack.encode(runtime.cultnet.encodeCultNetMessageForWire(message,"cultnet.schema.v0")));const permit=await response;validatePermit(permit,request,anchor,runtime.msgpack.encode);return permit;}finally{connection.close();}
}
export function validatePermit(value,request,anchor,encode){const v=Array.isArray(value)?parsePermitWireTuple(value):value,providerSignature=v?.providerSignature instanceof Uint8Array?v.providerSignature:Array.isArray(v?.providerSignature)?Uint8Array.from(v.providerSignature):null;if(!v||v.schemaVersion!==permitSchema||v.requestId!==request.requestId||v.requestPayloadSha256!==request.requestPayloadSha256||v.targetRuntimeId!==request.targetRuntimeId||v.nonce!==request.nonce||v.requesterIdentityId!==request.requesterIdentityId||v.providerIdentityId!==anchor.identityId||v.privateStateExposed!==false||!/^sha256-[0-9a-f]{64}$/.test(v.brakeStateDocumentSha256)||!providerSignature||providerSignature.length!==64)throw new Error("Epiphany permit substituted delivery authority");const issued=Date.parse(v.issuedAt),expires=Date.parse(v.expiresAt),observed=Date.parse(v.brakeObservedAt),now=Date.now();if(!Number.isFinite(issued)||!Number.isFinite(expires)||!Number.isFinite(observed)||expires<=issued||expires-issued>5000||expires<now||observed>issued||issued>now+1000)throw new Error("Epiphany permit is stale or has an invalid lifetime");if(!verifyDomainPurpose(anchor.publicKey,permitSignatureDomain,permitPurpose,encode(permitTuple(v)),providerSignature))throw new Error("Epiphany permit signature is invalid");return {...v,providerSignature};}
export function parseEpiphanyPermitAnchorTuple(a,expectedRuntimeId,now=Date.now()){if(!Array.isArray(a)||a.length!==13)throw new Error("Epiphany permit anchor must be an exact service anchor");const [schemaVersion,,serviceId,runtimeId,identityId,rawPublicKey,algorithm,purpose,signedSchema,authority,boundAt,expiresAt,privateStateExposed]=a,publicKey=rawPublicKey instanceof Uint8Array?rawPublicKey:Array.isArray(rawPublicKey)?Uint8Array.from(rawPublicKey):null;if(schemaVersion!=="gamecult.service_trust_anchor.v1"||serviceId!=="epiphany-persona-discord-permit"||runtimeId!==expectedRuntimeId||!publicKey||publicKey.length!==32||algorithm!=="ed25519"||purpose!==permitPurpose||signedSchema!==permitSchema||authority!=="root"||boundAt>now||(expiresAt!==null&&expiresAt<=now)||privateStateExposed!==false)throw new Error("Epiphany permit anchor is invalid");return {identityId,publicKey};}
async function readAnchor(runtime,path,expectedRuntimeId){return parseEpiphanyPermitAnchorTuple(runtime.msgpack.decode(await readFile(path)),expectedRuntimeId);}
function wait(c,m,t,r){return new Promise((ok,no)=>{const done=(f,v)=>{clearTimeout(timer);c.off?.("frame",frame);c.off?.("error",error);f(v)},error=e=>done(no,e),frame=x=>{try{if(x.channelId!=="schema")throw new Error("permit arrived on foreign channel");const w=m.decode(x.payload),d=w?.document,p=parsePermitWireTuple(m.decode(d?.payload));if(w?.schemaVersion!=="cultnet.document_put_raw.v0"||d?.schemaId!==permitSchema||d?.recordKey!==`permit:${r.requestPayloadSha256}`||d?.payloadEncoding!=="messagepack"||d?.sourceRuntimeId!==r.targetRuntimeId||d?.sourceRole!=="epiphany-persona-discord-permit"||d?.sourceAgentId!==p.providerIdentityId)throw new Error("permit envelope substituted authority");done(ok,p)}catch(e){done(no,e)}};const timer=setTimeout(()=>done(no,new Error("Timed out waiting for Epiphany Persona Discord permit")),t);c.on("frame",frame);c.on("error",error)})}
async function connected(c,t){const s=Date.now();while(!c.connected){if(Date.now()-s>=t)throw new Error("Timed out establishing permit RUDP session");await new Promise(r=>setTimeout(r,5));}}
function bind(s){return new Promise((ok,no)=>{s.once("error",no);s.once("listening",ok);s.bind(0,"0.0.0.0")})}
function parseEndpoint(v){const u=new URL(v);if(u.protocol!=="rudp:"||!u.hostname||!u.port)throw new Error("permit endpoint must be rudp://host:port");return {host:u.hostname,port:Number(u.port)}}
function loadRuntime(){const root=resolve(import.meta.dirname,".."),cult=resolve(process.env.VOIDBOT_CULTLIB_ROOT||resolve(root,"..","CultLib")),pkg=resolve(cult,"packages","cultnet-ts","package.json");if(!existsSync(pkg))throw new Error("CultNet runtime unavailable");const req=createRequire(pkg);return {cultnet:req("cultnet-ts"),msgpack:req("@msgpack/msgpack")};}