@floegence/flowersec-core 0.1.1
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/LICENSE +22 -0
- package/README.md +42 -0
- package/YAMUX_ALIGNMENT.md +127 -0
- package/dist/_examples/flowersec/demo/v1.facade.gen.d.ts +12 -0
- package/dist/_examples/flowersec/demo/v1.facade.gen.js +15 -0
- package/dist/_examples/flowersec/demo/v1.gen.d.ts +16 -0
- package/dist/_examples/flowersec/demo/v1.gen.js +86 -0
- package/dist/_examples/flowersec/demo/v1.rpc.gen.d.ts +11 -0
- package/dist/_examples/flowersec/demo/v1.rpc.gen.js +22 -0
- package/dist/browser/connect.d.ts +12 -0
- package/dist/browser/connect.js +31 -0
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +1 -0
- package/dist/client-connect/common.d.ts +26 -0
- package/dist/client-connect/common.js +167 -0
- package/dist/client-connect/connectCore.d.ts +42 -0
- package/dist/client-connect/connectCore.js +302 -0
- package/dist/client-connect/tunnelAttachCloseReason.d.ts +3 -0
- package/dist/client-connect/tunnelAttachCloseReason.js +16 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.js +1 -0
- package/dist/direct-client/connect.d.ts +4 -0
- package/dist/direct-client/connect.js +67 -0
- package/dist/direct-client/index.d.ts +1 -0
- package/dist/direct-client/index.js +1 -0
- package/dist/e2ee/constants.d.ts +9 -0
- package/dist/e2ee/constants.js +18 -0
- package/dist/e2ee/errors.d.ts +5 -0
- package/dist/e2ee/errors.js +8 -0
- package/dist/e2ee/framing.d.ts +12 -0
- package/dist/e2ee/framing.js +57 -0
- package/dist/e2ee/handshake.d.ts +80 -0
- package/dist/e2ee/handshake.js +322 -0
- package/dist/e2ee/index.d.ts +7 -0
- package/dist/e2ee/index.js +7 -0
- package/dist/e2ee/kdf.d.ts +15 -0
- package/dist/e2ee/kdf.js +39 -0
- package/dist/e2ee/record.d.ts +11 -0
- package/dist/e2ee/record.js +69 -0
- package/dist/e2ee/secureChannel.d.ts +82 -0
- package/dist/e2ee/secureChannel.js +265 -0
- package/dist/e2ee/transcript.d.ts +23 -0
- package/dist/e2ee/transcript.js +31 -0
- package/dist/facade.d.ts +21 -0
- package/dist/facade.js +61 -0
- package/dist/gen/flowersec/controlplane/v1.gen.d.ts +36 -0
- package/dist/gen/flowersec/controlplane/v1.gen.js +135 -0
- package/dist/gen/flowersec/direct/v1.gen.d.ts +21 -0
- package/dist/gen/flowersec/direct/v1.gen.js +101 -0
- package/dist/gen/flowersec/e2ee/v1.gen.d.ts +68 -0
- package/dist/gen/flowersec/e2ee/v1.gen.js +194 -0
- package/dist/gen/flowersec/rpc/v1.gen.d.ts +30 -0
- package/dist/gen/flowersec/rpc/v1.gen.js +107 -0
- package/dist/gen/flowersec/tunnel/v1.gen.d.ts +23 -0
- package/dist/gen/flowersec/tunnel/v1.gen.js +104 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/node/connect.d.ts +9 -0
- package/dist/node/connect.js +13 -0
- package/dist/node/index.d.ts +2 -0
- package/dist/node/index.js +2 -0
- package/dist/node/wsFactory.d.ts +2 -0
- package/dist/node/wsFactory.js +69 -0
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/observability/observer.d.ts +23 -0
- package/dist/observability/observer.js +28 -0
- package/dist/rpc/callError.d.ts +5 -0
- package/dist/rpc/callError.js +11 -0
- package/dist/rpc/caller.d.ts +8 -0
- package/dist/rpc/caller.js +1 -0
- package/dist/rpc/client.d.ts +22 -0
- package/dist/rpc/client.js +170 -0
- package/dist/rpc/framing.d.ts +4 -0
- package/dist/rpc/framing.js +24 -0
- package/dist/rpc/index.d.ts +6 -0
- package/dist/rpc/index.js +6 -0
- package/dist/rpc/server.d.ts +15 -0
- package/dist/rpc/server.js +67 -0
- package/dist/rpc/typed.d.ts +5 -0
- package/dist/rpc/typed.js +9 -0
- package/dist/rpc/validate.d.ts +2 -0
- package/dist/rpc/validate.js +27 -0
- package/dist/rpc-proxy/index.d.ts +1 -0
- package/dist/rpc-proxy/index.js +1 -0
- package/dist/rpc-proxy/rpcProxy.d.ts +13 -0
- package/dist/rpc-proxy/rpcProxy.js +59 -0
- package/dist/streamhello/index.d.ts +1 -0
- package/dist/streamhello/index.js +1 -0
- package/dist/streamhello/streamHello.d.ts +3 -0
- package/dist/streamhello/streamHello.js +13 -0
- package/dist/tunnel-client/connect.d.ts +7 -0
- package/dist/tunnel-client/connect.js +125 -0
- package/dist/tunnel-client/index.d.ts +1 -0
- package/dist/tunnel-client/index.js +1 -0
- package/dist/utils/base64url.d.ts +2 -0
- package/dist/utils/base64url.js +40 -0
- package/dist/utils/bin.d.ts +6 -0
- package/dist/utils/bin.js +55 -0
- package/dist/utils/errors.d.ts +26 -0
- package/dist/utils/errors.js +42 -0
- package/dist/utils/number.d.ts +2 -0
- package/dist/utils/number.js +9 -0
- package/dist/ws/index.d.ts +1 -0
- package/dist/ws/index.js +1 -0
- package/dist/ws-client/binaryTransport.d.ts +49 -0
- package/dist/ws-client/binaryTransport.js +301 -0
- package/dist/yamux/byteReader.d.ts +10 -0
- package/dist/yamux/byteReader.js +50 -0
- package/dist/yamux/constants.d.ts +10 -0
- package/dist/yamux/constants.js +14 -0
- package/dist/yamux/header.d.ts +17 -0
- package/dist/yamux/header.js +26 -0
- package/dist/yamux/index.d.ts +5 -0
- package/dist/yamux/index.js +5 -0
- package/dist/yamux/session.d.ts +44 -0
- package/dist/yamux/session.js +228 -0
- package/dist/yamux/stream.d.ts +30 -0
- package/dist/yamux/stream.js +222 -0
- package/package.json +112 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { x25519 } from "@noble/curves/ed25519";
|
|
2
|
+
import { p256 } from "@noble/curves/p256";
|
|
3
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
4
|
+
import { base64urlDecode, base64urlEncode } from "../utils/base64url.js";
|
|
5
|
+
import { HANDSHAKE_TYPE_ACK, HANDSHAKE_TYPE_INIT, HANDSHAKE_TYPE_RESP, PROTOCOL_VERSION, RECORD_FLAG_PING } from "./constants.js";
|
|
6
|
+
import { encodeHandshakeFrame, decodeHandshakeFrame } from "./framing.js";
|
|
7
|
+
import { computeAuthTag, deriveSessionKeys } from "./kdf.js";
|
|
8
|
+
import { transcriptHash } from "./transcript.js";
|
|
9
|
+
import { decryptRecord, encryptRecord } from "./record.js";
|
|
10
|
+
import { SecureChannel } from "./secureChannel.js";
|
|
11
|
+
import { E2EEHandshakeError } from "./errors.js";
|
|
12
|
+
import { TimeoutError, throwIfAborted } from "../utils/errors.js";
|
|
13
|
+
const te = new TextEncoder();
|
|
14
|
+
const td = new TextDecoder();
|
|
15
|
+
function handshakeDeadlineMs(timeoutMs) {
|
|
16
|
+
const ms = Math.max(0, timeoutMs ?? 10_000);
|
|
17
|
+
if (ms <= 0)
|
|
18
|
+
return null;
|
|
19
|
+
return Date.now() + ms;
|
|
20
|
+
}
|
|
21
|
+
function ioReadOpts(signal, deadlineMs) {
|
|
22
|
+
throwIfAborted(signal, "handshake aborted");
|
|
23
|
+
if (deadlineMs == null)
|
|
24
|
+
return signal != null ? { signal } : {};
|
|
25
|
+
const remaining = deadlineMs - Date.now();
|
|
26
|
+
if (remaining <= 0)
|
|
27
|
+
throw new TimeoutError("handshake timeout");
|
|
28
|
+
return signal != null ? { signal, timeoutMs: remaining } : { timeoutMs: remaining };
|
|
29
|
+
}
|
|
30
|
+
function ioWriteOpts(signal) {
|
|
31
|
+
throwIfAborted(signal, "handshake aborted");
|
|
32
|
+
return signal != null ? { signal } : {};
|
|
33
|
+
}
|
|
34
|
+
function randomBytes(n) {
|
|
35
|
+
const out = new Uint8Array(n);
|
|
36
|
+
crypto.getRandomValues(out);
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
// suiteKeypair generates a per-handshake ECDH keypair.
|
|
40
|
+
function suiteKeypair(suite) {
|
|
41
|
+
if (suite === 1) {
|
|
42
|
+
const priv = x25519.utils.randomPrivateKey();
|
|
43
|
+
const pub = x25519.getPublicKey(priv);
|
|
44
|
+
return { priv, pub };
|
|
45
|
+
}
|
|
46
|
+
if (suite === 2) {
|
|
47
|
+
const priv = p256.utils.randomPrivateKey();
|
|
48
|
+
const pub = p256.getPublicKey(priv, false);
|
|
49
|
+
return { priv, pub };
|
|
50
|
+
}
|
|
51
|
+
throw new Error(`unsupported suite ${suite}`);
|
|
52
|
+
}
|
|
53
|
+
// suiteSharedSecret computes the ECDH shared secret for the suite.
|
|
54
|
+
function suiteSharedSecret(suite, priv, peerPub) {
|
|
55
|
+
if (suite === 1)
|
|
56
|
+
return x25519.getSharedSecret(priv, peerPub);
|
|
57
|
+
if (suite === 2) {
|
|
58
|
+
// P-256 uses the x-coordinate (32 bytes) to align with Go's crypto/ecdh output.
|
|
59
|
+
const shared = p256.getSharedSecret(priv, peerPub, false);
|
|
60
|
+
if (shared.length !== 65 || shared[0] !== 4)
|
|
61
|
+
throw new Error("invalid P-256 shared secret encoding");
|
|
62
|
+
return shared.slice(1, 33);
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`unsupported suite ${suite}`);
|
|
65
|
+
}
|
|
66
|
+
function fingerprintInit(init) {
|
|
67
|
+
// Canonicalize to avoid JSON key-order affecting the cache key.
|
|
68
|
+
const canonical = {
|
|
69
|
+
channel_id: init.channel_id,
|
|
70
|
+
role: init.role,
|
|
71
|
+
version: init.version,
|
|
72
|
+
suite: init.suite,
|
|
73
|
+
client_eph_pub_b64u: init.client_eph_pub_b64u,
|
|
74
|
+
nonce_c_b64u: init.nonce_c_b64u,
|
|
75
|
+
client_features: init.client_features
|
|
76
|
+
};
|
|
77
|
+
const b = te.encode(JSON.stringify(canonical));
|
|
78
|
+
const sum = sha256(b);
|
|
79
|
+
// Full 32-byte fingerprint (aligns with Go's sha256-based fingerprinting).
|
|
80
|
+
return base64urlEncode(sum);
|
|
81
|
+
}
|
|
82
|
+
// clientHandshake performs the client side of the E2EE handshake.
|
|
83
|
+
export async function clientHandshake(transport, opts) {
|
|
84
|
+
if (opts.psk.length !== 32)
|
|
85
|
+
throw new Error("psk must be 32 bytes");
|
|
86
|
+
if (opts.channelId === "")
|
|
87
|
+
throw new Error("missing channel_id");
|
|
88
|
+
const deadlineMs = handshakeDeadlineMs(opts.timeoutMs);
|
|
89
|
+
const kp = suiteKeypair(opts.suite);
|
|
90
|
+
const nonceC = randomBytes(32);
|
|
91
|
+
const init = {
|
|
92
|
+
channel_id: opts.channelId,
|
|
93
|
+
role: 1,
|
|
94
|
+
version: PROTOCOL_VERSION,
|
|
95
|
+
suite: opts.suite,
|
|
96
|
+
client_eph_pub_b64u: base64urlEncode(kp.pub),
|
|
97
|
+
nonce_c_b64u: base64urlEncode(nonceC),
|
|
98
|
+
client_features: opts.clientFeatures >>> 0
|
|
99
|
+
};
|
|
100
|
+
const initJson = te.encode(JSON.stringify(init));
|
|
101
|
+
await transport.writeBinary(encodeHandshakeFrame(HANDSHAKE_TYPE_INIT, initJson), ioWriteOpts(opts.signal));
|
|
102
|
+
// Read server response with ephemeral key and nonce.
|
|
103
|
+
const respFrame = await transport.readBinary(ioReadOpts(opts.signal, deadlineMs));
|
|
104
|
+
const decoded = decodeHandshakeFrame(respFrame, opts.maxHandshakePayload);
|
|
105
|
+
if (decoded.handshakeType !== HANDSHAKE_TYPE_RESP)
|
|
106
|
+
throw new Error("unexpected handshake type");
|
|
107
|
+
const resp = JSON.parse(td.decode(decoded.payloadJsonUtf8));
|
|
108
|
+
if (resp.handshake_id == null || resp.handshake_id === "")
|
|
109
|
+
throw new Error("missing handshake_id");
|
|
110
|
+
if (resp.server_eph_pub_b64u == null || resp.server_eph_pub_b64u === "")
|
|
111
|
+
throw new Error("missing server_eph_pub_b64u");
|
|
112
|
+
if (resp.nonce_s_b64u == null || resp.nonce_s_b64u === "")
|
|
113
|
+
throw new Error("missing nonce_s_b64u");
|
|
114
|
+
const serverPub = base64urlDecode(resp.server_eph_pub_b64u);
|
|
115
|
+
const nonceS = base64urlDecode(resp.nonce_s_b64u);
|
|
116
|
+
if (nonceS.length !== 32)
|
|
117
|
+
throw new Error("bad nonce_s length");
|
|
118
|
+
if (opts.suite === 1 && serverPub.length !== 32)
|
|
119
|
+
throw new Error("bad server eph pub length");
|
|
120
|
+
if (opts.suite === 2 && serverPub.length !== 65)
|
|
121
|
+
throw new Error("bad server eph pub length");
|
|
122
|
+
const th = transcriptHash({
|
|
123
|
+
version: PROTOCOL_VERSION,
|
|
124
|
+
suite: opts.suite,
|
|
125
|
+
role: 1,
|
|
126
|
+
clientFeatures: init.client_features,
|
|
127
|
+
serverFeatures: resp.server_features >>> 0,
|
|
128
|
+
channelId: opts.channelId,
|
|
129
|
+
nonceC,
|
|
130
|
+
nonceS,
|
|
131
|
+
clientEphPub: kp.pub,
|
|
132
|
+
serverEphPub: serverPub
|
|
133
|
+
});
|
|
134
|
+
const shared = suiteSharedSecret(opts.suite, kp.priv, serverPub);
|
|
135
|
+
const keys = deriveSessionKeys(opts.psk, shared, th);
|
|
136
|
+
const ts = BigInt(Math.floor(Date.now() / 1000));
|
|
137
|
+
const tag = computeAuthTag(opts.psk, th, ts);
|
|
138
|
+
const ack = {
|
|
139
|
+
handshake_id: resp.handshake_id,
|
|
140
|
+
timestamp_unix_s: Number(ts),
|
|
141
|
+
auth_tag_b64u: base64urlEncode(tag)
|
|
142
|
+
};
|
|
143
|
+
await transport.writeBinary(encodeHandshakeFrame(HANDSHAKE_TYPE_ACK, te.encode(JSON.stringify(ack))), ioWriteOpts(opts.signal));
|
|
144
|
+
// Server-finished confirmation: require an encrypted ping record (seq=1) before returning.
|
|
145
|
+
const finishedFrame = await transport.readBinary(ioReadOpts(opts.signal, deadlineMs));
|
|
146
|
+
const finished = decryptRecord(keys.s2cKey, keys.s2cNoncePrefix, finishedFrame, 1n, opts.maxRecordBytes);
|
|
147
|
+
if (finished.flags !== RECORD_FLAG_PING || finished.plaintext.length !== 0) {
|
|
148
|
+
throw new Error("expected server-finished ping");
|
|
149
|
+
}
|
|
150
|
+
// Client sends application data with the C2S keys.
|
|
151
|
+
return new SecureChannel({
|
|
152
|
+
transport,
|
|
153
|
+
maxRecordBytes: opts.maxRecordBytes,
|
|
154
|
+
...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
|
|
155
|
+
sendKey: keys.c2sKey,
|
|
156
|
+
recvKey: keys.s2cKey,
|
|
157
|
+
sendNoncePrefix: keys.c2sNoncePrefix,
|
|
158
|
+
recvNoncePrefix: keys.s2cNoncePrefix,
|
|
159
|
+
rekeyBase: keys.rekeyBase,
|
|
160
|
+
transcriptHash: th,
|
|
161
|
+
sendDir: 1,
|
|
162
|
+
recvDir: 2,
|
|
163
|
+
recvSeq: 2n
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// ServerHandshakeCache stores server-side handshake state for retries.
|
|
167
|
+
export class ServerHandshakeCache {
|
|
168
|
+
// Cache keyed by init fingerprint.
|
|
169
|
+
m = new Map();
|
|
170
|
+
// Time-to-live for cache entries in milliseconds.
|
|
171
|
+
ttlMs;
|
|
172
|
+
// Maximum number of cached entries.
|
|
173
|
+
maxEntries;
|
|
174
|
+
constructor(opts = {}) {
|
|
175
|
+
this.ttlMs = Math.max(0, opts.ttlMs ?? 60_000);
|
|
176
|
+
this.maxEntries = Math.max(0, opts.maxEntries ?? 4096);
|
|
177
|
+
}
|
|
178
|
+
cleanup(nowMs) {
|
|
179
|
+
if (this.ttlMs <= 0)
|
|
180
|
+
return;
|
|
181
|
+
for (const [k, v] of this.m) {
|
|
182
|
+
if (nowMs - v.createdAtMs > this.ttlMs)
|
|
183
|
+
this.m.delete(k);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// getOrCreate returns a cached entry or creates a new handshake response.
|
|
187
|
+
getOrCreate(init, suite, serverFeatures) {
|
|
188
|
+
const nowMs = Date.now();
|
|
189
|
+
const initKey = fingerprintInit(init);
|
|
190
|
+
this.cleanup(nowMs);
|
|
191
|
+
const existing = this.m.get(initKey);
|
|
192
|
+
if (existing != null)
|
|
193
|
+
return existing;
|
|
194
|
+
if (this.maxEntries > 0 && this.m.size >= this.maxEntries) {
|
|
195
|
+
throw new Error("too many pending handshakes");
|
|
196
|
+
}
|
|
197
|
+
const kp = suiteKeypair(suite);
|
|
198
|
+
const nonceS = randomBytes(32);
|
|
199
|
+
const entry = {
|
|
200
|
+
initKey,
|
|
201
|
+
suite,
|
|
202
|
+
serverPriv: kp.priv,
|
|
203
|
+
serverPub: kp.pub,
|
|
204
|
+
nonceS,
|
|
205
|
+
handshakeId: base64urlEncode(randomBytes(24)),
|
|
206
|
+
serverFeatures: serverFeatures >>> 0,
|
|
207
|
+
createdAtMs: nowMs
|
|
208
|
+
};
|
|
209
|
+
this.m.set(initKey, entry);
|
|
210
|
+
return entry;
|
|
211
|
+
}
|
|
212
|
+
// delete removes a cached handshake entry.
|
|
213
|
+
delete(init) {
|
|
214
|
+
const initKey = fingerprintInit(init);
|
|
215
|
+
this.m.delete(initKey);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// serverHandshake performs the server side of the E2EE handshake.
|
|
219
|
+
export async function serverHandshake(transport, cache, opts) {
|
|
220
|
+
if (opts.initExpireAtUnixS <= 0)
|
|
221
|
+
throw new Error("missing init_exp");
|
|
222
|
+
const deadlineMs = handshakeDeadlineMs(opts.timeoutMs);
|
|
223
|
+
const initFrame = await transport.readBinary(ioReadOpts(opts.signal, deadlineMs));
|
|
224
|
+
const decodedInit = decodeHandshakeFrame(initFrame, opts.maxHandshakePayload);
|
|
225
|
+
if (decodedInit.handshakeType !== HANDSHAKE_TYPE_INIT)
|
|
226
|
+
throw new Error("unexpected handshake type");
|
|
227
|
+
const init = JSON.parse(td.decode(decodedInit.payloadJsonUtf8));
|
|
228
|
+
if (init.version !== PROTOCOL_VERSION)
|
|
229
|
+
throw new E2EEHandshakeError("invalid_version", "bad version");
|
|
230
|
+
if (init.role !== 1)
|
|
231
|
+
throw new Error("bad role");
|
|
232
|
+
if (init.channel_id !== opts.channelId)
|
|
233
|
+
throw new Error("bad channel_id");
|
|
234
|
+
const suite = init.suite;
|
|
235
|
+
if (suite !== opts.suite)
|
|
236
|
+
throw new Error("bad suite");
|
|
237
|
+
const clientPub = base64urlDecode(init.client_eph_pub_b64u);
|
|
238
|
+
const nonceC = base64urlDecode(init.nonce_c_b64u);
|
|
239
|
+
if (nonceC.length !== 32)
|
|
240
|
+
throw new Error("bad nonce_c length");
|
|
241
|
+
if (suite === 1 && clientPub.length !== 32)
|
|
242
|
+
throw new Error("bad client eph pub length");
|
|
243
|
+
if (suite === 2 && clientPub.length !== 65)
|
|
244
|
+
throw new Error("bad client eph pub length");
|
|
245
|
+
const entry = cache.getOrCreate(init, suite, opts.serverFeatures);
|
|
246
|
+
const resp = {
|
|
247
|
+
handshake_id: entry.handshakeId,
|
|
248
|
+
server_eph_pub_b64u: base64urlEncode(entry.serverPub),
|
|
249
|
+
nonce_s_b64u: base64urlEncode(entry.nonceS),
|
|
250
|
+
server_features: entry.serverFeatures
|
|
251
|
+
};
|
|
252
|
+
await transport.writeBinary(encodeHandshakeFrame(HANDSHAKE_TYPE_RESP, te.encode(JSON.stringify(resp))), ioWriteOpts(opts.signal));
|
|
253
|
+
let ack;
|
|
254
|
+
while (true) {
|
|
255
|
+
const frame = await transport.readBinary(ioReadOpts(opts.signal, deadlineMs));
|
|
256
|
+
const decoded = decodeHandshakeFrame(frame, opts.maxHandshakePayload);
|
|
257
|
+
if (decoded.handshakeType === HANDSHAKE_TYPE_INIT) {
|
|
258
|
+
// Client retry: re-send the cached response if parameters match.
|
|
259
|
+
const retry = JSON.parse(td.decode(decoded.payloadJsonUtf8));
|
|
260
|
+
if (fingerprintInit(retry) !== entry.initKey)
|
|
261
|
+
throw new Error("unexpected init retry parameters");
|
|
262
|
+
await transport.writeBinary(encodeHandshakeFrame(HANDSHAKE_TYPE_RESP, te.encode(JSON.stringify(resp))), ioWriteOpts(opts.signal));
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
if (decoded.handshakeType !== HANDSHAKE_TYPE_ACK)
|
|
266
|
+
throw new Error("unexpected handshake type");
|
|
267
|
+
ack = JSON.parse(td.decode(decoded.payloadJsonUtf8));
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
if (ack.handshake_id !== entry.handshakeId)
|
|
271
|
+
throw new Error("handshake_id mismatch");
|
|
272
|
+
const now = Math.floor(Date.now() / 1000);
|
|
273
|
+
if (Math.abs(now - ack.timestamp_unix_s) > opts.clockSkewSeconds)
|
|
274
|
+
throw new E2EEHandshakeError("timestamp_out_of_skew", "timestamp skew");
|
|
275
|
+
if (ack.timestamp_unix_s > opts.initExpireAtUnixS + opts.clockSkewSeconds)
|
|
276
|
+
throw new E2EEHandshakeError("timestamp_after_init_exp", "timestamp after init_exp");
|
|
277
|
+
const th = transcriptHash({
|
|
278
|
+
version: PROTOCOL_VERSION,
|
|
279
|
+
suite: suite,
|
|
280
|
+
role: 1,
|
|
281
|
+
clientFeatures: init.client_features >>> 0,
|
|
282
|
+
serverFeatures: entry.serverFeatures,
|
|
283
|
+
channelId: init.channel_id,
|
|
284
|
+
nonceC,
|
|
285
|
+
nonceS: entry.nonceS,
|
|
286
|
+
clientEphPub: clientPub,
|
|
287
|
+
serverEphPub: entry.serverPub
|
|
288
|
+
});
|
|
289
|
+
const expected = computeAuthTag(opts.psk, th, BigInt(ack.timestamp_unix_s));
|
|
290
|
+
const got = base64urlDecode(ack.auth_tag_b64u);
|
|
291
|
+
if (got.length !== expected.length || !equalBytes(got, expected))
|
|
292
|
+
throw new E2EEHandshakeError("auth_tag_mismatch", "auth tag mismatch");
|
|
293
|
+
const shared = suiteSharedSecret(suite, entry.serverPriv, clientPub);
|
|
294
|
+
const keys = deriveSessionKeys(opts.psk, shared, th);
|
|
295
|
+
cache.delete(init);
|
|
296
|
+
// Server-finished confirmation: send an encrypted ping record (seq=1) immediately after the handshake.
|
|
297
|
+
const pingFrame = encryptRecord(keys.s2cKey, keys.s2cNoncePrefix, RECORD_FLAG_PING, 1n, new Uint8Array(), opts.maxRecordBytes);
|
|
298
|
+
await transport.writeBinary(pingFrame, ioWriteOpts(opts.signal));
|
|
299
|
+
// Server sends application data with the S2C keys.
|
|
300
|
+
return new SecureChannel({
|
|
301
|
+
transport,
|
|
302
|
+
maxRecordBytes: opts.maxRecordBytes,
|
|
303
|
+
...(opts.maxBufferedBytes !== undefined ? { maxBufferedBytes: opts.maxBufferedBytes } : {}),
|
|
304
|
+
sendKey: keys.s2cKey,
|
|
305
|
+
recvKey: keys.c2sKey,
|
|
306
|
+
sendNoncePrefix: keys.s2cNoncePrefix,
|
|
307
|
+
recvNoncePrefix: keys.c2sNoncePrefix,
|
|
308
|
+
rekeyBase: keys.rekeyBase,
|
|
309
|
+
transcriptHash: th,
|
|
310
|
+
sendDir: 2,
|
|
311
|
+
recvDir: 1,
|
|
312
|
+
sendSeq: 2n
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
function equalBytes(a, b) {
|
|
316
|
+
if (a.length !== b.length)
|
|
317
|
+
return false;
|
|
318
|
+
let ok = 0;
|
|
319
|
+
for (let i = 0; i < a.length; i++)
|
|
320
|
+
ok |= a[i] ^ b[i];
|
|
321
|
+
return ok === 0;
|
|
322
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type SessionKeys = Readonly<{
|
|
2
|
+
/** Client-to-server AEAD key (32 bytes). */
|
|
3
|
+
c2sKey: Uint8Array;
|
|
4
|
+
/** Server-to-client AEAD key (32 bytes). */
|
|
5
|
+
s2cKey: Uint8Array;
|
|
6
|
+
/** Client-to-server nonce prefix (4 bytes). */
|
|
7
|
+
c2sNoncePrefix: Uint8Array;
|
|
8
|
+
/** Server-to-client nonce prefix (4 bytes). */
|
|
9
|
+
s2cNoncePrefix: Uint8Array;
|
|
10
|
+
/** Base secret for deriving per-sequence rekeyed keys. */
|
|
11
|
+
rekeyBase: Uint8Array;
|
|
12
|
+
}>;
|
|
13
|
+
export declare function deriveSessionKeys(psk: Uint8Array, sharedSecret: Uint8Array, transcriptHash: Uint8Array): SessionKeys;
|
|
14
|
+
export declare function computeAuthTag(psk: Uint8Array, transcriptHash: Uint8Array, timestampUnixS: bigint): Uint8Array;
|
|
15
|
+
export declare function deriveRekeyKey(rekeyBase: Uint8Array, transcriptHash: Uint8Array, seq: bigint, dir: number): Uint8Array;
|
package/dist/e2ee/kdf.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { hkdf } from "@noble/hashes/hkdf";
|
|
2
|
+
import { hmac } from "@noble/hashes/hmac";
|
|
3
|
+
import { sha256 } from "@noble/hashes/sha256";
|
|
4
|
+
import { concatBytes, u64be } from "../utils/bin.js";
|
|
5
|
+
const te = new TextEncoder();
|
|
6
|
+
// deriveSessionKeys expands the shared secret and transcript into session keys.
|
|
7
|
+
export function deriveSessionKeys(psk, sharedSecret, transcriptHash) {
|
|
8
|
+
if (psk.length !== 32)
|
|
9
|
+
throw new Error("psk must be 32 bytes");
|
|
10
|
+
if (transcriptHash.length !== 32)
|
|
11
|
+
throw new Error("transcript hash must be 32 bytes");
|
|
12
|
+
const ikm = concatBytes([sharedSecret, transcriptHash]);
|
|
13
|
+
const c2sKey = hkdf(sha256, ikm, psk, te.encode("flowersec-e2ee-v1:c2s:key"), 32);
|
|
14
|
+
const s2cKey = hkdf(sha256, ikm, psk, te.encode("flowersec-e2ee-v1:s2c:key"), 32);
|
|
15
|
+
const rekeyBase = hkdf(sha256, ikm, psk, te.encode("flowersec-e2ee-v1:rekey_base"), 32);
|
|
16
|
+
const c2sNoncePrefix = hkdf(sha256, ikm, psk, te.encode("flowersec-e2ee-v1:c2s:nonce_prefix"), 4);
|
|
17
|
+
const s2cNoncePrefix = hkdf(sha256, ikm, psk, te.encode("flowersec-e2ee-v1:s2c:nonce_prefix"), 4);
|
|
18
|
+
return { c2sKey, s2cKey, c2sNoncePrefix, s2cNoncePrefix, rekeyBase };
|
|
19
|
+
}
|
|
20
|
+
// computeAuthTag authenticates the transcript hash and timestamp.
|
|
21
|
+
export function computeAuthTag(psk, transcriptHash, timestampUnixS) {
|
|
22
|
+
if (psk.length !== 32)
|
|
23
|
+
throw new Error("psk must be 32 bytes");
|
|
24
|
+
if (transcriptHash.length !== 32)
|
|
25
|
+
throw new Error("transcript hash must be 32 bytes");
|
|
26
|
+
const msg = concatBytes([transcriptHash, u64be(timestampUnixS)]);
|
|
27
|
+
return hmac(sha256, psk, msg);
|
|
28
|
+
}
|
|
29
|
+
// deriveRekeyKey derives a new record key bound to sequence and direction.
|
|
30
|
+
export function deriveRekeyKey(rekeyBase, transcriptHash, seq, dir) {
|
|
31
|
+
if (rekeyBase.length !== 32)
|
|
32
|
+
throw new Error("rekeyBase must be 32 bytes");
|
|
33
|
+
if (transcriptHash.length !== 32)
|
|
34
|
+
throw new Error("transcript hash must be 32 bytes");
|
|
35
|
+
const msg = concatBytes([transcriptHash, u64be(seq), new Uint8Array([dir & 0xff])]);
|
|
36
|
+
const salt = hmac(sha256, rekeyBase, msg);
|
|
37
|
+
const prkIkm = te.encode("flowersec-e2ee-v1:rekey");
|
|
38
|
+
return hkdf(sha256, prkIkm, salt, te.encode("flowersec-e2ee-v1:rekey:key"), 32);
|
|
39
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RECORD_FLAG_APP, RECORD_FLAG_PING, RECORD_FLAG_REKEY } from "./constants.js";
|
|
2
|
+
export type RecordFlag = typeof RECORD_FLAG_APP | typeof RECORD_FLAG_PING | typeof RECORD_FLAG_REKEY;
|
|
3
|
+
export declare class RecordError extends Error {
|
|
4
|
+
}
|
|
5
|
+
export declare function maxPlaintextBytes(maxRecordBytes: number): number;
|
|
6
|
+
export declare function encryptRecord(key: Uint8Array, noncePrefix: Uint8Array, flags: RecordFlag, seq: bigint, plaintext: Uint8Array, maxRecordBytes: number): Uint8Array;
|
|
7
|
+
export declare function decryptRecord(key: Uint8Array, noncePrefix: Uint8Array, frame: Uint8Array, expectSeq: bigint | null, maxRecordBytes: number): {
|
|
8
|
+
flags: number;
|
|
9
|
+
seq: bigint;
|
|
10
|
+
plaintext: Uint8Array;
|
|
11
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { gcm } from "@noble/ciphers/aes";
|
|
2
|
+
import { concatBytes, readU32be, readU64be, u32be, u64be } from "../utils/bin.js";
|
|
3
|
+
import { PROTOCOL_VERSION, RECORD_MAGIC, RECORD_FLAG_APP, RECORD_FLAG_PING, RECORD_FLAG_REKEY } from "./constants.js";
|
|
4
|
+
const te = new TextEncoder();
|
|
5
|
+
// RecordError marks record parsing or cryptographic failures.
|
|
6
|
+
export class RecordError extends Error {
|
|
7
|
+
}
|
|
8
|
+
// maxPlaintextBytes returns the payload cap derived from a record size limit.
|
|
9
|
+
export function maxPlaintextBytes(maxRecordBytes) {
|
|
10
|
+
if (maxRecordBytes <= 0)
|
|
11
|
+
return 0;
|
|
12
|
+
return maxRecordBytes - (4 + 1 + 1 + 8 + 4) - 16;
|
|
13
|
+
}
|
|
14
|
+
// encryptRecord builds an AEAD-protected record frame.
|
|
15
|
+
export function encryptRecord(key, noncePrefix, flags, seq, plaintext, maxRecordBytes) {
|
|
16
|
+
if (key.length !== 32)
|
|
17
|
+
throw new RecordError("key must be 32 bytes");
|
|
18
|
+
if (noncePrefix.length !== 4)
|
|
19
|
+
throw new RecordError("noncePrefix must be 4 bytes");
|
|
20
|
+
const cipherLen = plaintext.length + 16;
|
|
21
|
+
if (cipherLen > 0xffffffff)
|
|
22
|
+
throw new RecordError("record too large");
|
|
23
|
+
const header = new Uint8Array(4 + 1 + 1 + 8 + 4);
|
|
24
|
+
header.set(te.encode(RECORD_MAGIC), 0);
|
|
25
|
+
header[4] = PROTOCOL_VERSION;
|
|
26
|
+
header[5] = flags & 0xff;
|
|
27
|
+
header.set(u64be(seq), 6);
|
|
28
|
+
header.set(u32be(cipherLen), 14);
|
|
29
|
+
const nonce = concatBytes([noncePrefix, u64be(seq)]);
|
|
30
|
+
const cipher = gcm(key, nonce, header).encrypt(plaintext);
|
|
31
|
+
const out = concatBytes([header, cipher]);
|
|
32
|
+
if (maxRecordBytes > 0 && out.length > maxRecordBytes)
|
|
33
|
+
throw new RecordError("record too large");
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
// decryptRecord validates and decrypts a record frame.
|
|
37
|
+
export function decryptRecord(key, noncePrefix, frame, expectSeq, maxRecordBytes) {
|
|
38
|
+
if (key.length !== 32)
|
|
39
|
+
throw new RecordError("key must be 32 bytes");
|
|
40
|
+
if (noncePrefix.length !== 4)
|
|
41
|
+
throw new RecordError("noncePrefix must be 4 bytes");
|
|
42
|
+
const headerLen = 4 + 1 + 1 + 8 + 4;
|
|
43
|
+
if (maxRecordBytes > 0 && frame.length > maxRecordBytes)
|
|
44
|
+
throw new RecordError("record too large");
|
|
45
|
+
if (frame.length < headerLen)
|
|
46
|
+
throw new RecordError("record too short");
|
|
47
|
+
if (new TextDecoder().decode(frame.slice(0, 4)) !== RECORD_MAGIC)
|
|
48
|
+
throw new RecordError("bad record magic");
|
|
49
|
+
if (frame[4] !== PROTOCOL_VERSION)
|
|
50
|
+
throw new RecordError("bad record version");
|
|
51
|
+
const flags = frame[5];
|
|
52
|
+
if (flags !== RECORD_FLAG_APP && flags !== RECORD_FLAG_PING && flags !== RECORD_FLAG_REKEY) {
|
|
53
|
+
throw new RecordError("bad record flag");
|
|
54
|
+
}
|
|
55
|
+
const seq = readU64be(frame, 6);
|
|
56
|
+
if (expectSeq != null && seq !== expectSeq)
|
|
57
|
+
throw new RecordError(`bad seq: got=${seq} want=${expectSeq}`);
|
|
58
|
+
const n = readU32be(frame, 14);
|
|
59
|
+
if (headerLen + n !== frame.length)
|
|
60
|
+
throw new RecordError("length mismatch");
|
|
61
|
+
const nonce = concatBytes([noncePrefix, u64be(seq)]);
|
|
62
|
+
try {
|
|
63
|
+
const plaintext = gcm(key, nonce, frame.slice(0, headerLen)).decrypt(frame.slice(headerLen));
|
|
64
|
+
return { flags, seq, plaintext };
|
|
65
|
+
}
|
|
66
|
+
catch (e) {
|
|
67
|
+
throw new RecordError(`decrypt failed: ${String(e)} len=${frame.length}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
export type ReadBinaryOptions = Readonly<{
|
|
2
|
+
/** Optional AbortSignal to cancel the pending operation. */
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
/** Optional timeout (milliseconds) for the pending operation. */
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
}>;
|
|
7
|
+
export type WriteBinaryOptions = Readonly<{
|
|
8
|
+
/** Optional AbortSignal to cancel the pending operation. */
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
}>;
|
|
11
|
+
export type BinaryTransport = {
|
|
12
|
+
/** Reads the next binary frame from the underlying transport. */
|
|
13
|
+
readBinary(opts?: ReadBinaryOptions): Promise<Uint8Array>;
|
|
14
|
+
/** Writes a binary frame to the underlying transport. */
|
|
15
|
+
writeBinary(frame: Uint8Array, opts?: WriteBinaryOptions): Promise<void>;
|
|
16
|
+
/** Closes the transport and unblocks pending readers/writers. */
|
|
17
|
+
close(): void;
|
|
18
|
+
};
|
|
19
|
+
export type SecureChannelOptions = Readonly<{
|
|
20
|
+
/** Maximum encoded record size (header + ciphertext). */
|
|
21
|
+
maxRecordBytes: number;
|
|
22
|
+
/** Maximum queued plaintext bytes before backpressure/errors. */
|
|
23
|
+
maxBufferedBytes?: number;
|
|
24
|
+
}>;
|
|
25
|
+
type Direction = 1 | 2;
|
|
26
|
+
export declare class SecureChannel {
|
|
27
|
+
private readonly transport;
|
|
28
|
+
private readonly maxRecordBytes;
|
|
29
|
+
private readonly maxBufferedBytes;
|
|
30
|
+
private sendKey;
|
|
31
|
+
private recvKey;
|
|
32
|
+
private sendNoncePrefix;
|
|
33
|
+
private recvNoncePrefix;
|
|
34
|
+
private readonly rekeyBase;
|
|
35
|
+
private readonly transcriptHash;
|
|
36
|
+
private readonly sendDir;
|
|
37
|
+
private readonly recvDir;
|
|
38
|
+
private sendSeq;
|
|
39
|
+
private recvSeq;
|
|
40
|
+
private sendQueue;
|
|
41
|
+
private sendQueueHead;
|
|
42
|
+
private sendWaiters;
|
|
43
|
+
private sendWaitersHead;
|
|
44
|
+
private sendClosed;
|
|
45
|
+
private sendErr;
|
|
46
|
+
private readonly recvQueue;
|
|
47
|
+
private recvQueueHead;
|
|
48
|
+
private recvQueueBytes;
|
|
49
|
+
private recvWaiters;
|
|
50
|
+
private readErr;
|
|
51
|
+
private closed;
|
|
52
|
+
constructor(args: {
|
|
53
|
+
transport: BinaryTransport;
|
|
54
|
+
maxRecordBytes: number;
|
|
55
|
+
maxBufferedBytes?: number;
|
|
56
|
+
sendKey: Uint8Array;
|
|
57
|
+
recvKey: Uint8Array;
|
|
58
|
+
sendNoncePrefix: Uint8Array;
|
|
59
|
+
recvNoncePrefix: Uint8Array;
|
|
60
|
+
rekeyBase: Uint8Array;
|
|
61
|
+
transcriptHash: Uint8Array;
|
|
62
|
+
sendDir: Direction;
|
|
63
|
+
recvDir: Direction;
|
|
64
|
+
sendSeq?: bigint;
|
|
65
|
+
recvSeq?: bigint;
|
|
66
|
+
});
|
|
67
|
+
write(plaintext: Uint8Array): Promise<void>;
|
|
68
|
+
read(): Promise<Uint8Array>;
|
|
69
|
+
close(): void;
|
|
70
|
+
sendPing(): Promise<void>;
|
|
71
|
+
rekeyNow(): Promise<void>;
|
|
72
|
+
private enqueueSend;
|
|
73
|
+
private nextSend;
|
|
74
|
+
private dequeueSend;
|
|
75
|
+
private shiftSendWaiter;
|
|
76
|
+
private wakeSendWaiters;
|
|
77
|
+
private rejectQueuedSenders;
|
|
78
|
+
private failSend;
|
|
79
|
+
private sendLoop;
|
|
80
|
+
private readLoop;
|
|
81
|
+
}
|
|
82
|
+
export {};
|