/// SPDX-License-Identifier: GPL-3.0-or-later /// SPDX-FileCopyrightText: Copyright © 2016-2025 Tony Garnock-Jones import { AnyValue, Assertion, Bytes, Dataspace, IdentitySet, Record, Ref, Relay, Schemas, Supervisor, Turn, assertionFacetObserver, canonicalEncode, decode, fromJS, isEmbedded, parse, stringify, underlying, Embeddable, SaltyCrypto, Question, Answer, Result, resultOk, resultError, } from "@syndicate-lang/core"; import G = Schemas.gatekeeper; import S = Schemas.sturdy; import N = Schemas.noise; import T = Schemas.transportAddress; import E = Schemas.stdenv; import R = Schemas.rpc; export * from './seal.js'; type TransportState = { addr: AnyValue, control: Ref, peer: Ref, }; export type Options = boolean /* debug */ | Partial; export type IOptions = { debug: boolean; inboundHook: (url: string, data: Uint8Array) => Uint8Array; outboundHook: (url: string, data: Uint8Array) => Uint8Array; }; export function boot(ds = Dataspace.local, options0: Options = {}, WebSocketConstructor?: typeof WebSocket) { const options1: Partial = typeof options0 === 'boolean' ? { debug: options0 } : Object.assign({}, options0); const options: IOptions = { debug: options1.debug ?? false, inboundHook: options1.inboundHook ?? ((_, data) => data), outboundHook: options1.outboundHook ?? ((_, data) => data), }; spawn named 'transportConnector' { at ds { during Question(G.ConnectTransport(T.WebSocket($url: string))) => { let counter = 0; Supervisor.always(() => ['wsTransport', fromJS(url), counter++], () => { console.log('connecting', url, counter); connectWs(url); }); } } } function connectWs(url: string) { const facet = Turn.activeFacet; facet.preventInertCheck(); const controlEntity = { message(a0: Assertion): void { const a = G.toTransportControl(a0); if (!a) return; stop facet; // ForceDisconnect }, }; let final = false; function succeed(ws: WebSocket) { if (final) return; final = true; console.log('opened', url); on stop { console.log('closing', url); ws.close(); } ws.onclose = () => facet.turn(() => { stop facet; }); ws.onerror = () => facet.turn(() => Turn.active.crash(new Error("WebSocket error"))); const relay = new Relay.Relay({ debug: options.debug, trustPeer: true, packetWriter: bs => ws.send(options.outboundHook(url, bs)), setup: r => ws.onmessage = e => r.accept(options.inboundHook(url, new Uint8Array(e.data))), initialOid: 0, }); console.log('succeed', url); at ds { assert Answer(G.ConnectTransport(fromJS(T.WebSocket(url))), resultOk(G.ConnectedTransport({ "addr": fromJS(T.WebSocket(url)), "control": create controlEntity, "responderSession": relay.peer!, }))); } } function fail(detail: Assertion) { if (final) return; final = true; console.log('fail', url, detail); at ds { assert Answer(G.ConnectTransport(fromJS(T.WebSocket(url))), resultError(detail)); } setTimeout(() => facet.turn(() => { stop facet; }), 10000); } try { const ws = new (WebSocketConstructor ?? WebSocket)(url); ws.binaryType = 'arraybuffer'; ws.onopen = () => facet.turn(() => succeed(ws)); ws.onclose = () => facet.turn(() => fail(Symbol.for('closed'))); ws.onerror = () => facet.turn(() => fail(Symbol.for('websocket-error-event'))); } catch (e) { console.error('Failed opening websocket', url, e); fail(Symbol.for('websocket-exception')); } } spawn named 'pathResolver' { at ds { during Question(G.ResolvePath($route0)) => { const route = G.toRoute(route0); if (!route) return; field candidates: IdentitySet = new IdentitySet(); route.transports.forEach(addr => { console.log('tracking', addr); assert Question(G.ConnectTransport(addr)); during Answer(G.ConnectTransport(addr), resultOk(G.ConnectedTransport({ "addr": addr, "control": $control: Ref, "responderSession": $peer: Ref, }))) => { const entry = { addr, control, peer }; candidates.value.add(entry); candidates.changed(); on stop { candidates.value.delete(entry); candidates.changed(); } } }); field best: TransportState | null = null; field rootPeer: Ref | null = null; dataflow { best.suppressCycleWarning(); best.value = null; for (const c of candidates.value) { best.value = c; break; } rootPeer.value = best.value?.peer ?? null; } resolve(() => ({ ref: rootPeer.value, error: false }), route.pathSteps, (r) => { console.log('leaf', best.value?.addr); assert Answer(G.ResolvePath(route), resultOk(G.ResolvedPath({ "addr": best.value!.addr, "control": best.value!.control, "responderSession": r().ref!, }))) when (r().ref); assert Answer(G.ResolvePath(route), resultError(r().error)) when (!r().ref); }); } } } function resolve( e: () => { ref: Ref | null, error: AnyValue }, // gross steps: G.PathStep[], k: (r: () => { ref: Ref | null, error: AnyValue }) => void, ) { if (steps.length === 0) { k(e); } else { const [step, ...more] = steps; at ds { const q = () => G.ResolvePathStep({ origin: (e().ref!), pathStep: step }); assert Question(q()) when (e().ref); during Answer(((q)()) /* omg */, $a: Result) when (e().ref) => { switch (a._variant) { case "Ok": // Include a call to our e() in the e we pass in to the recursive // call to resolve(). e() returning non-null is a precondition for // the call; if that precondition ever changes, we want to NOT // reevaluate the body of any assertion, so we should test it // before we do. // // Concrete example of a problem that occurs if the `e() && ...` // isn't there: connected all OK, the websocket disconnects, // best.value gets set null, the previous assertion of ResolvePath // at the end of the chain gets reevaluated because // resolved.responderSession is still non-null, it includes // best.value!, which is now null, boom. With the call to e(), we // short circuit and so the assertion becomes null at that point. // const nextRef = G.toResolvedPathStep(a.value.value); const nextResult = (nextRef === void 0) ? { ref: null, error: 'bad ResolvedPathStep' } : { ref: nextRef, error: false }; const nextE = () => { const result = e(); return result.ref ? nextResult : result; }; resolve(nextE, more, k); break; case "Error": k(() => ({ ref: null, error: a.value.error })); break; } } } } } spawn named 'noiseStep' { at ds { during Question($q(G.ResolvePathStep({ origin: $origin: Ref, pathStep: G.PathStep({ stepType: N.$noise, detail: $spec: N.NoisePathStepDetail, }), }))) => { const algorithms = SaltyCrypto.Noise_25519_ChaChaPoly_BLAKE2s; const protocol = spec.protocol._variant === "present" ? spec.protocol.protocol : spec.protocol._variant === "absent" ? N.fromDefaultProtocol(N.DefaultProtocol()) as string : (() => { throw new Error("Invalid noise protocol name"); })(); const patternName0 = SaltyCrypto.matchPattern(algorithms, protocol); if (patternName0 === null) throw new Error("Unsupported protocol " + protocol); const patternName = patternName0; const preSharedKeys = spec.preSharedKeys._variant === "present" ? spec.preSharedKeys.preSharedKeys : spec.preSharedKeys._variant === "absent" ? [] : (() => { throw new Error("Invalid pre-shared keys"); })(); const prologue = underlying(canonicalEncode(spec.service)); react { at origin { assert G.Resolve({ "step": G.Step({ "stepType": N.$noise, "detail": fromJS(N.ServiceSelector(spec.service)), }), "observer": create assertionFacetObserver(e => { const response = R.toResult(e); if (!response) return; switch (response._variant) { case "Ok": if (isEmbedded(response.value.value)) { runSession(response.value.value); } break; case "Error": stop { at ds { assert Answer(q, response); } } } }), }); } } function runSession(responderSession: Ref) { const H = new SaltyCrypto.Handshake( algorithms, patternName, 'initiator', { prologue, remoteStaticPublicKey: underlying(spec.key), preSharedKeys: preSharedKeys.map(underlying), }); let transportState: SaltyCrypto.TransportState | null = null; let relay: Relay.Relay | null = null; const { packet, finished } = H.writeMessage(new Uint8Array()); at responderSession { assert N.Initiator(create ({ message: handlePacket })); send message Bytes.from(packet); } maybeTransition(finished); function maybeTransition(s: SaltyCrypto.TransportState | null) { if (transportState !== null) { throw new Error("Unexpected double-transition to transport state"); } transportState = s; if (transportState !== null) { actuallyTransition(); } } function actuallyTransition() { const noiseSessionFacet = Turn.activeFacet; const peer = new Relay.Relay({ debug: options.debug, trustPeer: true, packetWriter: bs => noiseSessionFacet.turn(() => { const fragments = transportState!.send.encrypt_large(bs).map(Bytes.from); at responderSession! { send message ((fragments.length === 1) ? N.Packet.complete(fragments[0]) : N.Packet.fragmented(fragments)); } }), setup: r => relay = r, initialOid: 0, }).peer!; assert Answer(q, resultOk(peer)); } function handlePacket(body: Assertion) { const p = N.asPacket(body); if (transportState) { const packet = transportState.recv.decrypt_large( p._variant === 'complete' ? [underlying(p.value)] : p.value.map(underlying)); relay!.accept(packet); } else { if (p._variant !== 'complete') { throw new Error("Unexpected fragmentation in handshake"); } const { message, finished } = H!.readMessage(underlying(p.value)); if (message.byteLength !== 0) { throw new Error("Unexpected payload during handshake"); } maybeTransition(finished); } } } } } } spawn named 'sturdyRefStep' { at ds { during Question($q(G.ResolvePathStep({ origin: $origin: Ref, pathStep: G.PathStep({ stepType: S.$ref, detail: $parameters: S.SturdyPathStepDetail, }), }))) => { at origin { assert G.Resolve({ "step": G.Step({ "stepType": S.$ref, "detail": fromJS(parameters), }), "observer": create assertionFacetObserver(e => { const response = R.toResult(e); if (!response) return; at ds { assert Answer(q, response); } }), }); } } } } } export function unpackStandardRoute(route: E.StandardRoute): G.Route { if (route._variant === 'general') return route.value; const { transports, key, service, sig, oid } = route; const protocol = N.NoiseProtocol.absent(); const preSharedKeys = N.NoisePreSharedKeys.absent(); const caveats = route.caveats.length ? S.CaveatsField.present(route.caveats) : S.CaveatsField.absent(); return G.Route({ transports: transports.map(t => { switch (t._variant) { case 'wsUrl': return T.fromWebSocket(T.WebSocket(t.value)); default: { const x = stringify(E.fromStandardTransport(t)); throw new Error(`Unsupported transport: ${x}`); } } }), pathSteps: [ G.PathStep({ stepType: N.$noise, detail: N.fromNoiseSpec(N.NoiseSpec({ service, key, protocol, preSharedKeys })), }), G.PathStep({ stepType: S.$ref, detail: S.fromParameters(S.Parameters({ oid, sig, caveats })), }), ], }); } // We support a couple of variations here: a string containing Base64-encoded machine-oriented // Preserves, or a string containing text-syntax Preserves. export function decodeStandardRoute(s: string): G.Route | null { try { let routeValue: AnyValue; // A valid text-syntax representation of a StandardRoute must start with // either '[' or '<', neither of which is valid Base64. if (s[0] === '[' || s[0] === '<') { routeValue = parse(s); } else { routeValue = decode(Bytes.fromBase64(s.replace(/[^-_+/A-Za-z0-9=]/g, ''))); } const route = E.toStandardRoute(routeValue); return route === void 0 ? null : unpackStandardRoute(route); } catch (e) { console.error('Decoding standard route:', e); return null; } } export type Routeish = G.Route | Record, Ref>; export function contactRemote( route: Routeish | (() => Routeish), connectedFacet: ( remoteObject: Ref, controlObject: Ref, transportAddr: AnyValue, ) => void, ds = Dataspace.local, ) { const routeFn = typeof route === 'function' ? route : () => route; field routeField: G.Route = null!; dataflow { const r = routeFn(); routeField.value = 'pathSteps' in r ? r : G.asRoute(r); } at ds { assert Question(G.ResolvePath(routeField.value)); during Answer(G.ResolvePath(routeField.value), resultOk(G.ResolvedPath({ "addr": $addr, "control": $control: Ref, "responderSession": $resolved: Ref, }))) => { connectedFacet(resolved, control, addr); } } }