@pellux/goodvibes-transport-core 1.6.1 → 1.7.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/dist/relay/crypto.d.ts +49 -0
- package/dist/relay/crypto.d.ts.map +1 -0
- package/dist/relay/crypto.js +215 -0
- package/dist/relay/handshake.d.ts +43 -0
- package/dist/relay/handshake.d.ts.map +1 -0
- package/dist/relay/handshake.js +100 -0
- package/dist/relay/identity.d.ts +19 -0
- package/dist/relay/identity.d.ts.map +1 -0
- package/dist/relay/identity.js +54 -0
- package/dist/relay/index.d.ts +8 -0
- package/dist/relay/index.d.ts.map +1 -0
- package/dist/relay/index.js +14 -0
- package/dist/relay/pairing.d.ts +28 -0
- package/dist/relay/pairing.d.ts.map +1 -0
- package/dist/relay/pairing.js +75 -0
- package/dist/relay/protocol.d.ts +79 -0
- package/dist/relay/protocol.d.ts.map +1 -0
- package/dist/relay/protocol.js +109 -0
- package/dist/relay/secure-channel.d.ts +21 -0
- package/dist/relay/secure-channel.d.ts.map +1 -0
- package/dist/relay/secure-channel.js +97 -0
- package/dist/relay/tunnel.d.ts +54 -0
- package/dist/relay/tunnel.d.ts.map +1 -0
- package/dist/relay/tunnel.js +69 -0
- package/package.json +6 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Named curve used for all relay key agreement. */
|
|
2
|
+
export declare const RELAY_CURVE: "P-256";
|
|
3
|
+
/** Raw (uncompressed) P-256 public key length in bytes. */
|
|
4
|
+
export declare const RELAY_PUBLIC_KEY_BYTES = 65;
|
|
5
|
+
/** AEAD nonce length in bytes (AES-GCM standard 96-bit nonce). */
|
|
6
|
+
export declare const RELAY_NONCE_BYTES = 12;
|
|
7
|
+
/** Symmetric key length in bytes (AES-256). */
|
|
8
|
+
export declare const RELAY_KEY_BYTES = 32;
|
|
9
|
+
/** Fill `n` bytes from the platform CSPRNG. */
|
|
10
|
+
export declare function randomBytes(n: number): Uint8Array<ArrayBuffer>;
|
|
11
|
+
/** Encode bytes to unpadded base64url. */
|
|
12
|
+
export declare function toBase64Url(bytes: Uint8Array<ArrayBuffer>): string;
|
|
13
|
+
/** Decode unpadded (or padded) base64url to bytes. Throws on invalid input. */
|
|
14
|
+
export declare function fromBase64Url(text: string): Uint8Array<ArrayBuffer>;
|
|
15
|
+
/** Constant-time equality for two byte arrays (length is not secret). */
|
|
16
|
+
export declare function bytesEqual(a: Uint8Array<ArrayBuffer>, b: Uint8Array<ArrayBuffer>): boolean;
|
|
17
|
+
/** Concatenate byte arrays into one. */
|
|
18
|
+
export declare function concatBytes(...parts: readonly Uint8Array<ArrayBuffer>[]): Uint8Array<ArrayBuffer>;
|
|
19
|
+
/** A relay identity/ephemeral key pair. Private key is non-extractable. */
|
|
20
|
+
export interface RelayKeyPair {
|
|
21
|
+
readonly publicKey: CryptoKey;
|
|
22
|
+
readonly privateKey: CryptoKey;
|
|
23
|
+
}
|
|
24
|
+
/** Generate a fresh ECDH P-256 key pair for key agreement. */
|
|
25
|
+
export declare function generateEcdhKeyPair(): Promise<RelayKeyPair>;
|
|
26
|
+
/** Export a public key to its 65-byte raw uncompressed form. */
|
|
27
|
+
export declare function exportRawPublicKey(key: CryptoKey): Promise<Uint8Array<ArrayBuffer>>;
|
|
28
|
+
/** Import a 65-byte raw uncompressed P-256 public key. */
|
|
29
|
+
export declare function importRawPublicKey(bytes: Uint8Array<ArrayBuffer>): Promise<CryptoKey>;
|
|
30
|
+
/** Compute the raw ECDH shared secret (32-byte x-coordinate) between our private key and their public key. */
|
|
31
|
+
export declare function deriveSharedSecret(privateKey: CryptoKey, publicKey: CryptoKey): Promise<Uint8Array<ArrayBuffer>>;
|
|
32
|
+
/** SHA-256 digest. */
|
|
33
|
+
export declare function sha256(data: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>>;
|
|
34
|
+
/**
|
|
35
|
+
* HKDF-SHA-256 (extract + expand) producing `length` bytes of output key
|
|
36
|
+
* material. `salt` and `info` bind the derivation to the handshake transcript.
|
|
37
|
+
*/
|
|
38
|
+
export declare function hkdf(ikm: Uint8Array<ArrayBuffer>, salt: Uint8Array<ArrayBuffer>, info: Uint8Array<ArrayBuffer>, length: number): Promise<Uint8Array<ArrayBuffer>>;
|
|
39
|
+
/** Import a 32-byte raw key as an AES-256-GCM key. */
|
|
40
|
+
export declare function importAeadKey(rawKey: Uint8Array<ArrayBuffer>): Promise<CryptoKey>;
|
|
41
|
+
/** AES-256-GCM seal. Returns ciphertext with the GCM tag appended. */
|
|
42
|
+
export declare function aeadSeal(key: CryptoKey, nonce: Uint8Array<ArrayBuffer>, plaintext: Uint8Array<ArrayBuffer>, aad: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>>;
|
|
43
|
+
/** AES-256-GCM open. Throws (auth failure) if the ciphertext/tag/AAD do not verify. */
|
|
44
|
+
export declare function aeadOpen(key: CryptoKey, nonce: Uint8Array<ArrayBuffer>, ciphertext: Uint8Array<ArrayBuffer>, aad: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>>;
|
|
45
|
+
/** UTF-8 encode a string to bytes. */
|
|
46
|
+
export declare function encodeUtf8(text: string): Uint8Array<ArrayBuffer>;
|
|
47
|
+
/** UTF-8 decode bytes to a string. */
|
|
48
|
+
export declare function decodeUtf8(bytes: Uint8Array<ArrayBuffer>): string;
|
|
49
|
+
//# sourceMappingURL=crypto.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../../src/relay/crypto.ts"],"names":[],"mappings":"AAiBA,oDAAoD;AACpD,eAAO,MAAM,WAAW,EAAG,OAAgB,CAAC;AAC5C,2DAA2D;AAC3D,eAAO,MAAM,sBAAsB,KAAK,CAAC;AACzC,kEAAkE;AAClE,eAAO,MAAM,iBAAiB,KAAK,CAAC;AACpC,+CAA+C;AAC/C,eAAO,MAAM,eAAe,KAAK,CAAC;AAelC,+CAA+C;AAC/C,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC,WAAW,CAAC,CAa9D;AAWD,0CAA0C;AAC1C,wBAAgB,WAAW,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,MAAM,CAgBlE;AAED,+EAA+E;AAC/E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC,WAAW,CAAC,CAiCnE;AAED,yEAAyE;AACzE,wBAAgB,UAAU,CAAC,CAAC,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,CAAC,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,OAAO,CAK1F;AAED,wCAAwC;AACxC,wBAAgB,WAAW,CAAC,GAAG,KAAK,EAAE,SAAS,UAAU,CAAC,WAAW,CAAC,EAAE,GAAG,UAAU,CAAC,WAAW,CAAC,CAUjG;AAID,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;CAChC;AAED,8DAA8D;AAC9D,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,YAAY,CAAC,CAGjE;AAED,gEAAgE;AAChE,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAGzF;AAED,0DAA0D;AAC1D,wBAAsB,kBAAkB,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,SAAS,CAAC,CAS3F;AAED,8GAA8G;AAC9G,wBAAsB,kBAAkB,CAAC,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAGtH;AAID,sBAAsB;AACtB,wBAAsB,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAG5F;AAED;;;GAGG;AACH,wBAAsB,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAIvK;AAID,sDAAsD;AACtD,wBAAsB,aAAa,CAAC,MAAM,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,SAAS,CAAC,CAKvF;AAED,sEAAsE;AACtE,wBAAsB,QAAQ,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,SAAS,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,GAAG,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAGjL;AAED,uFAAuF;AACvF,wBAAsB,QAAQ,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,UAAU,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,GAAG,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAYlL;AAKD,sCAAsC;AACtC,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAAC,WAAW,CAAC,CAEhE;AAED,sCAAsC;AACtC,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,MAAM,CAEjE"}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// relay/crypto.ts
|
|
2
|
+
//
|
|
3
|
+
// Runtime-neutral cryptographic primitives for the zero-knowledge relay
|
|
4
|
+
// end-to-end (E2E) channel. Every primitive here is a thin, honest wrapper over
|
|
5
|
+
// the Web Crypto API (`globalThis.crypto.subtle`) — the same interface exposed
|
|
6
|
+
// by browsers, Bun, and Node 22+. Nothing is hand-rolled: key agreement is
|
|
7
|
+
// ECDH over the NIST P-256 curve, key derivation is HKDF-SHA-256, and the AEAD
|
|
8
|
+
// is AES-256-GCM. P-256 is chosen over X25519 because it is universally
|
|
9
|
+
// available in Web Crypto across every surface that must terminate this channel
|
|
10
|
+
// (browser PWA, Bun daemon, Node), and it matches the curve the codebase
|
|
11
|
+
// already relies on for push-message encryption.
|
|
12
|
+
//
|
|
13
|
+
// This module intentionally holds ZERO relay-protocol knowledge — it only knows
|
|
14
|
+
// bytes and keys. Higher layers (handshake.ts, secure-channel.ts) compose these.
|
|
15
|
+
import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
|
|
16
|
+
/** Named curve used for all relay key agreement. */
|
|
17
|
+
export const RELAY_CURVE = 'P-256';
|
|
18
|
+
/** Raw (uncompressed) P-256 public key length in bytes. */
|
|
19
|
+
export const RELAY_PUBLIC_KEY_BYTES = 65;
|
|
20
|
+
/** AEAD nonce length in bytes (AES-GCM standard 96-bit nonce). */
|
|
21
|
+
export const RELAY_NONCE_BYTES = 12;
|
|
22
|
+
/** Symmetric key length in bytes (AES-256). */
|
|
23
|
+
export const RELAY_KEY_BYTES = 32;
|
|
24
|
+
function subtle() {
|
|
25
|
+
const c = globalThis.crypto;
|
|
26
|
+
if (!c || typeof c.subtle === 'undefined') {
|
|
27
|
+
throw new GoodVibesSdkError('Web Crypto (crypto.subtle) is unavailable in this runtime.', {
|
|
28
|
+
category: 'config',
|
|
29
|
+
source: 'transport',
|
|
30
|
+
recoverable: false,
|
|
31
|
+
hint: 'Run the relay in a runtime that provides the Web Crypto API (browser, Bun, or Node 22+).',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return c.subtle;
|
|
35
|
+
}
|
|
36
|
+
/** Fill `n` bytes from the platform CSPRNG. */
|
|
37
|
+
export function randomBytes(n) {
|
|
38
|
+
const out = new Uint8Array(n);
|
|
39
|
+
const c = globalThis.crypto;
|
|
40
|
+
if (!c || typeof c.getRandomValues !== 'function') {
|
|
41
|
+
throw new GoodVibesSdkError('Secure random generation is unavailable in this runtime.', {
|
|
42
|
+
category: 'config',
|
|
43
|
+
source: 'transport',
|
|
44
|
+
recoverable: false,
|
|
45
|
+
hint: 'Run the relay in a runtime that provides crypto.getRandomValues().',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
c.getRandomValues(out);
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
// ─── base64url (no padding), binary-safe, no Buffer/atob dependency ───────────
|
|
52
|
+
const B64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
|
53
|
+
const B64URL_LOOKUP = (() => {
|
|
54
|
+
const table = new Array(128).fill(-1);
|
|
55
|
+
for (let i = 0; i < B64URL_ALPHABET.length; i += 1)
|
|
56
|
+
table[B64URL_ALPHABET.charCodeAt(i)] = i;
|
|
57
|
+
return table;
|
|
58
|
+
})();
|
|
59
|
+
/** Encode bytes to unpadded base64url. */
|
|
60
|
+
export function toBase64Url(bytes) {
|
|
61
|
+
let out = '';
|
|
62
|
+
let i = 0;
|
|
63
|
+
for (; i + 2 < bytes.length; i += 3) {
|
|
64
|
+
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
|
|
65
|
+
out += B64URL_ALPHABET[(n >> 18) & 63] + B64URL_ALPHABET[(n >> 12) & 63] + B64URL_ALPHABET[(n >> 6) & 63] + B64URL_ALPHABET[n & 63];
|
|
66
|
+
}
|
|
67
|
+
const rem = bytes.length - i;
|
|
68
|
+
if (rem === 1) {
|
|
69
|
+
const n = bytes[i] << 16;
|
|
70
|
+
out += B64URL_ALPHABET[(n >> 18) & 63] + B64URL_ALPHABET[(n >> 12) & 63];
|
|
71
|
+
}
|
|
72
|
+
else if (rem === 2) {
|
|
73
|
+
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
|
|
74
|
+
out += B64URL_ALPHABET[(n >> 18) & 63] + B64URL_ALPHABET[(n >> 12) & 63] + B64URL_ALPHABET[(n >> 6) & 63];
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** Decode unpadded (or padded) base64url to bytes. Throws on invalid input. */
|
|
79
|
+
export function fromBase64Url(text) {
|
|
80
|
+
const clean = text.replace(/=+$/, '');
|
|
81
|
+
const len = clean.length;
|
|
82
|
+
const fullGroups = Math.floor(len / 4);
|
|
83
|
+
const remainder = len - fullGroups * 4;
|
|
84
|
+
if (remainder === 1) {
|
|
85
|
+
throw new GoodVibesSdkError('Invalid base64url string length.', { category: 'bad_request', source: 'transport', recoverable: false });
|
|
86
|
+
}
|
|
87
|
+
const outLen = fullGroups * 3 + (remainder === 2 ? 1 : remainder === 3 ? 2 : 0);
|
|
88
|
+
const out = new Uint8Array(outLen);
|
|
89
|
+
let o = 0;
|
|
90
|
+
let i = 0;
|
|
91
|
+
const at = (index) => {
|
|
92
|
+
const code = clean.charCodeAt(index);
|
|
93
|
+
const v = code < 128 ? B64URL_LOOKUP[code] : -1;
|
|
94
|
+
if (v < 0)
|
|
95
|
+
throw new GoodVibesSdkError('Invalid base64url character.', { category: 'bad_request', source: 'transport', recoverable: false });
|
|
96
|
+
return v;
|
|
97
|
+
};
|
|
98
|
+
for (let g = 0; g < fullGroups; g += 1, i += 4) {
|
|
99
|
+
const n = (at(i) << 18) | (at(i + 1) << 12) | (at(i + 2) << 6) | at(i + 3);
|
|
100
|
+
out[o++] = (n >> 16) & 0xff;
|
|
101
|
+
out[o++] = (n >> 8) & 0xff;
|
|
102
|
+
out[o++] = n & 0xff;
|
|
103
|
+
}
|
|
104
|
+
if (remainder === 2) {
|
|
105
|
+
const n = (at(i) << 18) | (at(i + 1) << 12);
|
|
106
|
+
out[o++] = (n >> 16) & 0xff;
|
|
107
|
+
}
|
|
108
|
+
else if (remainder === 3) {
|
|
109
|
+
const n = (at(i) << 18) | (at(i + 1) << 12) | (at(i + 2) << 6);
|
|
110
|
+
out[o++] = (n >> 16) & 0xff;
|
|
111
|
+
out[o++] = (n >> 8) & 0xff;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/** Constant-time equality for two byte arrays (length is not secret). */
|
|
116
|
+
export function bytesEqual(a, b) {
|
|
117
|
+
if (a.length !== b.length)
|
|
118
|
+
return false;
|
|
119
|
+
let diff = 0;
|
|
120
|
+
for (let i = 0; i < a.length; i += 1)
|
|
121
|
+
diff |= a[i] ^ b[i];
|
|
122
|
+
return diff === 0;
|
|
123
|
+
}
|
|
124
|
+
/** Concatenate byte arrays into one. */
|
|
125
|
+
export function concatBytes(...parts) {
|
|
126
|
+
let total = 0;
|
|
127
|
+
for (const p of parts)
|
|
128
|
+
total += p.length;
|
|
129
|
+
const out = new Uint8Array(total);
|
|
130
|
+
let offset = 0;
|
|
131
|
+
for (const p of parts) {
|
|
132
|
+
out.set(p, offset);
|
|
133
|
+
offset += p.length;
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
/** Generate a fresh ECDH P-256 key pair for key agreement. */
|
|
138
|
+
export async function generateEcdhKeyPair() {
|
|
139
|
+
const pair = (await subtle().generateKey({ name: 'ECDH', namedCurve: RELAY_CURVE }, true, ['deriveBits']));
|
|
140
|
+
return { publicKey: pair.publicKey, privateKey: pair.privateKey };
|
|
141
|
+
}
|
|
142
|
+
/** Export a public key to its 65-byte raw uncompressed form. */
|
|
143
|
+
export async function exportRawPublicKey(key) {
|
|
144
|
+
const raw = await subtle().exportKey('raw', key);
|
|
145
|
+
return new Uint8Array(raw);
|
|
146
|
+
}
|
|
147
|
+
/** Import a 65-byte raw uncompressed P-256 public key. */
|
|
148
|
+
export async function importRawPublicKey(bytes) {
|
|
149
|
+
if (bytes.length !== RELAY_PUBLIC_KEY_BYTES || bytes[0] !== 0x04) {
|
|
150
|
+
throw new GoodVibesSdkError('Malformed P-256 public key (expected 65-byte uncompressed point).', {
|
|
151
|
+
category: 'bad_request',
|
|
152
|
+
source: 'transport',
|
|
153
|
+
recoverable: false,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
return subtle().importKey('raw', bytes, { name: 'ECDH', namedCurve: RELAY_CURVE }, true, []);
|
|
157
|
+
}
|
|
158
|
+
/** Compute the raw ECDH shared secret (32-byte x-coordinate) between our private key and their public key. */
|
|
159
|
+
export async function deriveSharedSecret(privateKey, publicKey) {
|
|
160
|
+
const bits = await subtle().deriveBits({ name: 'ECDH', public: publicKey }, privateKey, 256);
|
|
161
|
+
return new Uint8Array(bits);
|
|
162
|
+
}
|
|
163
|
+
// ─── hashing + KDF ────────────────────────────────────────────────────────────
|
|
164
|
+
/** SHA-256 digest. */
|
|
165
|
+
export async function sha256(data) {
|
|
166
|
+
const digest = await subtle().digest('SHA-256', data);
|
|
167
|
+
return new Uint8Array(digest);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* HKDF-SHA-256 (extract + expand) producing `length` bytes of output key
|
|
171
|
+
* material. `salt` and `info` bind the derivation to the handshake transcript.
|
|
172
|
+
*/
|
|
173
|
+
export async function hkdf(ikm, salt, info, length) {
|
|
174
|
+
const key = await subtle().importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
175
|
+
const bits = await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt, info }, key, length * 8);
|
|
176
|
+
return new Uint8Array(bits);
|
|
177
|
+
}
|
|
178
|
+
// ─── AEAD (AES-256-GCM) ────────────────────────────────────────────────────────
|
|
179
|
+
/** Import a 32-byte raw key as an AES-256-GCM key. */
|
|
180
|
+
export async function importAeadKey(rawKey) {
|
|
181
|
+
if (rawKey.length !== RELAY_KEY_BYTES) {
|
|
182
|
+
throw new GoodVibesSdkError('AEAD key must be 32 bytes.', { category: 'bad_request', source: 'transport', recoverable: false });
|
|
183
|
+
}
|
|
184
|
+
return subtle().importKey('raw', rawKey, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
|
185
|
+
}
|
|
186
|
+
/** AES-256-GCM seal. Returns ciphertext with the GCM tag appended. */
|
|
187
|
+
export async function aeadSeal(key, nonce, plaintext, aad) {
|
|
188
|
+
const ct = await subtle().encrypt({ name: 'AES-GCM', iv: nonce, additionalData: aad }, key, plaintext);
|
|
189
|
+
return new Uint8Array(ct);
|
|
190
|
+
}
|
|
191
|
+
/** AES-256-GCM open. Throws (auth failure) if the ciphertext/tag/AAD do not verify. */
|
|
192
|
+
export async function aeadOpen(key, nonce, ciphertext, aad) {
|
|
193
|
+
try {
|
|
194
|
+
const pt = await subtle().decrypt({ name: 'AES-GCM', iv: nonce, additionalData: aad }, key, ciphertext);
|
|
195
|
+
return new Uint8Array(pt);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
throw new GoodVibesSdkError('Relay AEAD authentication failed (tampered or out-of-order frame).', {
|
|
199
|
+
category: 'bad_request',
|
|
200
|
+
source: 'transport',
|
|
201
|
+
recoverable: false,
|
|
202
|
+
hint: 'The end-to-end channel integrity check failed. The connection must be torn down.',
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const TEXT_ENCODER = /* @__PURE__ */ new TextEncoder();
|
|
207
|
+
const TEXT_DECODER = /* @__PURE__ */ new TextDecoder();
|
|
208
|
+
/** UTF-8 encode a string to bytes. */
|
|
209
|
+
export function encodeUtf8(text) {
|
|
210
|
+
return TEXT_ENCODER.encode(text);
|
|
211
|
+
}
|
|
212
|
+
/** UTF-8 decode bytes to a string. */
|
|
213
|
+
export function decodeUtf8(bytes) {
|
|
214
|
+
return TEXT_DECODER.decode(bytes);
|
|
215
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type RelayKeyPair } from './crypto.js';
|
|
2
|
+
/** Derived per-direction session keys plus the transcript binding. */
|
|
3
|
+
export interface RelayHandshakeKeys {
|
|
4
|
+
/** AES-256-GCM key for client → daemon frames. */
|
|
5
|
+
readonly clientToDaemonKey: CryptoKey;
|
|
6
|
+
/** AES-256-GCM key for daemon → client frames. */
|
|
7
|
+
readonly daemonToClientKey: CryptoKey;
|
|
8
|
+
/** SHA-256 of the full handshake transcript (used as channel AAD prefix). */
|
|
9
|
+
readonly transcriptHash: Uint8Array<ArrayBuffer>;
|
|
10
|
+
}
|
|
11
|
+
/** Initiator (client) state carried between the two handshake messages. */
|
|
12
|
+
export interface RelayInitiatorState {
|
|
13
|
+
readonly ephemeral: RelayKeyPair;
|
|
14
|
+
readonly ephemeralPublicRaw: Uint8Array<ArrayBuffer>;
|
|
15
|
+
readonly daemonStaticPublicRaw: Uint8Array<ArrayBuffer>;
|
|
16
|
+
readonly ridBytes: Uint8Array<ArrayBuffer>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Begin the handshake. `daemonStaticPublicRaw` is the 65-byte pinned daemon key
|
|
20
|
+
* from the pairing payload; `ridBytes` are the UTF-8 bytes of the rendezvous id.
|
|
21
|
+
* Returns the state to keep and `message1` to send through the pipe.
|
|
22
|
+
*/
|
|
23
|
+
export declare function startInitiatorHandshake(daemonStaticPublicRaw: Uint8Array<ArrayBuffer>, ridBytes: Uint8Array<ArrayBuffer>): Promise<{
|
|
24
|
+
state: RelayInitiatorState;
|
|
25
|
+
message1: Uint8Array<ArrayBuffer>;
|
|
26
|
+
}>;
|
|
27
|
+
/**
|
|
28
|
+
* Complete the handshake on the initiator side. `message2` is the daemon's
|
|
29
|
+
* reply: its 65-byte ephemeral public key followed by an AEAD confirmation tag.
|
|
30
|
+
* Throws if the confirmation fails to verify (daemon impersonation / MITM).
|
|
31
|
+
*/
|
|
32
|
+
export declare function finishInitiatorHandshake(state: RelayInitiatorState, message2: Uint8Array<ArrayBuffer>): Promise<RelayHandshakeKeys>;
|
|
33
|
+
/**
|
|
34
|
+
* Complete the handshake on the responder (daemon) side. `staticKeyPair` is the
|
|
35
|
+
* daemon's persistent identity key; `message1` is the initiator's ephemeral
|
|
36
|
+
* public key. Returns the derived channel keys and `message2` to send back
|
|
37
|
+
* (daemon ephemeral public key + AEAD confirmation tag).
|
|
38
|
+
*/
|
|
39
|
+
export declare function respondToHandshake(staticKeyPair: RelayKeyPair, ridBytes: Uint8Array<ArrayBuffer>, message1: Uint8Array<ArrayBuffer>): Promise<{
|
|
40
|
+
keys: RelayHandshakeKeys;
|
|
41
|
+
message2: Uint8Array<ArrayBuffer>;
|
|
42
|
+
}>;
|
|
43
|
+
//# sourceMappingURL=handshake.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handshake.d.ts","sourceRoot":"","sources":["../../src/relay/handshake.ts"],"names":[],"mappings":"AAuBA,OAAO,EAcL,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AAQrB,sEAAsE;AACtE,MAAM,WAAW,kBAAkB;IACjC,kDAAkD;IAClD,QAAQ,CAAC,iBAAiB,EAAE,SAAS,CAAC;IACtC,kDAAkD;IAClD,QAAQ,CAAC,iBAAiB,EAAE,SAAS,CAAC;IACtC,6EAA6E;IAC7E,QAAQ,CAAC,cAAc,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;CAClD;AAED,2EAA2E;AAC3E,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,SAAS,EAAE,YAAY,CAAC;IACjC,QAAQ,CAAC,kBAAkB,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;IACrD,QAAQ,CAAC,qBAAqB,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;IACxD,QAAQ,CAAC,QAAQ,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;CAC5C;AA0BD;;;;GAIG;AACH,wBAAsB,uBAAuB,CAC3C,qBAAqB,EAAE,UAAU,CAAC,WAAW,CAAC,EAC9C,QAAQ,EAAE,UAAU,CAAC,WAAW,CAAC,GAChC,OAAO,CAAC;IAAE,KAAK,EAAE,mBAAmB,CAAC;IAAC,QAAQ,EAAE,UAAU,CAAC,WAAW,CAAC,CAAA;CAAE,CAAC,CAO5E;AAED;;;;GAIG;AACH,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,mBAAmB,EAC1B,QAAQ,EAAE,UAAU,CAAC,WAAW,CAAC,GAChC,OAAO,CAAC,kBAAkB,CAAC,CAmB7B;AAID;;;;;GAKG;AACH,wBAAsB,kBAAkB,CACtC,aAAa,EAAE,YAAY,EAC3B,QAAQ,EAAE,UAAU,CAAC,WAAW,CAAC,EACjC,QAAQ,EAAE,UAAU,CAAC,WAAW,CAAC,GAChC,OAAO,CAAC;IAAE,IAAI,EAAE,kBAAkB,CAAC;IAAC,QAAQ,EAAE,UAAU,CAAC,WAAW,CAAC,CAAA;CAAE,CAAC,CAc1E"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// relay/handshake.ts
|
|
2
|
+
//
|
|
3
|
+
// The end-to-end (E2E) authenticated key exchange that runs INSIDE a relay pipe
|
|
4
|
+
// before any application byte flows. It follows the Noise "NK" shape: the
|
|
5
|
+
// initiator (client/surface) already knows the responder's (daemon's) static
|
|
6
|
+
// public key — it was pinned from the pairing payload — and stays anonymous
|
|
7
|
+
// itself. This gives three properties that make the relay zero-knowledge:
|
|
8
|
+
//
|
|
9
|
+
// * Daemon authentication: the derived keys mix a static-ephemeral DH
|
|
10
|
+
// (dh_se) against the daemon's static key. Only the real daemon holds that
|
|
11
|
+
// private key, so a malicious or curious relay that tried to sit in the
|
|
12
|
+
// middle cannot derive the session keys — its forged confirmation fails and
|
|
13
|
+
// the client tears the pipe down.
|
|
14
|
+
// * Forward secrecy: an ephemeral-ephemeral DH (dh_ee) means recording the
|
|
15
|
+
// ciphertext and later stealing the daemon's static key still does not
|
|
16
|
+
// reveal past sessions.
|
|
17
|
+
// * Confidentiality from the relay: every derived key comes from DH secrets
|
|
18
|
+
// the relay never sees. It only forwards the two public ephemerals and
|
|
19
|
+
// ciphertext.
|
|
20
|
+
//
|
|
21
|
+
// Primitives (all from crypto.ts / Web Crypto): ECDH P-256 for DH, HKDF-SHA-256
|
|
22
|
+
// for key derivation, AES-256-GCM for the confirmation tag and the channel.
|
|
23
|
+
import { aeadOpen, aeadSeal, concatBytes, deriveSharedSecret, encodeUtf8, exportRawPublicKey, generateEcdhKeyPair, hkdf, importAeadKey, importRawPublicKey, RELAY_NONCE_BYTES, RELAY_PUBLIC_KEY_BYTES, sha256, } from './crypto.js';
|
|
24
|
+
import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
|
|
25
|
+
const HANDSHAKE_LABEL = encodeUtf8('gv-relay-e2e/v1');
|
|
26
|
+
const CONFIRM_PLAINTEXT = encodeUtf8('gv-relay-confirm');
|
|
27
|
+
const ZERO_NONCE = new Uint8Array(RELAY_NONCE_BYTES);
|
|
28
|
+
const CONFIRM_TAG_BYTES = CONFIRM_PLAINTEXT.length + 16; // GCM tag is 16 bytes
|
|
29
|
+
async function buildTranscript(ridBytes, initiatorPub, responderPub, staticPub) {
|
|
30
|
+
return sha256(concatBytes(HANDSHAKE_LABEL, ridBytes, initiatorPub, responderPub, staticPub));
|
|
31
|
+
}
|
|
32
|
+
async function deriveKeys(dhEe, dhSe, transcriptHash) {
|
|
33
|
+
const ikm = concatBytes(dhEe, dhSe);
|
|
34
|
+
const material = await hkdf(ikm, transcriptHash, HANDSHAKE_LABEL, 96);
|
|
35
|
+
const clientToDaemonKey = await importAeadKey(material.subarray(0, 32));
|
|
36
|
+
const daemonToClientKey = await importAeadKey(material.subarray(32, 64));
|
|
37
|
+
const confirmKey = await importAeadKey(material.subarray(64, 96));
|
|
38
|
+
return { keys: { clientToDaemonKey, daemonToClientKey, transcriptHash }, confirmKey };
|
|
39
|
+
}
|
|
40
|
+
// ─── Initiator (client / surface) ─────────────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* Begin the handshake. `daemonStaticPublicRaw` is the 65-byte pinned daemon key
|
|
43
|
+
* from the pairing payload; `ridBytes` are the UTF-8 bytes of the rendezvous id.
|
|
44
|
+
* Returns the state to keep and `message1` to send through the pipe.
|
|
45
|
+
*/
|
|
46
|
+
export async function startInitiatorHandshake(daemonStaticPublicRaw, ridBytes) {
|
|
47
|
+
const ephemeral = await generateEcdhKeyPair();
|
|
48
|
+
const ephemeralPublicRaw = await exportRawPublicKey(ephemeral.publicKey);
|
|
49
|
+
return {
|
|
50
|
+
state: { ephemeral, ephemeralPublicRaw, daemonStaticPublicRaw, ridBytes },
|
|
51
|
+
message1: ephemeralPublicRaw,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Complete the handshake on the initiator side. `message2` is the daemon's
|
|
56
|
+
* reply: its 65-byte ephemeral public key followed by an AEAD confirmation tag.
|
|
57
|
+
* Throws if the confirmation fails to verify (daemon impersonation / MITM).
|
|
58
|
+
*/
|
|
59
|
+
export async function finishInitiatorHandshake(state, message2) {
|
|
60
|
+
if (message2.length !== RELAY_PUBLIC_KEY_BYTES + CONFIRM_TAG_BYTES) {
|
|
61
|
+
throw new GoodVibesSdkError('Malformed relay handshake response.', { category: 'protocol', source: 'transport', recoverable: false });
|
|
62
|
+
}
|
|
63
|
+
const responderPub = message2.subarray(0, RELAY_PUBLIC_KEY_BYTES);
|
|
64
|
+
const confirmTag = message2.subarray(RELAY_PUBLIC_KEY_BYTES);
|
|
65
|
+
const responderKey = await importRawPublicKey(responderPub);
|
|
66
|
+
const staticKey = await importRawPublicKey(state.daemonStaticPublicRaw);
|
|
67
|
+
const dhEe = await deriveSharedSecret(state.ephemeral.privateKey, responderKey);
|
|
68
|
+
const dhSe = await deriveSharedSecret(state.ephemeral.privateKey, staticKey);
|
|
69
|
+
const transcriptHash = await buildTranscript(state.ridBytes, state.ephemeralPublicRaw, responderPub, state.daemonStaticPublicRaw);
|
|
70
|
+
const { keys, confirmKey } = await deriveKeys(dhEe, dhSe, transcriptHash);
|
|
71
|
+
// Verifying the daemon's confirmation authenticates it: only the holder of
|
|
72
|
+
// the pinned static private key could have derived confirmKey.
|
|
73
|
+
const opened = await aeadOpen(confirmKey, ZERO_NONCE, confirmTag, transcriptHash);
|
|
74
|
+
if (opened.length !== CONFIRM_PLAINTEXT.length) {
|
|
75
|
+
throw new GoodVibesSdkError('Relay handshake confirmation mismatch.', { category: 'protocol', source: 'transport', recoverable: false });
|
|
76
|
+
}
|
|
77
|
+
return keys;
|
|
78
|
+
}
|
|
79
|
+
// ─── Responder (daemon) ───────────────────────────────────────────────────────
|
|
80
|
+
/**
|
|
81
|
+
* Complete the handshake on the responder (daemon) side. `staticKeyPair` is the
|
|
82
|
+
* daemon's persistent identity key; `message1` is the initiator's ephemeral
|
|
83
|
+
* public key. Returns the derived channel keys and `message2` to send back
|
|
84
|
+
* (daemon ephemeral public key + AEAD confirmation tag).
|
|
85
|
+
*/
|
|
86
|
+
export async function respondToHandshake(staticKeyPair, ridBytes, message1) {
|
|
87
|
+
if (message1.length !== RELAY_PUBLIC_KEY_BYTES) {
|
|
88
|
+
throw new GoodVibesSdkError('Malformed relay handshake initiation.', { category: 'protocol', source: 'transport', recoverable: false });
|
|
89
|
+
}
|
|
90
|
+
const initiatorKey = await importRawPublicKey(message1);
|
|
91
|
+
const ephemeral = await generateEcdhKeyPair();
|
|
92
|
+
const ephemeralPublicRaw = await exportRawPublicKey(ephemeral.publicKey);
|
|
93
|
+
const staticPublicRaw = await exportRawPublicKey(staticKeyPair.publicKey);
|
|
94
|
+
const dhEe = await deriveSharedSecret(ephemeral.privateKey, initiatorKey);
|
|
95
|
+
const dhSe = await deriveSharedSecret(staticKeyPair.privateKey, initiatorKey);
|
|
96
|
+
const transcriptHash = await buildTranscript(ridBytes, message1, ephemeralPublicRaw, staticPublicRaw);
|
|
97
|
+
const { keys, confirmKey } = await deriveKeys(dhEe, dhSe, transcriptHash);
|
|
98
|
+
const confirmTag = await aeadSeal(confirmKey, ZERO_NONCE, CONFIRM_PLAINTEXT, transcriptHash);
|
|
99
|
+
return { keys, message2: concatBytes(ephemeralPublicRaw, confirmTag) };
|
|
100
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type RelayKeyPair } from './crypto.js';
|
|
2
|
+
/** Serializable form of a relay identity, safe to hand to a secret store. */
|
|
3
|
+
export interface SerializedRelayIdentity {
|
|
4
|
+
/** Format version. */
|
|
5
|
+
readonly v: 1;
|
|
6
|
+
/** Full private key in JWK form (contains the `d` scalar — treat as a secret). */
|
|
7
|
+
readonly privateKeyJwk: JsonWebKey;
|
|
8
|
+
/** Raw uncompressed public key, base64url — safe to publish in pairing payloads. */
|
|
9
|
+
readonly publicKeyRaw: string;
|
|
10
|
+
}
|
|
11
|
+
/** Generate a fresh daemon relay identity key pair. */
|
|
12
|
+
export declare function generateRelayIdentity(): Promise<RelayKeyPair>;
|
|
13
|
+
/** Serialize an identity for durable storage (JWK private + raw public). */
|
|
14
|
+
export declare function serializeRelayIdentity(pair: RelayKeyPair): Promise<SerializedRelayIdentity>;
|
|
15
|
+
/** Reconstruct an identity key pair from its serialized form. */
|
|
16
|
+
export declare function deserializeRelayIdentity(serialized: SerializedRelayIdentity): Promise<RelayKeyPair>;
|
|
17
|
+
/** The base64url raw public key for a pairing payload. */
|
|
18
|
+
export declare function relayIdentityPublicKeyBase64Url(pair: RelayKeyPair): Promise<string>;
|
|
19
|
+
//# sourceMappingURL=identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../src/relay/identity.ts"],"names":[],"mappings":"AAcA,OAAO,EAIL,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AAGrB,6EAA6E;AAC7E,MAAM,WAAW,uBAAuB;IACtC,sBAAsB;IACtB,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC;IACd,kFAAkF;IAClF,QAAQ,CAAC,aAAa,EAAE,UAAU,CAAC;IACnC,oFAAoF;IACpF,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;CAC/B;AAcD,uDAAuD;AACvD,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,YAAY,CAAC,CAEnE;AAED,4EAA4E;AAC5E,wBAAsB,sBAAsB,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,uBAAuB,CAAC,CAIjG;AAED,iEAAiE;AACjE,wBAAsB,wBAAwB,CAAC,UAAU,EAAE,uBAAuB,GAAG,OAAO,CAAC,YAAY,CAAC,CAYzG;AAED,0DAA0D;AAC1D,wBAAsB,+BAA+B,CAAC,IAAI,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAEzF"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// relay/identity.ts
|
|
2
|
+
//
|
|
3
|
+
// The daemon's persistent relay identity key pair. The codebase had no existing
|
|
4
|
+
// asymmetric daemon identity (identities were random UUIDs and shared-secret
|
|
5
|
+
// tokens), so this mints one, modelled on the existing VapidManager pattern:
|
|
6
|
+
// generate once, persist the whole key pair through the daemon's SecretsManager
|
|
7
|
+
// (custody is the caller's responsibility — this module only (de)serializes),
|
|
8
|
+
// and expose the public half for pinning in pairing payloads.
|
|
9
|
+
//
|
|
10
|
+
// The private key never leaves the daemon process. Its public key is what a
|
|
11
|
+
// surface pins to authenticate the daemon during the E2E handshake, so the
|
|
12
|
+
// security of the whole relay path rests on the pairing payload being delivered
|
|
13
|
+
// out-of-band (QR / trusted LAN), exactly like an SSH host key fingerprint.
|
|
14
|
+
import { exportRawPublicKey, generateEcdhKeyPair, toBase64Url, } from './crypto.js';
|
|
15
|
+
import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
|
|
16
|
+
function subtle() {
|
|
17
|
+
const c = globalThis.crypto;
|
|
18
|
+
if (!c || typeof c.subtle === 'undefined') {
|
|
19
|
+
throw new GoodVibesSdkError('Web Crypto (crypto.subtle) is unavailable in this runtime.', {
|
|
20
|
+
category: 'config',
|
|
21
|
+
source: 'transport',
|
|
22
|
+
recoverable: false,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return c.subtle;
|
|
26
|
+
}
|
|
27
|
+
/** Generate a fresh daemon relay identity key pair. */
|
|
28
|
+
export async function generateRelayIdentity() {
|
|
29
|
+
return generateEcdhKeyPair();
|
|
30
|
+
}
|
|
31
|
+
/** Serialize an identity for durable storage (JWK private + raw public). */
|
|
32
|
+
export async function serializeRelayIdentity(pair) {
|
|
33
|
+
const jwk = await subtle().exportKey('jwk', pair.privateKey);
|
|
34
|
+
const publicKeyRaw = toBase64Url(await exportRawPublicKey(pair.publicKey));
|
|
35
|
+
return { v: 1, privateKeyJwk: jwk, publicKeyRaw };
|
|
36
|
+
}
|
|
37
|
+
/** Reconstruct an identity key pair from its serialized form. */
|
|
38
|
+
export async function deserializeRelayIdentity(serialized) {
|
|
39
|
+
if (serialized.v !== 1) {
|
|
40
|
+
throw new GoodVibesSdkError('Unsupported relay identity format version.', { category: 'bad_request', source: 'transport', recoverable: false });
|
|
41
|
+
}
|
|
42
|
+
const jwk = serialized.privateKeyJwk;
|
|
43
|
+
const privateKey = await subtle().importKey('jwk', jwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
44
|
+
// Derive a public-only JWK by dropping the private scalar `d`.
|
|
45
|
+
const publicJwk = { ...jwk };
|
|
46
|
+
delete publicJwk['d'];
|
|
47
|
+
publicJwk.key_ops = [];
|
|
48
|
+
const publicKey = await subtle().importKey('jwk', publicJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, []);
|
|
49
|
+
return { publicKey, privateKey };
|
|
50
|
+
}
|
|
51
|
+
/** The base64url raw public key for a pairing payload. */
|
|
52
|
+
export async function relayIdentityPublicKeyBase64Url(pair) {
|
|
53
|
+
return toBase64Url(await exportRawPublicKey(pair.publicKey));
|
|
54
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { RELAY_CURVE, RELAY_PUBLIC_KEY_BYTES, RELAY_NONCE_BYTES, RELAY_KEY_BYTES, randomBytes, toBase64Url, fromBase64Url, bytesEqual, concatBytes, encodeUtf8, decodeUtf8, generateEcdhKeyPair, exportRawPublicKey, importRawPublicKey, deriveSharedSecret, sha256, hkdf, importAeadKey, aeadSeal, aeadOpen, type RelayKeyPair, } from './crypto.js';
|
|
2
|
+
export { RELAY_PROTOCOL_VERSION, RELAY_PIPE_ID_BYTES, encodeControlFrame, decodeControlFrame, framePipePayload, unframePipePayload, type RendezvousId, type PipeId, type RelayRole, type RelayErrorCode, type RelayControlFrame, type RelayRegisterFrame, type RelayRegisteredFrame, type RelayConnectFrame, type RelayConnectedFrame, type RelayPipeOpenFrame, type RelayPipeCloseFrame, type RelayErrorFrame, } from './protocol.js';
|
|
3
|
+
export { startInitiatorHandshake, finishInitiatorHandshake, respondToHandshake, type RelayHandshakeKeys, type RelayInitiatorState, } from './handshake.js';
|
|
4
|
+
export { RelaySecureChannel } from './secure-channel.js';
|
|
5
|
+
export { generateRelayIdentity, serializeRelayIdentity, deserializeRelayIdentity, relayIdentityPublicKeyBase64Url, type SerializedRelayIdentity, } from './identity.js';
|
|
6
|
+
export { PAIRING_SCHEME, createRelayPairingPayload, encodeRelayPairingString, decodeRelayPairingString, type RelayPairingPayload, } from './pairing.js';
|
|
7
|
+
export { encodeTunnelFrame, decodeTunnelFrame, type TunnelHeader, type TunnelRequestHeader, type TunnelResponseHeader, type TunnelStreamOpenHeader, type TunnelStreamDataHeader, type TunnelStreamOverflowHeader, type TunnelStreamCloseHeader, } from './tunnel.js';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/relay/index.ts"],"names":[],"mappings":"AAQA,OAAO,EACL,WAAW,EACX,sBAAsB,EACtB,iBAAiB,EACjB,eAAe,EACf,WAAW,EACX,WAAW,EACX,aAAa,EACb,UAAU,EACV,WAAW,EACX,UAAU,EACV,UAAU,EACV,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,MAAM,EACN,IAAI,EACJ,aAAa,EACb,QAAQ,EACR,QAAQ,EACR,KAAK,YAAY,GAClB,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,sBAAsB,EACtB,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,gBAAgB,EAChB,kBAAkB,EAClB,KAAK,YAAY,EACjB,KAAK,MAAM,EACX,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,KAAK,oBAAoB,EACzB,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,eAAe,GACrB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,uBAAuB,EACvB,wBAAwB,EACxB,kBAAkB,EAClB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,GACzB,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAEzD,OAAO,EACL,qBAAqB,EACrB,sBAAsB,EACtB,wBAAwB,EACxB,+BAA+B,EAC/B,KAAK,uBAAuB,GAC7B,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,cAAc,EACd,yBAAyB,EACzB,wBAAwB,EACxB,wBAAwB,EACxB,KAAK,mBAAmB,GACzB,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,EACzB,KAAK,sBAAsB,EAC3B,KAAK,sBAAsB,EAC3B,KAAK,0BAA0B,EAC/B,KAAK,uBAAuB,GAC7B,MAAM,aAAa,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// relay/index.ts
|
|
2
|
+
//
|
|
3
|
+
// Runtime-neutral building blocks for the zero-knowledge, self-hostable relay:
|
|
4
|
+
// the hop protocol the relay operator can read, and the end-to-end crypto it
|
|
5
|
+
// cannot. Consumed by the relay server (daemon-sdk), the client relay transport
|
|
6
|
+
// (transport-realtime), and the daemon-side termination. Kept off the main
|
|
7
|
+
// transport-core barrel so it stays an explicit, opt-in subpath import.
|
|
8
|
+
export { RELAY_CURVE, RELAY_PUBLIC_KEY_BYTES, RELAY_NONCE_BYTES, RELAY_KEY_BYTES, randomBytes, toBase64Url, fromBase64Url, bytesEqual, concatBytes, encodeUtf8, decodeUtf8, generateEcdhKeyPair, exportRawPublicKey, importRawPublicKey, deriveSharedSecret, sha256, hkdf, importAeadKey, aeadSeal, aeadOpen, } from './crypto.js';
|
|
9
|
+
export { RELAY_PROTOCOL_VERSION, RELAY_PIPE_ID_BYTES, encodeControlFrame, decodeControlFrame, framePipePayload, unframePipePayload, } from './protocol.js';
|
|
10
|
+
export { startInitiatorHandshake, finishInitiatorHandshake, respondToHandshake, } from './handshake.js';
|
|
11
|
+
export { RelaySecureChannel } from './secure-channel.js';
|
|
12
|
+
export { generateRelayIdentity, serializeRelayIdentity, deserializeRelayIdentity, relayIdentityPublicKeyBase64Url, } from './identity.js';
|
|
13
|
+
export { PAIRING_SCHEME, createRelayPairingPayload, encodeRelayPairingString, decodeRelayPairingString, } from './pairing.js';
|
|
14
|
+
export { encodeTunnelFrame, decodeTunnelFrame, } from './tunnel.js';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type RendezvousId } from './protocol.js';
|
|
2
|
+
/** Self-describing prefix so a scanner can recognize a relay pairing string. */
|
|
3
|
+
export declare const PAIRING_SCHEME = "gvrelay1";
|
|
4
|
+
/** The decoded contents of a pairing payload. */
|
|
5
|
+
export interface RelayPairingPayload {
|
|
6
|
+
/** Pairing format / protocol version. */
|
|
7
|
+
readonly protocol: number;
|
|
8
|
+
/** Public relay URL the surface should dial (wss://…). */
|
|
9
|
+
readonly relayUrl: string;
|
|
10
|
+
/** Unguessable rendezvous id the daemon registered under. */
|
|
11
|
+
readonly rid: RendezvousId;
|
|
12
|
+
/** Daemon static public key (raw uncompressed P-256), base64url. */
|
|
13
|
+
readonly daemonPublicKey: string;
|
|
14
|
+
/** Optional human-facing label for the daemon (shown in the surface UI). */
|
|
15
|
+
readonly label?: string;
|
|
16
|
+
}
|
|
17
|
+
/** Mint a pairing payload object from its parts. */
|
|
18
|
+
export declare function createRelayPairingPayload(input: {
|
|
19
|
+
readonly relayUrl: string;
|
|
20
|
+
readonly rid: RendezvousId;
|
|
21
|
+
readonly daemonPublicKey: string;
|
|
22
|
+
readonly label?: string;
|
|
23
|
+
}): RelayPairingPayload;
|
|
24
|
+
/** Encode a pairing payload to a compact, QR-friendly string. */
|
|
25
|
+
export declare function encodeRelayPairingString(payload: RelayPairingPayload): string;
|
|
26
|
+
/** Decode a pairing string back into a payload. Throws on malformed input. */
|
|
27
|
+
export declare function decodeRelayPairingString(text: string): RelayPairingPayload;
|
|
28
|
+
//# sourceMappingURL=pairing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pairing.d.ts","sourceRoot":"","sources":["../../src/relay/pairing.ts"],"names":[],"mappings":"AAeA,OAAO,EAA0B,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAG1E,gFAAgF;AAChF,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC,iDAAiD;AACjD,MAAM,WAAW,mBAAmB;IAClC,yCAAyC;IACzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,0DAA0D;IAC1D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,6DAA6D;IAC7D,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC;IAC3B,oEAAoE;IACpE,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,4EAA4E;IAC5E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAUD,oDAAoD;AACpD,wBAAgB,yBAAyB,CAAC,KAAK,EAAE;IAC/C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC;IAC3B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB,GAAG,mBAAmB,CAQtB;AAED,iEAAiE;AACjE,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAS7E;AAED,8EAA8E;AAC9E,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,mBAAmB,CAmC1E"}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// relay/pairing.ts
|
|
2
|
+
//
|
|
3
|
+
// The pairing payload is the out-of-band bootstrap for a relay connection: it
|
|
4
|
+
// carries everything a surface needs to reach a daemon and authenticate it —
|
|
5
|
+
// the relay URL, the unguessable rendezvous id, and the daemon's pinned static
|
|
6
|
+
// public key. It is deliberately compact and QR-encodable (base64url of a small
|
|
7
|
+
// JSON object, prefixed with a self-describing scheme tag) because the intended
|
|
8
|
+
// delivery is a QR code scanned from the daemon's screen, the same trust model
|
|
9
|
+
// as pairing a phone by scanning a code.
|
|
10
|
+
//
|
|
11
|
+
// Delivering this payload over a trusted channel (QR on the same LAN, a copied
|
|
12
|
+
// string) is what bootstraps trust. Whoever holds a valid pairing payload can
|
|
13
|
+
// reach the daemon through the relay; treat it like a credential.
|
|
14
|
+
import { fromBase64Url, toBase64Url, decodeUtf8, encodeUtf8 } from './crypto.js';
|
|
15
|
+
import { RELAY_PROTOCOL_VERSION } from './protocol.js';
|
|
16
|
+
import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
|
|
17
|
+
/** Self-describing prefix so a scanner can recognize a relay pairing string. */
|
|
18
|
+
export const PAIRING_SCHEME = 'gvrelay1';
|
|
19
|
+
/** Mint a pairing payload object from its parts. */
|
|
20
|
+
export function createRelayPairingPayload(input) {
|
|
21
|
+
return {
|
|
22
|
+
protocol: RELAY_PROTOCOL_VERSION,
|
|
23
|
+
relayUrl: input.relayUrl,
|
|
24
|
+
rid: input.rid,
|
|
25
|
+
daemonPublicKey: input.daemonPublicKey,
|
|
26
|
+
...(input.label !== undefined ? { label: input.label } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Encode a pairing payload to a compact, QR-friendly string. */
|
|
30
|
+
export function encodeRelayPairingString(payload) {
|
|
31
|
+
const wire = {
|
|
32
|
+
p: payload.protocol,
|
|
33
|
+
u: payload.relayUrl,
|
|
34
|
+
r: payload.rid,
|
|
35
|
+
k: payload.daemonPublicKey,
|
|
36
|
+
...(payload.label !== undefined ? { l: payload.label } : {}),
|
|
37
|
+
};
|
|
38
|
+
return `${PAIRING_SCHEME}.${toBase64Url(encodeUtf8(JSON.stringify(wire)))}`;
|
|
39
|
+
}
|
|
40
|
+
/** Decode a pairing string back into a payload. Throws on malformed input. */
|
|
41
|
+
export function decodeRelayPairingString(text) {
|
|
42
|
+
const trimmed = text.trim();
|
|
43
|
+
const dot = trimmed.indexOf('.');
|
|
44
|
+
if (dot < 0 || trimmed.slice(0, dot) !== PAIRING_SCHEME) {
|
|
45
|
+
throw new GoodVibesSdkError('Not a recognizable relay pairing string.', {
|
|
46
|
+
category: 'bad_request',
|
|
47
|
+
source: 'transport',
|
|
48
|
+
recoverable: false,
|
|
49
|
+
hint: `Expected a "${PAIRING_SCHEME}." prefixed pairing code.`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
let wire;
|
|
53
|
+
try {
|
|
54
|
+
wire = JSON.parse(decodeUtf8(fromBase64Url(trimmed.slice(dot + 1))));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new GoodVibesSdkError('Corrupt relay pairing string.', { category: 'bad_request', source: 'transport', recoverable: false });
|
|
58
|
+
}
|
|
59
|
+
if (typeof wire !== 'object' ||
|
|
60
|
+
wire === null ||
|
|
61
|
+
typeof wire.p !== 'number' ||
|
|
62
|
+
typeof wire.u !== 'string' ||
|
|
63
|
+
typeof wire.r !== 'string' ||
|
|
64
|
+
typeof wire.k !== 'string') {
|
|
65
|
+
throw new GoodVibesSdkError('Relay pairing string is missing required fields.', { category: 'bad_request', source: 'transport', recoverable: false });
|
|
66
|
+
}
|
|
67
|
+
const w = wire;
|
|
68
|
+
return {
|
|
69
|
+
protocol: w.p,
|
|
70
|
+
relayUrl: w.u,
|
|
71
|
+
rid: w.r,
|
|
72
|
+
daemonPublicKey: w.k,
|
|
73
|
+
...(typeof w.l === 'string' ? { label: w.l } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Wire protocol version. Bumped only on incompatible control-frame changes. */
|
|
2
|
+
export declare const RELAY_PROTOCOL_VERSION = 1;
|
|
3
|
+
/** Length in bytes of a multiplexed pipe identifier. */
|
|
4
|
+
export declare const RELAY_PIPE_ID_BYTES = 16;
|
|
5
|
+
/** Rendezvous id: unguessable string a daemon registers under and a client dials. */
|
|
6
|
+
export type RendezvousId = string;
|
|
7
|
+
/** Opaque multiplexed pipe id (one client<->daemon tunnel through the relay). */
|
|
8
|
+
export type PipeId = string;
|
|
9
|
+
/** Endpoint role on a relay connection. */
|
|
10
|
+
export type RelayRole = 'daemon' | 'client';
|
|
11
|
+
/** Machine-readable relay failure codes (surfaced to callers as honest errors). */
|
|
12
|
+
export type RelayErrorCode = 'daemon-offline' | 'rid-taken' | 'capacity' | 'protocol-version' | 'malformed' | 'rate-limited' | 'unauthorized' | 'internal';
|
|
13
|
+
/** Daemon → relay: claim a rendezvous id and accept client pipes for it. */
|
|
14
|
+
export interface RelayRegisterFrame {
|
|
15
|
+
readonly t: 'register';
|
|
16
|
+
readonly role: 'daemon';
|
|
17
|
+
readonly protocol: number;
|
|
18
|
+
readonly rid: RendezvousId;
|
|
19
|
+
}
|
|
20
|
+
/** Relay → daemon: the rendezvous id is claimed; the daemon is now reachable. */
|
|
21
|
+
export interface RelayRegisteredFrame {
|
|
22
|
+
readonly t: 'registered';
|
|
23
|
+
readonly rid: RendezvousId;
|
|
24
|
+
}
|
|
25
|
+
/** Client → relay: dial a rendezvous id. */
|
|
26
|
+
export interface RelayConnectFrame {
|
|
27
|
+
readonly t: 'connect';
|
|
28
|
+
readonly role: 'client';
|
|
29
|
+
readonly protocol: number;
|
|
30
|
+
readonly rid: RendezvousId;
|
|
31
|
+
}
|
|
32
|
+
/** Relay → client: a pipe to the daemon is open; begin the E2E handshake. */
|
|
33
|
+
export interface RelayConnectedFrame {
|
|
34
|
+
readonly t: 'connected';
|
|
35
|
+
readonly pipe: PipeId;
|
|
36
|
+
}
|
|
37
|
+
/** Relay → daemon: a new client pipe opened for this daemon's rendezvous id. */
|
|
38
|
+
export interface RelayPipeOpenFrame {
|
|
39
|
+
readonly t: 'pipe-open';
|
|
40
|
+
readonly pipe: PipeId;
|
|
41
|
+
}
|
|
42
|
+
/** Relay → daemon/client: a pipe closed (peer gone or torn down). */
|
|
43
|
+
export interface RelayPipeCloseFrame {
|
|
44
|
+
readonly t: 'pipe-close';
|
|
45
|
+
readonly pipe: PipeId;
|
|
46
|
+
readonly reason?: string;
|
|
47
|
+
}
|
|
48
|
+
/** Relay → endpoint: an error occurred. `code` is machine-readable. */
|
|
49
|
+
export interface RelayErrorFrame {
|
|
50
|
+
readonly t: 'error';
|
|
51
|
+
readonly code: RelayErrorCode;
|
|
52
|
+
readonly message: string;
|
|
53
|
+
readonly pipe?: PipeId;
|
|
54
|
+
}
|
|
55
|
+
/** Any endpoint ↔ relay control frame. */
|
|
56
|
+
export type RelayControlFrame = RelayRegisterFrame | RelayRegisteredFrame | RelayConnectFrame | RelayConnectedFrame | RelayPipeOpenFrame | RelayPipeCloseFrame | RelayErrorFrame;
|
|
57
|
+
/** Serialize a control frame to a WebSocket text payload. */
|
|
58
|
+
export declare function encodeControlFrame(frame: RelayControlFrame): string;
|
|
59
|
+
/**
|
|
60
|
+
* Parse a WebSocket text payload into a control frame, or return null if it is
|
|
61
|
+
* not a recognizable, well-typed relay control frame. Callers treat null as a
|
|
62
|
+
* `malformed` protocol violation.
|
|
63
|
+
*/
|
|
64
|
+
export declare function decodeControlFrame(text: string): RelayControlFrame | null;
|
|
65
|
+
/**
|
|
66
|
+
* Prefix an opaque ciphertext payload with its 16-byte pipe id, for the
|
|
67
|
+
* daemon<->relay leg where a single socket multiplexes many client pipes.
|
|
68
|
+
* `pipeIdBytes` is the raw 16-byte random pipe id; its base64url encoding is the
|
|
69
|
+
* `PipeId` string carried in control frames (the daemon correlates the two by
|
|
70
|
+
* base64url-encoding this prefix). The relay reads this prefix as routing
|
|
71
|
+
* metadata and never inspects the payload that follows.
|
|
72
|
+
*/
|
|
73
|
+
export declare function framePipePayload(pipeIdBytes: Uint8Array<ArrayBuffer>, payload: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer>;
|
|
74
|
+
/** Split a daemon<->relay binary frame into its pipe-id prefix and opaque payload. */
|
|
75
|
+
export declare function unframePipePayload(frame: Uint8Array<ArrayBuffer>): {
|
|
76
|
+
readonly pipeId: Uint8Array<ArrayBuffer>;
|
|
77
|
+
readonly payload: Uint8Array<ArrayBuffer>;
|
|
78
|
+
} | null;
|
|
79
|
+
//# sourceMappingURL=protocol.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../../src/relay/protocol.ts"],"names":[],"mappings":"AAkBA,gFAAgF;AAChF,eAAO,MAAM,sBAAsB,IAAI,CAAC;AAExC,wDAAwD;AACxD,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,qFAAqF;AACrF,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC;AAElC,iFAAiF;AACjF,MAAM,MAAM,MAAM,GAAG,MAAM,CAAC;AAE5B,2CAA2C;AAC3C,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5C,mFAAmF;AACnF,MAAM,MAAM,cAAc,GACtB,gBAAgB,GAChB,WAAW,GACX,UAAU,GACV,kBAAkB,GAClB,WAAW,GACX,cAAc,GACd,cAAc,GACd,UAAU,CAAC;AAIf,4EAA4E;AAC5E,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAC;IACvB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC;CAC5B;AAED,iFAAiF;AACjF,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC;CAC5B;AAED,4CAA4C;AAC5C,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,CAAC,EAAE,SAAS,CAAC;IACtB,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,GAAG,EAAE,YAAY,CAAC;CAC5B;AAED,6EAA6E;AAC7E,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,CAAC,EAAE,WAAW,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,gFAAgF;AAChF,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,CAAC,EAAE,WAAW,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,CAAC,EAAE,YAAY,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,uEAAuE;AACvE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,CAAC,EAAE,OAAO,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,0CAA0C;AAC1C,MAAM,MAAM,iBAAiB,GACzB,kBAAkB,GAClB,oBAAoB,GACpB,iBAAiB,GACjB,mBAAmB,GACnB,kBAAkB,GAClB,mBAAmB,GACnB,eAAe,CAAC;AAEpB,6DAA6D;AAC7D,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,iBAAiB,GAAG,MAAM,CAEnE;AAMD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAwCzE;AAYD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,UAAU,CAAC,WAAW,CAAC,EAAE,OAAO,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,UAAU,CAAC,WAAW,CAAC,CAQhI;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,WAAW,CAAC,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,UAAU,CAAC,WAAW,CAAC,CAAA;CAAE,GAAG,IAAI,CAMjK"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// relay/protocol.ts
|
|
2
|
+
//
|
|
3
|
+
// The relay-hop wire protocol. This is the ONLY part of the traffic the relay
|
|
4
|
+
// operator can read: connection-control frames (who is a daemon, who is a
|
|
5
|
+
// client, which rendezvous id, which multiplexed pipe) plus opaque binary
|
|
6
|
+
// payloads it forwards verbatim. The relay never sees the end-to-end keys or
|
|
7
|
+
// plaintext — every binary payload is ciphertext produced by secure-channel.ts.
|
|
8
|
+
//
|
|
9
|
+
// Two message shapes travel over each relay WebSocket:
|
|
10
|
+
// 1. Control frames — JSON text messages (this module encodes/decodes them).
|
|
11
|
+
// 2. Data frames — binary messages: on the daemon<->relay leg they carry a
|
|
12
|
+
// 16-byte pipe-id prefix (relay-hop routing metadata) followed by opaque
|
|
13
|
+
// ciphertext; on the client<->relay leg they are bare opaque ciphertext
|
|
14
|
+
// (a client owns exactly one pipe).
|
|
15
|
+
//
|
|
16
|
+
// Keeping the framing tiny and explicit is deliberate: a public relay instance
|
|
17
|
+
// must be auditable at a glance for exactly what it can observe.
|
|
18
|
+
/** Wire protocol version. Bumped only on incompatible control-frame changes. */
|
|
19
|
+
export const RELAY_PROTOCOL_VERSION = 1;
|
|
20
|
+
/** Length in bytes of a multiplexed pipe identifier. */
|
|
21
|
+
export const RELAY_PIPE_ID_BYTES = 16;
|
|
22
|
+
/** Serialize a control frame to a WebSocket text payload. */
|
|
23
|
+
export function encodeControlFrame(frame) {
|
|
24
|
+
return JSON.stringify(frame);
|
|
25
|
+
}
|
|
26
|
+
function isRecord(v) {
|
|
27
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Parse a WebSocket text payload into a control frame, or return null if it is
|
|
31
|
+
* not a recognizable, well-typed relay control frame. Callers treat null as a
|
|
32
|
+
* `malformed` protocol violation.
|
|
33
|
+
*/
|
|
34
|
+
export function decodeControlFrame(text) {
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(text);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
if (!isRecord(parsed) || typeof parsed['t'] !== 'string')
|
|
43
|
+
return null;
|
|
44
|
+
const t = parsed['t'];
|
|
45
|
+
switch (t) {
|
|
46
|
+
case 'register':
|
|
47
|
+
return isRid(parsed) && parsed['role'] === 'daemon' && typeof parsed['protocol'] === 'number'
|
|
48
|
+
? { t, role: 'daemon', protocol: parsed['protocol'], rid: parsed['rid'] }
|
|
49
|
+
: null;
|
|
50
|
+
case 'connect':
|
|
51
|
+
return isRid(parsed) && parsed['role'] === 'client' && typeof parsed['protocol'] === 'number'
|
|
52
|
+
? { t, role: 'client', protocol: parsed['protocol'], rid: parsed['rid'] }
|
|
53
|
+
: null;
|
|
54
|
+
case 'registered':
|
|
55
|
+
return isRid(parsed) ? { t, rid: parsed['rid'] } : null;
|
|
56
|
+
case 'connected':
|
|
57
|
+
return isPipe(parsed) ? { t, pipe: parsed['pipe'] } : null;
|
|
58
|
+
case 'pipe-open':
|
|
59
|
+
return isPipe(parsed) ? { t, pipe: parsed['pipe'] } : null;
|
|
60
|
+
case 'pipe-close':
|
|
61
|
+
return isPipe(parsed)
|
|
62
|
+
? { t, pipe: parsed['pipe'], ...(typeof parsed['reason'] === 'string' ? { reason: parsed['reason'] } : {}) }
|
|
63
|
+
: null;
|
|
64
|
+
case 'error':
|
|
65
|
+
return typeof parsed['code'] === 'string' && typeof parsed['message'] === 'string'
|
|
66
|
+
? {
|
|
67
|
+
t,
|
|
68
|
+
code: parsed['code'],
|
|
69
|
+
message: parsed['message'],
|
|
70
|
+
...(typeof parsed['pipe'] === 'string' ? { pipe: parsed['pipe'] } : {}),
|
|
71
|
+
}
|
|
72
|
+
: null;
|
|
73
|
+
default:
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function isRid(v) {
|
|
78
|
+
return typeof v['rid'] === 'string' && v['rid'].length > 0;
|
|
79
|
+
}
|
|
80
|
+
function isPipe(v) {
|
|
81
|
+
return typeof v['pipe'] === 'string' && v['pipe'].length > 0;
|
|
82
|
+
}
|
|
83
|
+
// ─── Data frames (binary, daemon<->relay leg carries a pipe-id prefix) ─────────
|
|
84
|
+
/**
|
|
85
|
+
* Prefix an opaque ciphertext payload with its 16-byte pipe id, for the
|
|
86
|
+
* daemon<->relay leg where a single socket multiplexes many client pipes.
|
|
87
|
+
* `pipeIdBytes` is the raw 16-byte random pipe id; its base64url encoding is the
|
|
88
|
+
* `PipeId` string carried in control frames (the daemon correlates the two by
|
|
89
|
+
* base64url-encoding this prefix). The relay reads this prefix as routing
|
|
90
|
+
* metadata and never inspects the payload that follows.
|
|
91
|
+
*/
|
|
92
|
+
export function framePipePayload(pipeIdBytes, payload) {
|
|
93
|
+
if (pipeIdBytes.length !== RELAY_PIPE_ID_BYTES) {
|
|
94
|
+
throw new Error(`pipe id must be ${RELAY_PIPE_ID_BYTES} bytes`);
|
|
95
|
+
}
|
|
96
|
+
const out = new Uint8Array(RELAY_PIPE_ID_BYTES + payload.length);
|
|
97
|
+
out.set(pipeIdBytes, 0);
|
|
98
|
+
out.set(payload, RELAY_PIPE_ID_BYTES);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/** Split a daemon<->relay binary frame into its pipe-id prefix and opaque payload. */
|
|
102
|
+
export function unframePipePayload(frame) {
|
|
103
|
+
if (frame.length < RELAY_PIPE_ID_BYTES)
|
|
104
|
+
return null;
|
|
105
|
+
return {
|
|
106
|
+
pipeId: frame.subarray(0, RELAY_PIPE_ID_BYTES),
|
|
107
|
+
payload: frame.subarray(RELAY_PIPE_ID_BYTES),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RelayHandshakeKeys } from './handshake.js';
|
|
2
|
+
/**
|
|
3
|
+
* An authenticated, ordered channel between one client and one daemon,
|
|
4
|
+
* established from a completed handshake. `role` selects which directional key
|
|
5
|
+
* is used for sending vs receiving.
|
|
6
|
+
*/
|
|
7
|
+
export declare class RelaySecureChannel {
|
|
8
|
+
private readonly sendKey;
|
|
9
|
+
private readonly recvKey;
|
|
10
|
+
private readonly sendPrefix;
|
|
11
|
+
private readonly recvPrefix;
|
|
12
|
+
private readonly aad;
|
|
13
|
+
private sendCounter;
|
|
14
|
+
private lastRecvCounter;
|
|
15
|
+
constructor(keys: RelayHandshakeKeys, role: 'client' | 'daemon');
|
|
16
|
+
/** Seal an application payload into a wire frame (counter prefix + ciphertext). */
|
|
17
|
+
seal(plaintext: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>>;
|
|
18
|
+
/** Open a received wire frame, enforcing strictly-increasing counters. */
|
|
19
|
+
open(frame: Uint8Array<ArrayBuffer>): Promise<Uint8Array<ArrayBuffer>>;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=secure-channel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secure-channel.d.ts","sourceRoot":"","sources":["../../src/relay/secure-channel.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAC;AAoBzD;;;;GAIG;AACH,qBAAa,kBAAkB;IAC7B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAY;IACpC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAY;IACpC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA0B;IACrD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAA0B;IACrD,OAAO,CAAC,QAAQ,CAAC,GAAG,CAA0B;IAC9C,OAAO,CAAC,WAAW,CAAK;IACxB,OAAO,CAAC,eAAe,CAAM;gBAEjB,IAAI,EAAE,kBAAkB,EAAE,IAAI,EAAE,QAAQ,GAAG,QAAQ;IAe/D,mFAAmF;IAC7E,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;IAmBhF,0EAA0E;IACpE,IAAI,CAAC,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC;CAkB7E"}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// relay/secure-channel.ts
|
|
2
|
+
//
|
|
3
|
+
// The AEAD record layer that rides on top of a completed handshake. It turns the
|
|
4
|
+
// two directional AES-256-GCM keys into an ordered, replay-resistant stream of
|
|
5
|
+
// sealed frames. This is what actually makes the bytes the relay forwards
|
|
6
|
+
// opaque: application payloads never touch the wire except as GCM ciphertext.
|
|
7
|
+
//
|
|
8
|
+
// Frame layout (all binary, forwarded verbatim by the relay):
|
|
9
|
+
// [ counter : 8 bytes big-endian ][ AES-256-GCM ciphertext+tag ]
|
|
10
|
+
// The nonce is derived, never transmitted as free bytes: a 4-byte direction
|
|
11
|
+
// prefix plus the 8-byte counter. Counters strictly increase per direction, so
|
|
12
|
+
// a replayed or reordered frame is rejected — GCM never reuses a (key, nonce)
|
|
13
|
+
// pair, which is the one catastrophic failure mode for GCM.
|
|
14
|
+
import { aeadOpen, aeadSeal, RELAY_NONCE_BYTES } from './crypto.js';
|
|
15
|
+
import { GoodVibesSdkError } from '@pellux/goodvibes-errors';
|
|
16
|
+
const COUNTER_BYTES = 8;
|
|
17
|
+
const DIRECTION_PREFIX_BYTES = RELAY_NONCE_BYTES - COUNTER_BYTES; // 4
|
|
18
|
+
const PREFIX_CLIENT_TO_DAEMON = new Uint8Array([0x63, 0x32, 0x64, 0x00]); // "c2d\0"
|
|
19
|
+
const PREFIX_DAEMON_TO_CLIENT = new Uint8Array([0x64, 0x32, 0x63, 0x00]); // "d2c\0"
|
|
20
|
+
// Guard against counter exhaustion far below the 2^64 ceiling. In practice a
|
|
21
|
+
// channel is torn down long before this; hitting it is a hard error, never a
|
|
22
|
+
// silent wrap (which would reuse a nonce).
|
|
23
|
+
const MAX_COUNTER = 0xffff_ffff_ffff_ff00;
|
|
24
|
+
function nonceFor(prefix, counter) {
|
|
25
|
+
const nonce = new Uint8Array(RELAY_NONCE_BYTES);
|
|
26
|
+
nonce.set(prefix, 0);
|
|
27
|
+
const view = new DataView(nonce.buffer);
|
|
28
|
+
view.setBigUint64(DIRECTION_PREFIX_BYTES, BigInt(counter), false);
|
|
29
|
+
return nonce;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* An authenticated, ordered channel between one client and one daemon,
|
|
33
|
+
* established from a completed handshake. `role` selects which directional key
|
|
34
|
+
* is used for sending vs receiving.
|
|
35
|
+
*/
|
|
36
|
+
export class RelaySecureChannel {
|
|
37
|
+
sendKey;
|
|
38
|
+
recvKey;
|
|
39
|
+
sendPrefix;
|
|
40
|
+
recvPrefix;
|
|
41
|
+
aad;
|
|
42
|
+
sendCounter = 0;
|
|
43
|
+
lastRecvCounter = -1;
|
|
44
|
+
constructor(keys, role) {
|
|
45
|
+
if (role === 'client') {
|
|
46
|
+
this.sendKey = keys.clientToDaemonKey;
|
|
47
|
+
this.recvKey = keys.daemonToClientKey;
|
|
48
|
+
this.sendPrefix = PREFIX_CLIENT_TO_DAEMON;
|
|
49
|
+
this.recvPrefix = PREFIX_DAEMON_TO_CLIENT;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
this.sendKey = keys.daemonToClientKey;
|
|
53
|
+
this.recvKey = keys.clientToDaemonKey;
|
|
54
|
+
this.sendPrefix = PREFIX_DAEMON_TO_CLIENT;
|
|
55
|
+
this.recvPrefix = PREFIX_CLIENT_TO_DAEMON;
|
|
56
|
+
}
|
|
57
|
+
this.aad = keys.transcriptHash;
|
|
58
|
+
}
|
|
59
|
+
/** Seal an application payload into a wire frame (counter prefix + ciphertext). */
|
|
60
|
+
async seal(plaintext) {
|
|
61
|
+
if (this.sendCounter >= MAX_COUNTER) {
|
|
62
|
+
throw new GoodVibesSdkError('Relay secure channel counter exhausted; reconnect required.', {
|
|
63
|
+
category: 'protocol',
|
|
64
|
+
source: 'transport',
|
|
65
|
+
recoverable: true,
|
|
66
|
+
hint: 'Tear down and re-establish the relay pipe to rotate keys.',
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const counter = this.sendCounter;
|
|
70
|
+
this.sendCounter += 1;
|
|
71
|
+
const nonce = nonceFor(this.sendPrefix, counter);
|
|
72
|
+
const ciphertext = await aeadSeal(this.sendKey, nonce, plaintext, this.aad);
|
|
73
|
+
const frame = new Uint8Array(COUNTER_BYTES + ciphertext.length);
|
|
74
|
+
new DataView(frame.buffer).setBigUint64(0, BigInt(counter), false);
|
|
75
|
+
frame.set(ciphertext, COUNTER_BYTES);
|
|
76
|
+
return frame;
|
|
77
|
+
}
|
|
78
|
+
/** Open a received wire frame, enforcing strictly-increasing counters. */
|
|
79
|
+
async open(frame) {
|
|
80
|
+
if (frame.length <= COUNTER_BYTES) {
|
|
81
|
+
throw new GoodVibesSdkError('Truncated relay channel frame.', { category: 'protocol', source: 'transport', recoverable: false });
|
|
82
|
+
}
|
|
83
|
+
const counter = Number(new DataView(frame.buffer, frame.byteOffset, COUNTER_BYTES).getBigUint64(0, false));
|
|
84
|
+
if (counter <= this.lastRecvCounter) {
|
|
85
|
+
throw new GoodVibesSdkError('Relay channel frame replay or reorder detected.', {
|
|
86
|
+
category: 'protocol',
|
|
87
|
+
source: 'transport',
|
|
88
|
+
recoverable: false,
|
|
89
|
+
hint: 'Frame counter did not strictly increase; the channel integrity is compromised.',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const nonce = nonceFor(this.recvPrefix, counter);
|
|
93
|
+
const plaintext = await aeadOpen(this.recvKey, nonce, frame.subarray(COUNTER_BYTES), this.aad);
|
|
94
|
+
this.lastRecvCounter = counter;
|
|
95
|
+
return plaintext;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** A tunneled HTTP request (surface → daemon). */
|
|
2
|
+
export interface TunnelRequestHeader {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly kind: 'request';
|
|
5
|
+
readonly method: string;
|
|
6
|
+
/** Path + query only (no host); the daemon resolves it against its local base. */
|
|
7
|
+
readonly path: string;
|
|
8
|
+
readonly headers: ReadonlyArray<readonly [string, string]>;
|
|
9
|
+
}
|
|
10
|
+
/** A tunneled HTTP response (daemon → surface). */
|
|
11
|
+
export interface TunnelResponseHeader {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly kind: 'response';
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly headers: ReadonlyArray<readonly [string, string]>;
|
|
16
|
+
}
|
|
17
|
+
/** Open a live event subscription (surface → daemon). Shape mirrors a request. */
|
|
18
|
+
export interface TunnelStreamOpenHeader {
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly kind: 'stream-open';
|
|
21
|
+
readonly method: string;
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly headers: ReadonlyArray<readonly [string, string]>;
|
|
24
|
+
}
|
|
25
|
+
/** One chunk of a subscription's event bytes (daemon → surface). */
|
|
26
|
+
export interface TunnelStreamDataHeader {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly kind: 'stream-data';
|
|
29
|
+
/** Monotonic per-stream sequence number (gaps after an overflow notice are expected). */
|
|
30
|
+
readonly seq: number;
|
|
31
|
+
}
|
|
32
|
+
/** The bounded send buffer dropped chunks — an honest overflow notice (daemon → surface). */
|
|
33
|
+
export interface TunnelStreamOverflowHeader {
|
|
34
|
+
readonly id: string;
|
|
35
|
+
readonly kind: 'stream-overflow';
|
|
36
|
+
/** How many chunks were dropped since the previous notice. */
|
|
37
|
+
readonly dropped: number;
|
|
38
|
+
}
|
|
39
|
+
/** Clean subscription teardown (either direction). */
|
|
40
|
+
export interface TunnelStreamCloseHeader {
|
|
41
|
+
readonly id: string;
|
|
42
|
+
readonly kind: 'stream-close';
|
|
43
|
+
readonly reason?: string;
|
|
44
|
+
}
|
|
45
|
+
/** Any tunnel frame header. */
|
|
46
|
+
export type TunnelHeader = TunnelRequestHeader | TunnelResponseHeader | TunnelStreamOpenHeader | TunnelStreamDataHeader | TunnelStreamOverflowHeader | TunnelStreamCloseHeader;
|
|
47
|
+
/** Encode a tunnel header + body into a single frame for the secure channel. */
|
|
48
|
+
export declare function encodeTunnelFrame(header: TunnelHeader, body: Uint8Array<ArrayBuffer>): Uint8Array<ArrayBuffer>;
|
|
49
|
+
/** Decode a tunnel frame into its header and raw body. Returns null if malformed. */
|
|
50
|
+
export declare function decodeTunnelFrame(frame: Uint8Array<ArrayBuffer>): {
|
|
51
|
+
readonly header: TunnelHeader;
|
|
52
|
+
readonly body: Uint8Array<ArrayBuffer>;
|
|
53
|
+
} | null;
|
|
54
|
+
//# sourceMappingURL=tunnel.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tunnel.d.ts","sourceRoot":"","sources":["../../src/relay/tunnel.ts"],"names":[],"mappings":"AA2BA,kDAAkD;AAClD,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,kFAAkF;IAClF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC5D;AAED,mDAAmD;AACnD,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC5D;AAED,kFAAkF;AAClF,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;CAC5D;AAED,oEAAoE;AACpE,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,yFAAyF;IACzF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;CACtB;AAED,6FAA6F;AAC7F,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;IACjC,8DAA8D;IAC9D,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,sDAAsD;AACtD,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,+BAA+B;AAC/B,MAAM,MAAM,YAAY,GACpB,mBAAmB,GACnB,oBAAoB,GACpB,sBAAsB,GACtB,sBAAsB,GACtB,0BAA0B,GAC1B,uBAAuB,CAAC;AAE5B,gFAAgF;AAChF,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,UAAU,CAAC,WAAW,CAAC,GAAG,UAAU,CAAC,WAAW,CAAC,CAK9G;AAED,qFAAqF;AACrF,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,UAAU,CAAC,WAAW,CAAC,GAC7B;IAAE,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC,WAAW,CAAC,CAAA;CAAE,GAAG,IAAI,CAYlF"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// relay/tunnel.ts
|
|
2
|
+
//
|
|
3
|
+
// The application framing that rides inside the E2E secure channel: whole HTTP
|
|
4
|
+
// request/response pairs AND live event subscriptions, tunneled opaquely between
|
|
5
|
+
// a surface and the daemon. Because the operator protocol is contract-driven
|
|
6
|
+
// REST-over-JSON, tunneling HTTP is all it takes for the existing typed client
|
|
7
|
+
// to work unchanged over the relay — the client's transport just swaps its
|
|
8
|
+
// `fetch` for one that serializes the Request here, and the daemon replays it
|
|
9
|
+
// against its own route dispatcher.
|
|
10
|
+
//
|
|
11
|
+
// Frame layout (this is what the secure channel seals, never the relay's
|
|
12
|
+
// business):
|
|
13
|
+
// [ headerLen : 4 bytes BE ][ header JSON (UTF-8) ][ raw body bytes ]
|
|
14
|
+
// Keeping the body as raw bytes (not base64 inside JSON) avoids inflating
|
|
15
|
+
// payloads and keeps binary bodies exact.
|
|
16
|
+
//
|
|
17
|
+
// Streaming frames (a live subscription, e.g. Server-Sent Events) reuse the same
|
|
18
|
+
// sealed-frame layout and multiplex over the same pipe by a shared `id`:
|
|
19
|
+
// - `stream-open` surface → daemon: open a subscription (like `request`).
|
|
20
|
+
// - `stream-data` daemon → surface: one chunk of the event source's bytes.
|
|
21
|
+
// - `stream-overflow` daemon → surface: the bounded send buffer dropped N
|
|
22
|
+
// chunks — an honest notice, never a silent gap.
|
|
23
|
+
// - `stream-close` either direction: clean teardown (surface unsubscribes,
|
|
24
|
+
// or the daemon's source ended/errored).
|
|
25
|
+
import { concatBytes, decodeUtf8, encodeUtf8 } from './crypto.js';
|
|
26
|
+
/** Encode a tunnel header + body into a single frame for the secure channel. */
|
|
27
|
+
export function encodeTunnelFrame(header, body) {
|
|
28
|
+
const headerBytes = encodeUtf8(JSON.stringify(header));
|
|
29
|
+
const prefix = new Uint8Array(4);
|
|
30
|
+
new DataView(prefix.buffer).setUint32(0, headerBytes.length, false);
|
|
31
|
+
return concatBytes(prefix, headerBytes, body);
|
|
32
|
+
}
|
|
33
|
+
/** Decode a tunnel frame into its header and raw body. Returns null if malformed. */
|
|
34
|
+
export function decodeTunnelFrame(frame) {
|
|
35
|
+
if (frame.length < 4)
|
|
36
|
+
return null;
|
|
37
|
+
const headerLen = new DataView(frame.buffer, frame.byteOffset, 4).getUint32(0, false);
|
|
38
|
+
if (frame.length < 4 + headerLen)
|
|
39
|
+
return null;
|
|
40
|
+
let header;
|
|
41
|
+
try {
|
|
42
|
+
header = JSON.parse(decodeUtf8(frame.subarray(4, 4 + headerLen)));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (!isTunnelHeader(header))
|
|
48
|
+
return null;
|
|
49
|
+
return { header, body: frame.subarray(4 + headerLen).slice() };
|
|
50
|
+
}
|
|
51
|
+
function isTunnelHeader(value) {
|
|
52
|
+
if (typeof value !== 'object' || value === null)
|
|
53
|
+
return false;
|
|
54
|
+
const v = value;
|
|
55
|
+
if (typeof v['id'] !== 'string')
|
|
56
|
+
return false;
|
|
57
|
+
if (v['kind'] === 'request' || v['kind'] === 'stream-open') {
|
|
58
|
+
return Array.isArray(v['headers']) && typeof v['method'] === 'string' && typeof v['path'] === 'string';
|
|
59
|
+
}
|
|
60
|
+
if (v['kind'] === 'response')
|
|
61
|
+
return Array.isArray(v['headers']) && typeof v['status'] === 'number';
|
|
62
|
+
if (v['kind'] === 'stream-data')
|
|
63
|
+
return typeof v['seq'] === 'number';
|
|
64
|
+
if (v['kind'] === 'stream-overflow')
|
|
65
|
+
return typeof v['dropped'] === 'number';
|
|
66
|
+
if (v['kind'] === 'stream-close')
|
|
67
|
+
return v['reason'] === undefined || typeof v['reason'] === 'string';
|
|
68
|
+
return false;
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-transport-core",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"bun": "1.3.10",
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"types": "./dist/observer.d.ts",
|
|
43
43
|
"import": "./dist/observer.js"
|
|
44
44
|
},
|
|
45
|
+
"./relay": {
|
|
46
|
+
"types": "./dist/relay/index.d.ts",
|
|
47
|
+
"import": "./dist/relay/index.js"
|
|
48
|
+
},
|
|
45
49
|
"./otel": {
|
|
46
50
|
"types": "./dist/otel.d.ts",
|
|
47
51
|
"import": "./dist/otel.js"
|
|
@@ -57,7 +61,7 @@
|
|
|
57
61
|
],
|
|
58
62
|
"sideEffects": false,
|
|
59
63
|
"dependencies": {
|
|
60
|
-
"@pellux/goodvibes-errors": "1.
|
|
64
|
+
"@pellux/goodvibes-errors": "1.7.0"
|
|
61
65
|
},
|
|
62
66
|
"license": "MIT",
|
|
63
67
|
"repository": {
|