@bifrost-ai/protocol 0.1.1-build.1783029473245
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/README.md +177 -0
- package/dist/index.d.mts +138 -0
- package/dist/index.mjs +299 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# @bifrost-ai/protocol
|
|
2
|
+
|
|
3
|
+
Signed WebSocket RPC transport for runner ↔ orchestrator communication in Bifrost v2.
|
|
4
|
+
|
|
5
|
+
This package is the **only** wire interface between runners and the orchestrator. There is no in-process direct-call variant — a co-located runner still dials the orchestrator over WebSocket.
|
|
6
|
+
|
|
7
|
+
Design background: [docs/protocol.md](../../docs/protocol.md) · Issue [#33](https://github.com/devzeebo/bifrost/issues/33)
|
|
8
|
+
|
|
9
|
+
## Purpose
|
|
10
|
+
|
|
11
|
+
v2 splits task execution onto remote runners. This package provides:
|
|
12
|
+
|
|
13
|
+
1. **ed25519 signing and verification** using Node built-in `crypto`
|
|
14
|
+
2. **Deterministic JSON canonicalization** so signers and verifiers agree on signing material
|
|
15
|
+
3. **WebSocket frame encoding/decoding** with envelope validation
|
|
16
|
+
4. **Peer abstractions** for orchestrator (server) and runner (client) roles
|
|
17
|
+
|
|
18
|
+
Cryptography is intentionally folded into this package rather than living in a separate `crypto` package.
|
|
19
|
+
|
|
20
|
+
## Public API
|
|
21
|
+
|
|
22
|
+
### Key management (`keys.ts`)
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
generateKeyPair(keyId?: string): PeerIdentity
|
|
26
|
+
loadKeyPair({ privateKeyPem, publicKeyPem, keyId }): PeerIdentity
|
|
27
|
+
exportPublicKeyPem(publicKey): string
|
|
28
|
+
exportPrivateKeyPem(privateKey): string
|
|
29
|
+
fingerprintPublicKey(publicKey): string // sha256(SPKI) → base64url, 16 chars
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`PeerIdentity` is `{ keyId, publicKey, privateKey }`. When `keyId` is omitted from `generateKeyPair`, it defaults to the public key fingerprint.
|
|
33
|
+
|
|
34
|
+
### Signing (`sign.ts`)
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
signPayload(payload, identity, timestamp?): SignedEnvelope
|
|
38
|
+
verifyEnvelope(envelope, trustedPublicKeys): boolean
|
|
39
|
+
buildSigningMaterial(unsignedEnvelope): string
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`signPayload` wraps a `FramePayload` in a `SignedEnvelope`. `verifyEnvelope` checks the signature against `trustedPublicKeys.get(envelope.keyId)`. Returns `false` on any mismatch — wrong algorithm, unknown keyId, bad base64, or signature failure.
|
|
43
|
+
|
|
44
|
+
### Canonicalization (`canonicalize.ts`)
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
canonicalize(value: unknown): string // JSON.stringify with sorted object keys
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Recursively sorts object keys before `JSON.stringify`. This ensures that `{ b: 1, a: 2 }` and `{ a: 2, b: 1 }` produce identical signing material. Arrays preserve element order.
|
|
51
|
+
|
|
52
|
+
Signing material is the canonical JSON of:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{ "algorithm": "ed25519", "keyId": "...", "payload": { ... }, "timestamp": 1234567890 }
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Frames (`frames.ts`)
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
encodeEnvelope(envelope: SignedEnvelope): string // JSON.stringify
|
|
62
|
+
decodeEnvelope(raw: string): SignedEnvelope | null
|
|
63
|
+
isFramePayload(value: unknown): value is FramePayload
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`decodeEnvelope` returns `null` for malformed JSON, missing fields, or invalid payload kinds. It does not verify signatures — that happens in the connection layer.
|
|
67
|
+
|
|
68
|
+
### Connection (`connection.ts`)
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
createProtocolConnection(socket, { identity, trustedPublicKeys, onClose? }): ProtocolConnection
|
|
72
|
+
toConnectedPeer(peerId, connection): ConnectedPeer
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`createProtocolConnection` wraps a raw `ws` WebSocket:
|
|
76
|
+
|
|
77
|
+
- **Inbound:** decode envelope → verify signature → validate payload kind → dispatch to subscribers matching their filter.
|
|
78
|
+
- **Outbound:** sign payload with local identity → encode envelope → `socket.send`.
|
|
79
|
+
- Invalid or untrusted frames are silently dropped (no error frame).
|
|
80
|
+
- `onClose` fires once when the socket closes or errors.
|
|
81
|
+
|
|
82
|
+
### Peers
|
|
83
|
+
|
|
84
|
+
**Orchestrator** (`orchestrator.ts`):
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
createOrchestratorPeer({ identity, trustedPublicKeys, host?, port? }): Promise<OrchestratorPeer>
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- Creates a `WebSocketServer`. `port: 0` binds an ephemeral port.
|
|
91
|
+
- Assigns each connection an opaque `peerId` (UUID).
|
|
92
|
+
- `onPeerConnect` fires for new and already-connected peers.
|
|
93
|
+
- `onPeerDisconnect` fires when a peer's socket closes.
|
|
94
|
+
- `send(peerId, payload)` throws if peerId is unknown.
|
|
95
|
+
|
|
96
|
+
**Runner** (`runner.ts`):
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
createRunnerPeer({ identity, trustedPublicKeys, url }): Promise<RunnerPeer>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- Dials the orchestrator URL.
|
|
103
|
+
- Resolves when the WebSocket `open` event fires.
|
|
104
|
+
- Same subscribe/send/close API as `ProtocolConnection`.
|
|
105
|
+
|
|
106
|
+
## Frame payload types
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
type RpcRequest = { kind: "rpc.request"; id: string; method: string; params: unknown };
|
|
110
|
+
type RpcResponse = {
|
|
111
|
+
kind: "rpc.response";
|
|
112
|
+
id: string;
|
|
113
|
+
result?: unknown;
|
|
114
|
+
error?: { code; message };
|
|
115
|
+
};
|
|
116
|
+
type RpcStreamEvent = {
|
|
117
|
+
kind: "rpc.stream";
|
|
118
|
+
id: string;
|
|
119
|
+
seq: number;
|
|
120
|
+
event: "data" | "end" | "error";
|
|
121
|
+
data?;
|
|
122
|
+
error?;
|
|
123
|
+
};
|
|
124
|
+
type Heartbeat = { kind: "heartbeat"; runnerId: string };
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`rpc.stream` supports ordered streaming responses. The orchestrator package does not use streaming today, but the protocol supports it for future methods.
|
|
128
|
+
|
|
129
|
+
## Trust model
|
|
130
|
+
|
|
131
|
+
Both sides maintain a `trustedPublicKeys: Map<keyId, KeyObject>`:
|
|
132
|
+
|
|
133
|
+
- The **orchestrator** trusts runner public keys (loaded from static config).
|
|
134
|
+
- The **runner** trusts the orchestrator's public key.
|
|
135
|
+
|
|
136
|
+
Every inbound frame is verified against this map. There is no handshake or key exchange at connection time — keys must be pre-shared. An unknown `keyId` causes the frame to be dropped.
|
|
137
|
+
|
|
138
|
+
## Wire format example
|
|
139
|
+
|
|
140
|
+
A heartbeat from a runner looks like this on the wire (one WebSocket text frame):
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{
|
|
144
|
+
"payload": { "kind": "heartbeat", "runnerId": "runner" },
|
|
145
|
+
"signature": "base64...",
|
|
146
|
+
"keyId": "runner",
|
|
147
|
+
"algorithm": "ed25519",
|
|
148
|
+
"timestamp": 1719900000000
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Module layout
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
src/
|
|
156
|
+
canonicalize.ts Deterministic JSON serialization
|
|
157
|
+
connection.ts WebSocket ↔ signed frame bridge
|
|
158
|
+
frames.ts Encode/decode/validate envelopes
|
|
159
|
+
keys.ts ed25519 keypair generation and PEM export
|
|
160
|
+
orchestrator.ts WebSocket server peer
|
|
161
|
+
runner.ts WebSocket client peer
|
|
162
|
+
sign.ts Sign and verify envelopes
|
|
163
|
+
types.ts Frame and peer type definitions
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Tests
|
|
167
|
+
|
|
168
|
+
- `sign.spec.ts` — round-trip, tamper, wrong key, non-canonical material
|
|
169
|
+
- `canonicalize.spec.ts` — key ordering stability
|
|
170
|
+
- `loopback.spec.ts` — real WebSocket server+client: RPC round-trip, streaming, disconnect, tampered frame rejection
|
|
171
|
+
|
|
172
|
+
Run with `vp test` from this package directory.
|
|
173
|
+
|
|
174
|
+
## Dependencies
|
|
175
|
+
|
|
176
|
+
- `ws` — WebSocket implementation
|
|
177
|
+
- `node:crypto` — ed25519 key generation, sign, verify (no external crypto libraries)
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { KeyObject } from "node:crypto";
|
|
2
|
+
import { WebSocket as WebSocket$1 } from "ws";
|
|
3
|
+
|
|
4
|
+
//#region src/canonicalize.d.ts
|
|
5
|
+
declare function canonicalize(value: unknown): string;
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/types.d.ts
|
|
8
|
+
type RpcRequest = {
|
|
9
|
+
kind: "rpc.request";
|
|
10
|
+
id: string;
|
|
11
|
+
method: string;
|
|
12
|
+
params: unknown;
|
|
13
|
+
};
|
|
14
|
+
type RpcResponse = {
|
|
15
|
+
kind: "rpc.response";
|
|
16
|
+
id: string;
|
|
17
|
+
result?: unknown;
|
|
18
|
+
error?: {
|
|
19
|
+
code: string;
|
|
20
|
+
message: string;
|
|
21
|
+
};
|
|
22
|
+
};
|
|
23
|
+
type RpcStreamEvent = {
|
|
24
|
+
kind: "rpc.stream";
|
|
25
|
+
id: string;
|
|
26
|
+
seq: number;
|
|
27
|
+
event: "data" | "end" | "error";
|
|
28
|
+
data?: unknown;
|
|
29
|
+
error?: {
|
|
30
|
+
code: string;
|
|
31
|
+
message: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
type Heartbeat = {
|
|
35
|
+
kind: "heartbeat";
|
|
36
|
+
runnerId: string;
|
|
37
|
+
};
|
|
38
|
+
type FramePayload = RpcRequest | RpcResponse | RpcStreamEvent | Heartbeat;
|
|
39
|
+
declare const SIGNING_ALGORITHM: "ed25519";
|
|
40
|
+
type SignedEnvelope<T = FramePayload> = {
|
|
41
|
+
payload: T;
|
|
42
|
+
signature: string;
|
|
43
|
+
keyId: string;
|
|
44
|
+
algorithm: typeof SIGNING_ALGORITHM;
|
|
45
|
+
timestamp: number;
|
|
46
|
+
};
|
|
47
|
+
type UnsignedEnvelope<T = FramePayload> = {
|
|
48
|
+
payload: T;
|
|
49
|
+
keyId: string;
|
|
50
|
+
algorithm: typeof SIGNING_ALGORITHM;
|
|
51
|
+
timestamp: number;
|
|
52
|
+
};
|
|
53
|
+
type RunnerPeer = {
|
|
54
|
+
subscribe(filter: (payload: FramePayload) => boolean, callback: (payload: FramePayload) => void): () => void;
|
|
55
|
+
send(payload: FramePayload): void;
|
|
56
|
+
close(): void;
|
|
57
|
+
};
|
|
58
|
+
type ConnectedPeer = {
|
|
59
|
+
readonly peerId: string;
|
|
60
|
+
subscribe(filter: (payload: FramePayload) => boolean, callback: (payload: FramePayload) => void): () => void;
|
|
61
|
+
send(payload: FramePayload): void;
|
|
62
|
+
close(): void;
|
|
63
|
+
};
|
|
64
|
+
type OrchestratorPeer = {
|
|
65
|
+
readonly address: {
|
|
66
|
+
host: string;
|
|
67
|
+
port: number;
|
|
68
|
+
};
|
|
69
|
+
onPeerConnect(callback: (peer: ConnectedPeer) => void): () => void;
|
|
70
|
+
onPeerDisconnect(callback: (peer: ConnectedPeer) => void): () => void;
|
|
71
|
+
send(peerId: string, payload: FramePayload): void;
|
|
72
|
+
close(): void;
|
|
73
|
+
};
|
|
74
|
+
type PeerIdentity = {
|
|
75
|
+
keyId: string;
|
|
76
|
+
publicKey: import("node:crypto").KeyObject;
|
|
77
|
+
privateKey: import("node:crypto").KeyObject;
|
|
78
|
+
};
|
|
79
|
+
type PeerOptions = {
|
|
80
|
+
identity: PeerIdentity;
|
|
81
|
+
trustedPublicKeys: ReadonlyMap<string, import("node:crypto").KeyObject>;
|
|
82
|
+
};
|
|
83
|
+
type CreateRunnerPeerOptions = PeerOptions & {
|
|
84
|
+
url: string;
|
|
85
|
+
};
|
|
86
|
+
type CreateOrchestratorPeerOptions = PeerOptions & {
|
|
87
|
+
port?: number;
|
|
88
|
+
host?: string;
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/connection.d.ts
|
|
92
|
+
type ProtocolConnectionOptions = {
|
|
93
|
+
identity: PeerIdentity;
|
|
94
|
+
trustedPublicKeys: ReadonlyMap<string, KeyObject>;
|
|
95
|
+
onClose?: () => void;
|
|
96
|
+
};
|
|
97
|
+
type ProtocolConnection = {
|
|
98
|
+
subscribe(filter: (payload: FramePayload) => boolean, callback: (payload: FramePayload) => void): () => void;
|
|
99
|
+
send(payload: FramePayload): void;
|
|
100
|
+
close(): void;
|
|
101
|
+
};
|
|
102
|
+
declare function createProtocolConnection(socket: WebSocket$1, options: ProtocolConnectionOptions): ProtocolConnection;
|
|
103
|
+
declare function toConnectedPeer(peerId: string, connection: ProtocolConnection): ConnectedPeer;
|
|
104
|
+
//#endregion
|
|
105
|
+
//#region src/frames.d.ts
|
|
106
|
+
declare function encodeEnvelope(envelope: SignedEnvelope): string;
|
|
107
|
+
declare function decodeEnvelope(raw: string): SignedEnvelope | null;
|
|
108
|
+
declare function isFramePayload(value: unknown): value is FramePayload;
|
|
109
|
+
//#endregion
|
|
110
|
+
//#region src/keys.d.ts
|
|
111
|
+
type LoadKeyPairOptions = {
|
|
112
|
+
privateKeyPem: string;
|
|
113
|
+
publicKeyPem: string;
|
|
114
|
+
keyId: string;
|
|
115
|
+
};
|
|
116
|
+
declare function fingerprintPublicKey(publicKey: KeyObject): string;
|
|
117
|
+
declare function generateKeyPair(keyId?: string): PeerIdentity;
|
|
118
|
+
declare function loadKeyPair(options: LoadKeyPairOptions): PeerIdentity;
|
|
119
|
+
declare function loadTrustedPublicKey(options: {
|
|
120
|
+
keyId: string;
|
|
121
|
+
publicKeyPem: string;
|
|
122
|
+
}): ReadonlyMap<string, KeyObject>;
|
|
123
|
+
declare function exportPublicKeyPem(publicKey: KeyObject): string;
|
|
124
|
+
declare function exportPrivateKeyPem(privateKey: KeyObject): string;
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/orchestrator.d.ts
|
|
127
|
+
declare function createOrchestratorPeer(options: CreateOrchestratorPeerOptions): Promise<OrchestratorPeer>;
|
|
128
|
+
//#endregion
|
|
129
|
+
//#region src/runner.d.ts
|
|
130
|
+
declare function createRunnerPeer(options: CreateRunnerPeerOptions): Promise<RunnerPeer>;
|
|
131
|
+
//#endregion
|
|
132
|
+
//#region src/sign.d.ts
|
|
133
|
+
declare function buildSigningMaterial(envelope: UnsignedEnvelope): string;
|
|
134
|
+
declare function signPayload(payload: FramePayload, identity: PeerIdentity, timestamp?: number): SignedEnvelope;
|
|
135
|
+
declare function verifyEnvelope(envelope: SignedEnvelope, trustedPublicKeys: ReadonlyMap<string, KeyObject>): boolean;
|
|
136
|
+
declare function signRawMaterial(material: string, identity: PeerIdentity): string;
|
|
137
|
+
//#endregion
|
|
138
|
+
export { type ConnectedPeer, type CreateOrchestratorPeerOptions, type CreateRunnerPeerOptions, type FramePayload, type Heartbeat, type LoadKeyPairOptions, type OrchestratorPeer, type PeerIdentity, type PeerOptions, type ProtocolConnection, type ProtocolConnectionOptions, type RpcRequest, type RpcResponse, type RpcStreamEvent, type RunnerPeer, SIGNING_ALGORITHM, type SignedEnvelope, type UnsignedEnvelope, buildSigningMaterial, canonicalize, createOrchestratorPeer, createProtocolConnection, createRunnerPeer, decodeEnvelope, encodeEnvelope, exportPrivateKeyPem, exportPublicKeyPem, fingerprintPublicKey, generateKeyPair, isFramePayload, loadKeyPair, loadTrustedPublicKey, signPayload, signRawMaterial, toConnectedPeer, verifyEnvelope };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomUUID, sign, verify } from "node:crypto";
|
|
2
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
3
|
+
//#region src/canonicalize.ts
|
|
4
|
+
function canonicalize(value) {
|
|
5
|
+
return JSON.stringify(sortKeys(value));
|
|
6
|
+
}
|
|
7
|
+
function sortKeys(value) {
|
|
8
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
9
|
+
if (value !== null && typeof value === "object") {
|
|
10
|
+
const record = value;
|
|
11
|
+
const sorted = {};
|
|
12
|
+
for (const key of Object.keys(record).sort()) sorted[key] = sortKeys(record[key]);
|
|
13
|
+
return sorted;
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/frames.ts
|
|
19
|
+
function encodeEnvelope(envelope) {
|
|
20
|
+
return JSON.stringify(envelope);
|
|
21
|
+
}
|
|
22
|
+
function decodeEnvelope(raw) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = JSON.parse(raw);
|
|
25
|
+
if (!isSignedEnvelope(parsed)) return null;
|
|
26
|
+
return parsed;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function isFramePayload(value) {
|
|
32
|
+
if (value === null || typeof value !== "object" || !("kind" in value)) return false;
|
|
33
|
+
const record = value;
|
|
34
|
+
switch (record.kind) {
|
|
35
|
+
case "rpc.request": return typeof record.id === "string" && typeof record.method === "string" && "params" in record;
|
|
36
|
+
case "rpc.response": return typeof record.id === "string";
|
|
37
|
+
case "rpc.stream": return typeof record.id === "string" && typeof record.seq === "number" && (record.event === "data" || record.event === "end" || record.event === "error");
|
|
38
|
+
case "heartbeat": return typeof record.runnerId === "string";
|
|
39
|
+
default: return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isSignedEnvelope(value) {
|
|
43
|
+
if (value === null || typeof value !== "object") return false;
|
|
44
|
+
const envelope = value;
|
|
45
|
+
return typeof envelope.signature === "string" && typeof envelope.keyId === "string" && envelope.algorithm === "ed25519" && typeof envelope.timestamp === "number" && envelope.payload !== void 0 && isFramePayload(envelope.payload);
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region src/types.ts
|
|
49
|
+
const SIGNING_ALGORITHM = "ed25519";
|
|
50
|
+
//#endregion
|
|
51
|
+
//#region src/sign.ts
|
|
52
|
+
function buildSigningMaterial(envelope) {
|
|
53
|
+
return canonicalize({
|
|
54
|
+
algorithm: envelope.algorithm,
|
|
55
|
+
keyId: envelope.keyId,
|
|
56
|
+
payload: envelope.payload,
|
|
57
|
+
timestamp: envelope.timestamp
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
function signPayload(payload, identity, timestamp = Date.now()) {
|
|
61
|
+
const unsigned = {
|
|
62
|
+
algorithm: SIGNING_ALGORITHM,
|
|
63
|
+
keyId: identity.keyId,
|
|
64
|
+
payload,
|
|
65
|
+
timestamp
|
|
66
|
+
};
|
|
67
|
+
const material = buildSigningMaterial(unsigned);
|
|
68
|
+
const signature = sign(null, Buffer.from(material, "utf8"), identity.privateKey);
|
|
69
|
+
return {
|
|
70
|
+
...unsigned,
|
|
71
|
+
signature: signature.toString("base64")
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function verifyEnvelope(envelope, trustedPublicKeys) {
|
|
75
|
+
if (envelope.algorithm !== "ed25519") return false;
|
|
76
|
+
const publicKey = trustedPublicKeys.get(envelope.keyId);
|
|
77
|
+
if (publicKey === void 0) return false;
|
|
78
|
+
const material = buildSigningMaterial({
|
|
79
|
+
algorithm: envelope.algorithm,
|
|
80
|
+
keyId: envelope.keyId,
|
|
81
|
+
payload: envelope.payload,
|
|
82
|
+
timestamp: envelope.timestamp
|
|
83
|
+
});
|
|
84
|
+
let signature;
|
|
85
|
+
try {
|
|
86
|
+
signature = Buffer.from(envelope.signature, "base64");
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return verify(null, Buffer.from(material, "utf8"), publicKey, signature);
|
|
91
|
+
}
|
|
92
|
+
function signRawMaterial(material, identity) {
|
|
93
|
+
return sign(null, Buffer.from(material, "utf8"), identity.privateKey).toString("base64");
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/connection.ts
|
|
97
|
+
function createProtocolConnection(socket, options) {
|
|
98
|
+
const subscribers = /* @__PURE__ */ new Set();
|
|
99
|
+
let closed = false;
|
|
100
|
+
const handleMessage = (data) => {
|
|
101
|
+
if (closed) return;
|
|
102
|
+
const envelope = decodeEnvelope(Buffer.isBuffer(data) ? data.toString("utf8") : Buffer.from(data).toString("utf8"));
|
|
103
|
+
if (envelope === null) return;
|
|
104
|
+
if (!verifyEnvelope(envelope, options.trustedPublicKeys)) return;
|
|
105
|
+
if (!isFramePayload(envelope.payload)) return;
|
|
106
|
+
const payload = envelope.payload;
|
|
107
|
+
for (const subscriber of subscribers) if (subscriber.filter(payload)) subscriber.callback(payload);
|
|
108
|
+
};
|
|
109
|
+
const handleClose = () => {
|
|
110
|
+
if (closed) return;
|
|
111
|
+
closed = true;
|
|
112
|
+
socket.removeListener("message", handleMessage);
|
|
113
|
+
socket.removeListener("close", handleClose);
|
|
114
|
+
subscribers.clear();
|
|
115
|
+
options.onClose?.();
|
|
116
|
+
};
|
|
117
|
+
socket.on("message", handleMessage);
|
|
118
|
+
socket.on("close", handleClose);
|
|
119
|
+
socket.on("error", () => {
|
|
120
|
+
handleClose();
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
subscribe(filter, callback) {
|
|
124
|
+
const subscriber = {
|
|
125
|
+
filter,
|
|
126
|
+
callback
|
|
127
|
+
};
|
|
128
|
+
subscribers.add(subscriber);
|
|
129
|
+
return () => {
|
|
130
|
+
subscribers.delete(subscriber);
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
send(payload) {
|
|
134
|
+
if (closed || socket.readyState !== socket.OPEN) return;
|
|
135
|
+
const envelope = signPayload(payload, options.identity);
|
|
136
|
+
socket.send(encodeEnvelope(envelope));
|
|
137
|
+
},
|
|
138
|
+
close() {
|
|
139
|
+
if (closed) return;
|
|
140
|
+
closed = true;
|
|
141
|
+
socket.close();
|
|
142
|
+
handleClose();
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function toConnectedPeer(peerId, connection) {
|
|
147
|
+
return {
|
|
148
|
+
peerId,
|
|
149
|
+
subscribe: connection.subscribe.bind(connection),
|
|
150
|
+
send: connection.send.bind(connection),
|
|
151
|
+
close: connection.close.bind(connection)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/keys.ts
|
|
156
|
+
function fingerprintPublicKey(publicKey) {
|
|
157
|
+
const der = publicKey.export({
|
|
158
|
+
format: "der",
|
|
159
|
+
type: "spki"
|
|
160
|
+
});
|
|
161
|
+
return createHash("sha256").update(der).digest("base64url").slice(0, 16);
|
|
162
|
+
}
|
|
163
|
+
function generateKeyPair(keyId) {
|
|
164
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
165
|
+
return {
|
|
166
|
+
keyId: keyId ?? fingerprintPublicKey(publicKey),
|
|
167
|
+
publicKey,
|
|
168
|
+
privateKey
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function loadKeyPair(options) {
|
|
172
|
+
return {
|
|
173
|
+
keyId: options.keyId,
|
|
174
|
+
publicKey: createPublicKey(options.publicKeyPem),
|
|
175
|
+
privateKey: createPrivateKey(options.privateKeyPem)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function loadTrustedPublicKey(options) {
|
|
179
|
+
return new Map([[options.keyId, createPublicKey(options.publicKeyPem)]]);
|
|
180
|
+
}
|
|
181
|
+
function exportPublicKeyPem(publicKey) {
|
|
182
|
+
return publicKey.export({
|
|
183
|
+
format: "pem",
|
|
184
|
+
type: "spki"
|
|
185
|
+
}).toString();
|
|
186
|
+
}
|
|
187
|
+
function exportPrivateKeyPem(privateKey) {
|
|
188
|
+
return privateKey.export({
|
|
189
|
+
format: "pem",
|
|
190
|
+
type: "pkcs8"
|
|
191
|
+
}).toString();
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/orchestrator.ts
|
|
195
|
+
function createOrchestratorPeer(options) {
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
const connectCallbacks = /* @__PURE__ */ new Set();
|
|
198
|
+
const disconnectCallbacks = /* @__PURE__ */ new Set();
|
|
199
|
+
const peers = /* @__PURE__ */ new Map();
|
|
200
|
+
let closed = false;
|
|
201
|
+
const wss = new WebSocketServer({
|
|
202
|
+
host: options.host ?? "127.0.0.1",
|
|
203
|
+
port: options.port ?? 0
|
|
204
|
+
});
|
|
205
|
+
wss.on("error", (error) => {
|
|
206
|
+
if (!closed) reject(error);
|
|
207
|
+
});
|
|
208
|
+
wss.on("listening", () => {
|
|
209
|
+
const address = wss.address();
|
|
210
|
+
if (address === null || typeof address === "string") {
|
|
211
|
+
reject(/* @__PURE__ */ new Error("WebSocket server address is unavailable"));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
resolve({
|
|
215
|
+
address: {
|
|
216
|
+
host: address.address === "::" || address.address === "0.0.0.0" ? "127.0.0.1" : address.address,
|
|
217
|
+
port: address.port
|
|
218
|
+
},
|
|
219
|
+
onPeerConnect(callback) {
|
|
220
|
+
connectCallbacks.add(callback);
|
|
221
|
+
for (const entry of peers.values()) callback(entry.peer);
|
|
222
|
+
return () => {
|
|
223
|
+
connectCallbacks.delete(callback);
|
|
224
|
+
};
|
|
225
|
+
},
|
|
226
|
+
onPeerDisconnect(callback) {
|
|
227
|
+
disconnectCallbacks.add(callback);
|
|
228
|
+
return () => {
|
|
229
|
+
disconnectCallbacks.delete(callback);
|
|
230
|
+
};
|
|
231
|
+
},
|
|
232
|
+
send(peerId, payload) {
|
|
233
|
+
const entry = peers.get(peerId);
|
|
234
|
+
if (entry === void 0) throw new Error(`Unknown peer id: ${peerId}`);
|
|
235
|
+
entry.connection.send(payload);
|
|
236
|
+
},
|
|
237
|
+
close() {
|
|
238
|
+
if (closed) return;
|
|
239
|
+
closed = true;
|
|
240
|
+
for (const entry of peers.values()) entry.connection.close();
|
|
241
|
+
peers.clear();
|
|
242
|
+
wss.close();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
wss.on("connection", (socket) => {
|
|
247
|
+
if (closed) {
|
|
248
|
+
socket.close();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const peerId = randomUUID();
|
|
252
|
+
const connection = createProtocolConnection(socket, {
|
|
253
|
+
identity: options.identity,
|
|
254
|
+
trustedPublicKeys: options.trustedPublicKeys,
|
|
255
|
+
onClose: () => {
|
|
256
|
+
const entry = peers.get(peerId);
|
|
257
|
+
if (entry === void 0) return;
|
|
258
|
+
peers.delete(peerId);
|
|
259
|
+
for (const callback of disconnectCallbacks) callback(entry.peer);
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
const peer = toConnectedPeer(peerId, connection);
|
|
263
|
+
peers.set(peerId, {
|
|
264
|
+
peer,
|
|
265
|
+
connection
|
|
266
|
+
});
|
|
267
|
+
for (const callback of connectCallbacks) callback(peer);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/runner.ts
|
|
273
|
+
function createRunnerPeer(options) {
|
|
274
|
+
return new Promise((resolve, reject) => {
|
|
275
|
+
const socket = new WebSocket(options.url);
|
|
276
|
+
const fail = (error) => {
|
|
277
|
+
socket.removeAllListeners();
|
|
278
|
+
reject(error);
|
|
279
|
+
};
|
|
280
|
+
socket.once("error", fail);
|
|
281
|
+
socket.once("open", () => {
|
|
282
|
+
socket.removeListener("error", fail);
|
|
283
|
+
socket.on("error", () => {
|
|
284
|
+
connection.close();
|
|
285
|
+
});
|
|
286
|
+
const connection = createProtocolConnection(socket, {
|
|
287
|
+
identity: options.identity,
|
|
288
|
+
trustedPublicKeys: options.trustedPublicKeys
|
|
289
|
+
});
|
|
290
|
+
resolve({
|
|
291
|
+
subscribe: connection.subscribe.bind(connection),
|
|
292
|
+
send: connection.send.bind(connection),
|
|
293
|
+
close: connection.close.bind(connection)
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
//#endregion
|
|
299
|
+
export { SIGNING_ALGORITHM, buildSigningMaterial, canonicalize, createOrchestratorPeer, createProtocolConnection, createRunnerPeer, decodeEnvelope, encodeEnvelope, exportPrivateKeyPem, exportPublicKeyPem, fingerprintPublicKey, generateKeyPair, isFramePayload, loadKeyPair, loadTrustedPublicKey, signPayload, signRawMaterial, toConnectedPeer, verifyEnvelope };
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bifrost-ai/protocol",
|
|
3
|
+
"version": "0.1.1-build.1783029473245",
|
|
4
|
+
"description": "Signed WebSocket RPC protocol for runner-orchestrator communication",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
9
|
+
"type": "module",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./dist/index.mjs",
|
|
12
|
+
"./package.json": "./package.json"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"ws": "^8.18.3"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^25.6.2",
|
|
22
|
+
"@types/ws": "^8.18.1",
|
|
23
|
+
"@typescript/native-preview": "latest",
|
|
24
|
+
"bumpp": "^11.1.0",
|
|
25
|
+
"typescript": "^6.0.3",
|
|
26
|
+
"vite-plus": "^0.2.0",
|
|
27
|
+
"vitest-gwt": "^4.1.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "vp pack",
|
|
31
|
+
"dev": "vp pack --watch",
|
|
32
|
+
"test": "vp test",
|
|
33
|
+
"check": "vp check"
|
|
34
|
+
}
|
|
35
|
+
}
|