replica-bridge 0.0.0-stage → 0.2.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/README.md +13 -2
- package/package.json +15 -4
- package/src/bridge/command.js +28 -0
- package/src/bridge/enroll.js +39 -0
- package/src/bridge/install.js +253 -0
- package/src/bridge/main.js +237 -0
- package/src/bridge/pair.js +137 -0
- package/src/bridge/service.js +319 -0
- package/src/bridge/state.js +44 -0
- package/src/http.js +32 -0
- package/src/protocol.js +262 -0
- package/src/relay/config.js +76 -0
package/src/protocol.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
// Shared protocol for replica-relay, replica-bridge and the test phone (Relay/PROTOCOL.md).
|
|
2
|
+
// Everything here is plain Node: X25519, HKDF-SHA256, ChaCha20-Poly1305 and HMAC-SHA256 match CryptoKit's
|
|
3
|
+
// Curve25519.KeyAgreement, HKDF<SHA256>, ChaChaPoly and HMAC<SHA256>, so the Swift side can follow the same steps.
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
|
|
6
|
+
export const PROTOCOL = "chatreplica-relay-v1";
|
|
7
|
+
export const LINK_SCHEME = "chatreplica:";
|
|
8
|
+
/** Pairing codes avoid 0/O, 1/I/L and U so they survive being read aloud or typed. */
|
|
9
|
+
export const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
10
|
+
export const CODE_LENGTH = 8;
|
|
11
|
+
export const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
export const b64url = (data ) => Buffer.from(data).toString("base64url");
|
|
14
|
+
export const fromB64url = (text ) => Buffer.from(text, "base64url");
|
|
15
|
+
export const sha256 = (...parts ) => {
|
|
16
|
+
const hash = crypto.createHash("sha256");
|
|
17
|
+
for (const part of parts) hash.update(part);
|
|
18
|
+
return hash.digest();
|
|
19
|
+
};
|
|
20
|
+
export const randomToken = () => b64url(crypto.randomBytes(32));
|
|
21
|
+
export const tokenHash = (token ) => sha256(Buffer.from(token, "utf8")).toString("hex");
|
|
22
|
+
|
|
23
|
+
export function randomCode() {
|
|
24
|
+
let code = "";
|
|
25
|
+
while (code.length < CODE_LENGTH) {
|
|
26
|
+
const byte = crypto.randomBytes(1)[0];
|
|
27
|
+
if (byte < 240) code += CODE_ALPHABET[byte % CODE_ALPHABET.length]; // 240 = 8 * 30, no modulo bias
|
|
28
|
+
}
|
|
29
|
+
return code;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Uppercases and drops spaces and dashes, so "abcd-2345" and "ABCD 2345" find the same pairing. */
|
|
33
|
+
export const normalizeCode = (code ) => code.toUpperCase().replace(/[\s-]/g, "");
|
|
34
|
+
export const formatCode = (code ) => `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
35
|
+
|
|
36
|
+
// MARK: - X25519 keys as raw 32-byte values (what CryptoKit's rawRepresentation holds)
|
|
37
|
+
|
|
38
|
+
const PKCS8_X25519_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
|
|
39
|
+
const SPKI_X25519_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
export function generateKeyPair() {
|
|
44
|
+
return keyPairFromPrivate(crypto.randomBytes(32));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function keyPairFromPrivate(privateKey ) {
|
|
48
|
+
const key = crypto.createPrivateKey({ key: Buffer.concat([PKCS8_X25519_PREFIX, privateKey]), format: "der", type: "pkcs8" });
|
|
49
|
+
const spki = crypto.createPublicKey(key).export({ format: "der", type: "spki" });
|
|
50
|
+
return { privateKey, publicKey: Buffer.from(spki.subarray(SPKI_X25519_PREFIX.length)) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function dh(privateKey , publicKey ) {
|
|
54
|
+
if (publicKey.length !== 32) throw new Error("X25519 public keys are 32 bytes");
|
|
55
|
+
return crypto.diffieHellman({
|
|
56
|
+
privateKey: crypto.createPrivateKey({ key: Buffer.concat([PKCS8_X25519_PREFIX, privateKey]), format: "der", type: "pkcs8" }),
|
|
57
|
+
publicKey: crypto.createPublicKey({ key: Buffer.concat([SPKI_X25519_PREFIX, publicKey]), format: "der", type: "spki" }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// MARK: - Pairing
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
/** chatreplica://pair?v=1&r=<relay URL>&h=<hostId>&k=<host key>&c=<code>&s=<secret>&n=<host name> */
|
|
73
|
+
export function buildPairingLink(link ) {
|
|
74
|
+
const params = new URLSearchParams({ v: "1", r: link.relay, h: link.hostId, k: b64url(link.hostPublicKey), c: link.code, s: b64url(link.secret) });
|
|
75
|
+
if (link.hostName) params.set("n", link.hostName);
|
|
76
|
+
return `chatreplica://pair?${params}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parsePairingLink(text ) {
|
|
80
|
+
const url = new URL(text.trim());
|
|
81
|
+
if (url.protocol !== LINK_SCHEME || url.hostname !== "pair") throw new Error("not a chatreplica://pair link");
|
|
82
|
+
const get = (name ) => {
|
|
83
|
+
const value = url.searchParams.get(name);
|
|
84
|
+
if (!value) throw new Error(`pairing link is missing ${name}`);
|
|
85
|
+
return value;
|
|
86
|
+
};
|
|
87
|
+
if (get("v") !== "1") throw new Error("unsupported pairing link version");
|
|
88
|
+
const relay = new URL(get("r"));
|
|
89
|
+
if (relay.protocol !== "https:" && relay.protocol !== "http:") throw new Error("relay must be an http(s) URL");
|
|
90
|
+
const hostPublicKey = fromB64url(get("k"));
|
|
91
|
+
const secret = fromB64url(get("s"));
|
|
92
|
+
if (hostPublicKey.length !== 32 || secret.length !== 32) throw new Error("pairing link key or secret has the wrong length");
|
|
93
|
+
return { relay: get("r"), hostId: get("h"), hostPublicKey, code: normalizeCode(get("c")), secret, hostName: url.searchParams.get("n") ?? undefined };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** What the relay keeps instead of the secret, so it cannot compute the proof itself. */
|
|
97
|
+
export const secretHash = (secret ) => b64url(sha256(secret));
|
|
98
|
+
|
|
99
|
+
/** HMAC-SHA256(secret, PROTOCOL "|pair|" code devicePublicKey): proves the phone saw the QR code. */
|
|
100
|
+
export function pairingProof(secret , code , devicePublicKey ) {
|
|
101
|
+
return crypto.createHmac("sha256", secret).update(`${PROTOCOL}|pair|${normalizeCode(code)}`).update(devicePublicKey).digest();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function verifyPairingProof(secret , code , devicePublicKey , proof ) {
|
|
105
|
+
const expected = pairingProof(secret, code, devicePublicKey);
|
|
106
|
+
return proof.length === expected.length && crypto.timingSafeEqual(proof, expected);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Six digits both screens show when pairing by typed code, where the phone got the host key from the relay. */
|
|
110
|
+
export function safetyCode(hostPublicKey , devicePublicKey ) {
|
|
111
|
+
const digest = sha256(Buffer.from(`${PROTOCOL}|safety`), hostPublicKey, devicePublicKey);
|
|
112
|
+
return String(digest.readUInt32BE(0) % 1_000_000).padStart(6, "0");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// MARK: - Session handshake and frames
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Both static keys and both ephemeral keys go into the transcript; the shared secret mixes ee, es and se, so only the
|
|
126
|
+
* paired phone (device static key) talking to the real host (host static key) derives the same keys.
|
|
127
|
+
*/
|
|
128
|
+
export function deriveSessionKeys(args
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
) {
|
|
132
|
+
const transcript = sha256(Buffer.from(PROTOCOL), args.hostStatic, args.deviceStatic, args.deviceEphemeral, args.hostEphemeral);
|
|
133
|
+
const okm = Buffer.from(crypto.hkdfSync("sha256", Buffer.concat([args.ee, args.es, args.se]), transcript, Buffer.from(`${PROTOCOL}|keys`), 64));
|
|
134
|
+
return { transcript, deviceToHost: okm.subarray(0, 32), hostToDevice: okm.subarray(32, 64) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The phone's side: ee = DH(De, He), es = DH(De, Hs), se = DH(Ds, He). */
|
|
138
|
+
export function deviceSessionKeys(device , deviceEphemeral , hostStatic , hostEphemeral ) {
|
|
139
|
+
return deriveSessionKeys({
|
|
140
|
+
hostStatic, deviceStatic: device.publicKey, deviceEphemeral: deviceEphemeral.publicKey, hostEphemeral,
|
|
141
|
+
ee: dh(deviceEphemeral.privateKey, hostEphemeral), es: dh(deviceEphemeral.privateKey, hostStatic), se: dh(device.privateKey, hostEphemeral),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The host's side of the same three DH values. */
|
|
146
|
+
export function hostSessionKeys(host , hostEphemeral , deviceStatic , deviceEphemeral ) {
|
|
147
|
+
return deriveSessionKeys({
|
|
148
|
+
hostStatic: host.publicKey, deviceStatic, deviceEphemeral, hostEphemeral: hostEphemeral.publicKey,
|
|
149
|
+
ee: dh(hostEphemeral.privateKey, deviceEphemeral), es: dh(host.privateKey, deviceEphemeral), se: dh(hostEphemeral.privateKey, deviceStatic),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 96-bit nonce: four zero bytes, then the frame counter as a big-endian UInt64. */
|
|
154
|
+
export function frameNonce(counter ) {
|
|
155
|
+
const nonce = Buffer.alloc(12);
|
|
156
|
+
nonce.writeBigUInt64BE(BigInt(counter), 4);
|
|
157
|
+
return nonce;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function seal(key , counter , aad , plaintext ) {
|
|
161
|
+
const cipher = crypto.createCipheriv("chacha20-poly1305", key, frameNonce(counter), { authTagLength: 16 });
|
|
162
|
+
cipher.setAAD(aad, { plaintextLength: plaintext.length });
|
|
163
|
+
return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function open(key , counter , aad , sealed ) {
|
|
167
|
+
if (sealed.length < 16) throw new Error("frame too short");
|
|
168
|
+
const decipher = crypto.createDecipheriv("chacha20-poly1305", key, frameNonce(counter), { authTagLength: 16 });
|
|
169
|
+
decipher.setAAD(aad, { plaintextLength: sealed.length - 16 });
|
|
170
|
+
decipher.setAuthTag(sealed.subarray(sealed.length - 16));
|
|
171
|
+
return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** One direction of an established session: counts frames and refuses gaps or replays. */
|
|
175
|
+
export class FrameCipher {
|
|
176
|
+
sendCounter = 0;
|
|
177
|
+
receiveCounter = 0;
|
|
178
|
+
sendKey ;
|
|
179
|
+
receiveKey ;
|
|
180
|
+
aad ;
|
|
181
|
+
constructor(sendKey , receiveKey , aad ) { this.sendKey = sendKey; this.receiveKey = receiveKey; this.aad = aad; }
|
|
182
|
+
|
|
183
|
+
sealFrame(message ) {
|
|
184
|
+
const n = this.sendCounter++;
|
|
185
|
+
return { type: "data", n, c: b64url(seal(this.sendKey, n, this.aad, Buffer.from(JSON.stringify(message), "utf8"))) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
openFrame(frame ) {
|
|
189
|
+
if (frame.n !== this.receiveCounter) throw new Error(`frame ${frame.n} out of order (expected ${this.receiveCounter})`);
|
|
190
|
+
const plaintext = open(this.receiveKey, frame.n, this.aad, fromB64url(frame.c));
|
|
191
|
+
this.receiveCounter++;
|
|
192
|
+
return JSON.parse(plaintext.toString("utf8")) ;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** What travels between the phone and the host through the relay; the relay only sees these. */
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
/** What is inside a data frame. */
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
/** Relay ↔ host multiplexing: one host WebSocket carries every phone connection. */
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
/** WebSocket close codes the relay and the bridge use. */
|
|
218
|
+
export const CloseCode = {
|
|
219
|
+
hostOffline: 4404,
|
|
220
|
+
revoked: 4403,
|
|
221
|
+
replaced: 4409,
|
|
222
|
+
protocolError: 4400,
|
|
223
|
+
streamGone: 4410,
|
|
224
|
+
} ;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Keeps one direction of a stream's messages until the peer acknowledges them, so a reconnect can resend exactly
|
|
228
|
+
* what was lost. Sequence numbers start at 1; `received` is the highest contiguous seq taken from the peer.
|
|
229
|
+
*/
|
|
230
|
+
export class StreamBuffer {
|
|
231
|
+
nextSeq = 1;
|
|
232
|
+
received = 0;
|
|
233
|
+
unacked = [];
|
|
234
|
+
bytes = 0;
|
|
235
|
+
|
|
236
|
+
maxBytes ;
|
|
237
|
+
constructor(maxBytes = 32 * 1024 * 1024) { this.maxBytes = maxBytes; }
|
|
238
|
+
|
|
239
|
+
push(line ) {
|
|
240
|
+
const item = { seq: this.nextSeq++, line, size: Buffer.byteLength(line) };
|
|
241
|
+
this.unacked.push(item);
|
|
242
|
+
this.bytes += item.size;
|
|
243
|
+
if (this.bytes > this.maxBytes) throw new Error("unacknowledged buffer full");
|
|
244
|
+
return item;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
ack(seq ) {
|
|
248
|
+
while (this.unacked.length && this.unacked[0].seq <= seq) this.bytes -= this.unacked.shift() .size;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
pending(after ) {
|
|
252
|
+
return this.unacked.filter((item) => item.seq > after);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** True when the line is new; duplicates from a resend are dropped. Gaps cannot happen on one ordered socket. */
|
|
256
|
+
accept(seq ) {
|
|
257
|
+
if (seq <= this.received) return false;
|
|
258
|
+
if (seq !== this.received + 1) throw new Error(`message ${seq} skips ahead of ${this.received}`);
|
|
259
|
+
this.received = seq;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Reads ~/.config/replica-relay/relay.env (Relay/deploy/relay.env.example). Real environment variables win over
|
|
2
|
+
// the file, so a systemd Environment= line or a one-off `RELAY_PORT=… node …` still works.
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
/** RELAY_ENV points somewhere else (the quick-tunnel script and the tests use it). */
|
|
8
|
+
export const defaultEnvPath = () => process.env.RELAY_ENV ?? path.join(os.homedir(), ".config", "replica-relay", "relay.env");
|
|
9
|
+
|
|
10
|
+
export function readEnvFile(file ) {
|
|
11
|
+
let text ;
|
|
12
|
+
try {
|
|
13
|
+
text = fs.readFileSync(file, "utf8");
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if ((error ).code === "ENOENT") return {};
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
const values = {};
|
|
19
|
+
for (const raw of text.split("\n")) {
|
|
20
|
+
const line = raw.trim();
|
|
21
|
+
if (!line || line.startsWith("#")) continue;
|
|
22
|
+
const eq = line.indexOf("=");
|
|
23
|
+
if (eq < 1) continue;
|
|
24
|
+
let value = line.slice(eq + 1).trim();
|
|
25
|
+
if (/^(".*"|'.*')$/.test(value)) value = value.slice(1, -1);
|
|
26
|
+
values[line.slice(0, eq).trim()] = value;
|
|
27
|
+
}
|
|
28
|
+
return values;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
/** "github:583231, 12345, *" → ["583231", "12345", "*"]; anything that is not a numeric id or * is refused. */
|
|
46
|
+
export function parseAllowedIds(text ) {
|
|
47
|
+
return text.split(",").map((item) => item.trim()).filter(Boolean).map((item) => {
|
|
48
|
+
const id = item.replace(/^github:/, "");
|
|
49
|
+
if (id !== "*" && !/^[0-9]{1,20}$/.test(id)) throw new Error(`GITHUB_ALLOWED_USERS takes numeric GitHub ids (github:123) or *, not "${item}"`);
|
|
50
|
+
return id;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const expandHome = (value ) => (value.startsWith("~/") ? path.join(os.homedir(), value.slice(2)) : value);
|
|
55
|
+
|
|
56
|
+
export function loadRelayConfig(envPath = defaultEnvPath()) {
|
|
57
|
+
const file = readEnvFile(envPath);
|
|
58
|
+
const get = (name ) => {
|
|
59
|
+
const value = process.env[name] ?? file[name];
|
|
60
|
+
return value === undefined || value === "" ? null : value;
|
|
61
|
+
};
|
|
62
|
+
const origin = get("RELAY_PUBLIC_ORIGIN");
|
|
63
|
+
if (origin && !/^https?:\/\/[^/]+\/?$/.test(origin)) throw new Error(`RELAY_PUBLIC_ORIGIN must look like https://host[:port], got ${origin}`);
|
|
64
|
+
return {
|
|
65
|
+
listen: get("RELAY_LISTEN") ?? "127.0.0.1",
|
|
66
|
+
port: Number(get("RELAY_PORT") ?? 8790),
|
|
67
|
+
publicOrigin: origin?.replace(/\/$/, "") ?? null,
|
|
68
|
+
trustProxy: get("RELAY_TRUST_PROXY") === "1",
|
|
69
|
+
dataDir: expandHome(get("RELAY_DATA_DIR") ?? "~/.local/share/replica-relay"),
|
|
70
|
+
devLogin: get("RELAY_DEV_LOGIN") === "1",
|
|
71
|
+
githubClientId: get("GITHUB_CLIENT_ID"),
|
|
72
|
+
githubClientSecret: get("GITHUB_CLIENT_SECRET"),
|
|
73
|
+
githubCallbackUrl: get("GITHUB_CALLBACK_URL"),
|
|
74
|
+
githubAllowedIds: parseAllowedIds(get("GITHUB_ALLOWED_USERS") ?? ""),
|
|
75
|
+
};
|
|
76
|
+
}
|