cairn-p2p 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 +43 -0
- package/dist/index.cjs +1883 -0
- package/dist/index.d.cts +572 -0
- package/dist/index.d.ts +572 -0
- package/dist/index.js +1827 -0
- package/eslint.config.js +24 -0
- package/package.json +54 -0
- package/src/channel.ts +277 -0
- package/src/config.ts +161 -0
- package/src/crypto/aead.ts +80 -0
- package/src/crypto/double-ratchet.ts +355 -0
- package/src/crypto/exchange.ts +51 -0
- package/src/crypto/hkdf.ts +33 -0
- package/src/crypto/identity.ts +84 -0
- package/src/crypto/index.ts +20 -0
- package/src/crypto/noise.ts +415 -0
- package/src/crypto/sas.ts +36 -0
- package/src/crypto/spake2.ts +169 -0
- package/src/discovery/index.ts +38 -0
- package/src/discovery/manager.ts +138 -0
- package/src/discovery/rendezvous.ts +189 -0
- package/src/discovery/tracker.ts +251 -0
- package/src/errors.ts +166 -0
- package/src/index.ts +57 -0
- package/src/mesh/index.ts +48 -0
- package/src/mesh/relay.ts +100 -0
- package/src/mesh/routing-table.ts +196 -0
- package/src/node.ts +619 -0
- package/src/pairing/adapter.ts +51 -0
- package/src/pairing/index.ts +40 -0
- package/src/pairing/link.ts +127 -0
- package/src/pairing/payload.ts +98 -0
- package/src/pairing/pin.ts +115 -0
- package/src/pairing/psk.ts +49 -0
- package/src/pairing/qr.ts +52 -0
- package/src/pairing/rate-limit.ts +134 -0
- package/src/pairing/sas-flow.ts +45 -0
- package/src/pairing/state-machine.ts +438 -0
- package/src/pairing/unpairing.ts +50 -0
- package/src/protocol/custom-handler.ts +52 -0
- package/src/protocol/envelope.ts +138 -0
- package/src/protocol/index.ts +36 -0
- package/src/protocol/message-types.ts +74 -0
- package/src/protocol/version.ts +98 -0
- package/src/server/index.ts +67 -0
- package/src/server/management.ts +285 -0
- package/src/server/store-forward.ts +266 -0
- package/src/session/backoff.ts +58 -0
- package/src/session/heartbeat.ts +79 -0
- package/src/session/index.ts +26 -0
- package/src/session/message-queue.ts +133 -0
- package/src/session/network-monitor.ts +130 -0
- package/src/session/state-machine.ts +122 -0
- package/src/session.ts +223 -0
- package/src/transport/fallback.ts +475 -0
- package/src/transport/index.ts +46 -0
- package/src/transport/libp2p-node.ts +158 -0
- package/src/transport/nat.ts +348 -0
- package/tests/conformance/cbor-vectors.test.ts +250 -0
- package/tests/integration/pairing-session.test.ts +317 -0
- package/tests/unit/config-api.test.ts +310 -0
- package/tests/unit/crypto.test.ts +407 -0
- package/tests/unit/discovery.test.ts +618 -0
- package/tests/unit/double-ratchet.test.ts +185 -0
- package/tests/unit/mesh.test.ts +349 -0
- package/tests/unit/noise.test.ts +346 -0
- package/tests/unit/pairing-extras.test.ts +402 -0
- package/tests/unit/pairing.test.ts +572 -0
- package/tests/unit/protocol.test.ts +438 -0
- package/tests/unit/reconnection.test.ts +402 -0
- package/tests/unit/scaffolding.test.ts +142 -0
- package/tests/unit/server.test.ts +492 -0
- package/tests/unit/sessions.test.ts +595 -0
- package/tests/unit/transport.test.ts +604 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { CairnError } from '../errors.js';
|
|
2
|
+
import type { CipherSuite } from '../config.js';
|
|
3
|
+
import { X25519Keypair } from './exchange.js';
|
|
4
|
+
import { hkdfSha256 } from './hkdf.js';
|
|
5
|
+
import { aeadEncrypt, aeadDecrypt } from './aead.js';
|
|
6
|
+
|
|
7
|
+
// Domain separation constants — must match Rust exactly.
|
|
8
|
+
const ROOT_KDF_INFO = new TextEncoder().encode('cairn-root-chain-v1');
|
|
9
|
+
const CHAIN_KDF_INFO = new TextEncoder().encode('cairn-chain-advance-v1');
|
|
10
|
+
const MESSAGE_KEY_KDF_INFO = new TextEncoder().encode('cairn-msg-encrypt-v1');
|
|
11
|
+
|
|
12
|
+
/** Header sent alongside each Double Ratchet encrypted message. */
|
|
13
|
+
export interface RatchetHeader {
|
|
14
|
+
/** Sender's current DH ratchet public key (32 bytes). */
|
|
15
|
+
dhPublic: Uint8Array;
|
|
16
|
+
/** Number of messages in the previous sending chain. */
|
|
17
|
+
prevChainLen: number;
|
|
18
|
+
/** Message number in the current sending chain. */
|
|
19
|
+
msgNum: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Configuration for the Double Ratchet. */
|
|
23
|
+
export interface RatchetConfig {
|
|
24
|
+
/** Maximum number of skipped message keys to cache (default: 100). */
|
|
25
|
+
maxSkip: number;
|
|
26
|
+
/** AEAD cipher suite (default: 'aes-256-gcm'). */
|
|
27
|
+
cipher: CipherSuite;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONFIG: RatchetConfig = {
|
|
31
|
+
maxSkip: 100,
|
|
32
|
+
cipher: 'aes-256-gcm',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Internal serializable state. */
|
|
36
|
+
interface RatchetState {
|
|
37
|
+
dhSelfSecret: Uint8Array;
|
|
38
|
+
dhSelfPublic: Uint8Array;
|
|
39
|
+
dhRemote: Uint8Array | null;
|
|
40
|
+
rootKey: Uint8Array;
|
|
41
|
+
chainKeySend: Uint8Array | null;
|
|
42
|
+
chainKeyRecv: Uint8Array | null;
|
|
43
|
+
msgNumSend: number;
|
|
44
|
+
msgNumRecv: number;
|
|
45
|
+
prevChainLen: number;
|
|
46
|
+
skippedKeys: Map<string, Uint8Array>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Signal Double Ratchet session.
|
|
51
|
+
*
|
|
52
|
+
* Combines DH ratcheting (X25519), root chain KDF, and symmetric chain
|
|
53
|
+
* KDF to provide forward secrecy and break-in recovery for each message.
|
|
54
|
+
*/
|
|
55
|
+
export class DoubleRatchet {
|
|
56
|
+
private state: RatchetState;
|
|
57
|
+
private config: RatchetConfig;
|
|
58
|
+
|
|
59
|
+
private constructor(state: RatchetState, config: RatchetConfig) {
|
|
60
|
+
this.state = state;
|
|
61
|
+
this.config = config;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Initialize as the sender (initiator/Alice) after a shared secret
|
|
66
|
+
* has been established (e.g., from Noise XX handshake).
|
|
67
|
+
*/
|
|
68
|
+
static initSender(
|
|
69
|
+
sharedSecret: Uint8Array,
|
|
70
|
+
remoteDh: Uint8Array,
|
|
71
|
+
config?: Partial<RatchetConfig>,
|
|
72
|
+
): DoubleRatchet {
|
|
73
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
74
|
+
const dhSelf = X25519Keypair.generate();
|
|
75
|
+
|
|
76
|
+
// Perform initial DH ratchet step
|
|
77
|
+
const dhOutput = dhSelf.diffieHellman(remoteDh);
|
|
78
|
+
const [rootKey, chainKeySend] = kdfRk(sharedSecret, dhOutput);
|
|
79
|
+
|
|
80
|
+
const state: RatchetState = {
|
|
81
|
+
dhSelfSecret: dhSelf.secretBytes(),
|
|
82
|
+
dhSelfPublic: dhSelf.publicKeyBytes(),
|
|
83
|
+
dhRemote: new Uint8Array(remoteDh),
|
|
84
|
+
rootKey,
|
|
85
|
+
chainKeySend,
|
|
86
|
+
chainKeyRecv: null,
|
|
87
|
+
msgNumSend: 0,
|
|
88
|
+
msgNumRecv: 0,
|
|
89
|
+
prevChainLen: 0,
|
|
90
|
+
skippedKeys: new Map(),
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return new DoubleRatchet(state, cfg);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Initialize as the receiver (responder/Bob) after a shared secret
|
|
98
|
+
* has been established.
|
|
99
|
+
*/
|
|
100
|
+
static initReceiver(
|
|
101
|
+
sharedSecret: Uint8Array,
|
|
102
|
+
ourKeypair: X25519Keypair,
|
|
103
|
+
config?: Partial<RatchetConfig>,
|
|
104
|
+
): DoubleRatchet {
|
|
105
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
106
|
+
|
|
107
|
+
const state: RatchetState = {
|
|
108
|
+
dhSelfSecret: ourKeypair.secretBytes(),
|
|
109
|
+
dhSelfPublic: ourKeypair.publicKeyBytes(),
|
|
110
|
+
dhRemote: null,
|
|
111
|
+
rootKey: new Uint8Array(sharedSecret),
|
|
112
|
+
chainKeySend: null,
|
|
113
|
+
chainKeyRecv: null,
|
|
114
|
+
msgNumSend: 0,
|
|
115
|
+
msgNumRecv: 0,
|
|
116
|
+
prevChainLen: 0,
|
|
117
|
+
skippedKeys: new Map(),
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return new DoubleRatchet(state, cfg);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Encrypt a message. Returns header and ciphertext. */
|
|
124
|
+
encrypt(plaintext: Uint8Array): { header: RatchetHeader; ciphertext: Uint8Array } {
|
|
125
|
+
if (!this.state.chainKeySend) {
|
|
126
|
+
throw new CairnError('CRYPTO', 'no sending chain key established');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const [newChainKey, messageKey] = kdfCk(this.state.chainKeySend);
|
|
130
|
+
this.state.chainKeySend = newChainKey;
|
|
131
|
+
|
|
132
|
+
const header: RatchetHeader = {
|
|
133
|
+
dhPublic: new Uint8Array(this.state.dhSelfPublic),
|
|
134
|
+
prevChainLen: this.state.prevChainLen,
|
|
135
|
+
msgNum: this.state.msgNumSend,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
this.state.msgNumSend++;
|
|
139
|
+
|
|
140
|
+
const nonce = deriveNonce(messageKey, header.msgNum);
|
|
141
|
+
const aad = serializeHeader(header);
|
|
142
|
+
|
|
143
|
+
const ciphertext = aeadEncrypt(this.config.cipher, messageKey, nonce, plaintext, aad);
|
|
144
|
+
|
|
145
|
+
// Best-effort zero message key
|
|
146
|
+
messageKey.fill(0);
|
|
147
|
+
|
|
148
|
+
return { header, ciphertext };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Decrypt a message given the header and ciphertext. */
|
|
152
|
+
decrypt(header: RatchetHeader, ciphertext: Uint8Array): Uint8Array {
|
|
153
|
+
// Try skipped keys first
|
|
154
|
+
const skippedId = skippedKeyId(header.dhPublic, header.msgNum);
|
|
155
|
+
const skippedMk = this.state.skippedKeys.get(skippedId);
|
|
156
|
+
if (skippedMk) {
|
|
157
|
+
this.state.skippedKeys.delete(skippedId);
|
|
158
|
+
return decryptWithKey(this.config.cipher, skippedMk, header, ciphertext);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check if peer's DH key changed (DH ratchet step needed)
|
|
162
|
+
const needDhRatchet = this.state.dhRemote === null ||
|
|
163
|
+
!bytesEqual(this.state.dhRemote, header.dhPublic);
|
|
164
|
+
|
|
165
|
+
if (needDhRatchet) {
|
|
166
|
+
this.skipMessageKeys(header.prevChainLen);
|
|
167
|
+
this.dhRatchet(header.dhPublic);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Skip ahead in the current receiving chain if needed
|
|
171
|
+
this.skipMessageKeys(header.msgNum);
|
|
172
|
+
|
|
173
|
+
// Derive the message key from the receiving chain
|
|
174
|
+
if (!this.state.chainKeyRecv) {
|
|
175
|
+
throw new CairnError('CRYPTO', 'no receiving chain key established');
|
|
176
|
+
}
|
|
177
|
+
const [newChainKey, messageKey] = kdfCk(this.state.chainKeyRecv);
|
|
178
|
+
this.state.chainKeyRecv = newChainKey;
|
|
179
|
+
this.state.msgNumRecv++;
|
|
180
|
+
|
|
181
|
+
const result = decryptWithKey(this.config.cipher, messageKey, header, ciphertext);
|
|
182
|
+
messageKey.fill(0);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Export the ratchet state for persistence. */
|
|
187
|
+
exportState(): Uint8Array {
|
|
188
|
+
const skippedEntries: Array<[string, number[]]> = [];
|
|
189
|
+
for (const [key, value] of this.state.skippedKeys) {
|
|
190
|
+
skippedEntries.push([key, Array.from(value)]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const obj = {
|
|
194
|
+
dhSelfSecret: Array.from(this.state.dhSelfSecret),
|
|
195
|
+
dhSelfPublic: Array.from(this.state.dhSelfPublic),
|
|
196
|
+
dhRemote: this.state.dhRemote ? Array.from(this.state.dhRemote) : null,
|
|
197
|
+
rootKey: Array.from(this.state.rootKey),
|
|
198
|
+
chainKeySend: this.state.chainKeySend ? Array.from(this.state.chainKeySend) : null,
|
|
199
|
+
chainKeyRecv: this.state.chainKeyRecv ? Array.from(this.state.chainKeyRecv) : null,
|
|
200
|
+
msgNumSend: this.state.msgNumSend,
|
|
201
|
+
msgNumRecv: this.state.msgNumRecv,
|
|
202
|
+
prevChainLen: this.state.prevChainLen,
|
|
203
|
+
skippedKeys: skippedEntries,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return new TextEncoder().encode(JSON.stringify(obj));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Import ratchet state from persisted bytes. */
|
|
210
|
+
static importState(data: Uint8Array, config?: Partial<RatchetConfig>): DoubleRatchet {
|
|
211
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
212
|
+
try {
|
|
213
|
+
const json = new TextDecoder().decode(data);
|
|
214
|
+
const obj = JSON.parse(json);
|
|
215
|
+
|
|
216
|
+
const skippedKeys = new Map<string, Uint8Array>();
|
|
217
|
+
if (obj.skippedKeys) {
|
|
218
|
+
for (const [key, value] of obj.skippedKeys) {
|
|
219
|
+
skippedKeys.set(key, new Uint8Array(value));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const state: RatchetState = {
|
|
224
|
+
dhSelfSecret: new Uint8Array(obj.dhSelfSecret),
|
|
225
|
+
dhSelfPublic: new Uint8Array(obj.dhSelfPublic),
|
|
226
|
+
dhRemote: obj.dhRemote ? new Uint8Array(obj.dhRemote) : null,
|
|
227
|
+
rootKey: new Uint8Array(obj.rootKey),
|
|
228
|
+
chainKeySend: obj.chainKeySend ? new Uint8Array(obj.chainKeySend) : null,
|
|
229
|
+
chainKeyRecv: obj.chainKeyRecv ? new Uint8Array(obj.chainKeyRecv) : null,
|
|
230
|
+
msgNumSend: obj.msgNumSend,
|
|
231
|
+
msgNumRecv: obj.msgNumRecv,
|
|
232
|
+
prevChainLen: obj.prevChainLen,
|
|
233
|
+
skippedKeys,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
return new DoubleRatchet(state, cfg);
|
|
237
|
+
} catch (e) {
|
|
238
|
+
if (e instanceof CairnError) throw e;
|
|
239
|
+
throw new CairnError('CRYPTO', `ratchet state deserialization: ${e}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Skip message keys up to (but not including) the given message number. */
|
|
244
|
+
private skipMessageKeys(until: number): void {
|
|
245
|
+
if (!this.state.chainKeyRecv) return;
|
|
246
|
+
|
|
247
|
+
const toSkip = until - this.state.msgNumRecv;
|
|
248
|
+
if (toSkip <= 0) return;
|
|
249
|
+
if (toSkip > this.config.maxSkip) {
|
|
250
|
+
throw new CairnError('CRYPTO', 'max skip threshold exceeded');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let ck = this.state.chainKeyRecv;
|
|
254
|
+
for (let i = this.state.msgNumRecv; i < until; i++) {
|
|
255
|
+
const [newCk, mk] = kdfCk(ck);
|
|
256
|
+
if (!this.state.dhRemote) {
|
|
257
|
+
throw new CairnError('CRYPTO', 'no remote DH key for skipping');
|
|
258
|
+
}
|
|
259
|
+
const id = skippedKeyId(this.state.dhRemote, i);
|
|
260
|
+
this.state.skippedKeys.set(id, mk);
|
|
261
|
+
ck = newCk;
|
|
262
|
+
this.state.msgNumRecv++;
|
|
263
|
+
}
|
|
264
|
+
this.state.chainKeyRecv = ck;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Perform a DH ratchet step when the peer's public key changes. */
|
|
268
|
+
private dhRatchet(newRemotePublic: Uint8Array): void {
|
|
269
|
+
this.state.prevChainLen = this.state.msgNumSend;
|
|
270
|
+
this.state.msgNumSend = 0;
|
|
271
|
+
this.state.msgNumRecv = 0;
|
|
272
|
+
this.state.dhRemote = new Uint8Array(newRemotePublic);
|
|
273
|
+
|
|
274
|
+
// Derive receiving chain key from current DH keypair + new remote key
|
|
275
|
+
const dhSelf = X25519Keypair.fromBytes(this.state.dhSelfSecret);
|
|
276
|
+
const dhOutput = dhSelf.diffieHellman(newRemotePublic);
|
|
277
|
+
const [rootKey1, chainKeyRecv] = kdfRk(this.state.rootKey, dhOutput);
|
|
278
|
+
this.state.rootKey = rootKey1;
|
|
279
|
+
this.state.chainKeyRecv = chainKeyRecv;
|
|
280
|
+
|
|
281
|
+
// Generate new DH keypair and derive sending chain key
|
|
282
|
+
const newDhSelf = X25519Keypair.generate();
|
|
283
|
+
this.state.dhSelfSecret = newDhSelf.secretBytes();
|
|
284
|
+
this.state.dhSelfPublic = newDhSelf.publicKeyBytes();
|
|
285
|
+
|
|
286
|
+
const dhOutput2 = newDhSelf.diffieHellman(newRemotePublic);
|
|
287
|
+
const [rootKey2, chainKeySend] = kdfRk(this.state.rootKey, dhOutput2);
|
|
288
|
+
this.state.rootKey = rootKey2;
|
|
289
|
+
this.state.chainKeySend = chainKeySend;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Derive new root key and chain key from DH output. */
|
|
294
|
+
function kdfRk(rootKey: Uint8Array, dhOutput: Uint8Array): [Uint8Array, Uint8Array] {
|
|
295
|
+
const output = hkdfSha256(dhOutput, rootKey, ROOT_KDF_INFO, 64);
|
|
296
|
+
return [output.slice(0, 32), output.slice(32, 64)];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Derive message key from chain key and advance the chain. */
|
|
300
|
+
function kdfCk(chainKey: Uint8Array): [Uint8Array, Uint8Array] {
|
|
301
|
+
const newCk = hkdfSha256(chainKey, undefined, CHAIN_KDF_INFO, 32);
|
|
302
|
+
const mk = hkdfSha256(chainKey, undefined, MESSAGE_KEY_KDF_INFO, 32);
|
|
303
|
+
return [newCk, mk];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Derive a 12-byte nonce from a message key and message number. */
|
|
307
|
+
function deriveNonce(messageKey: Uint8Array, msgNum: number): Uint8Array {
|
|
308
|
+
const nonce = new Uint8Array(12);
|
|
309
|
+
// First 8 bytes from message key, last 4 from message number (big-endian)
|
|
310
|
+
nonce.set(messageKey.slice(0, 8), 0);
|
|
311
|
+
const view = new DataView(nonce.buffer, nonce.byteOffset, nonce.byteLength);
|
|
312
|
+
view.setUint32(8, msgNum);
|
|
313
|
+
return nonce;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Serialize a RatchetHeader as JSON for AEAD AAD. */
|
|
317
|
+
function serializeHeader(header: RatchetHeader): Uint8Array {
|
|
318
|
+
const obj = {
|
|
319
|
+
dh_public: Array.from(header.dhPublic),
|
|
320
|
+
prev_chain_len: header.prevChainLen,
|
|
321
|
+
msg_num: header.msgNum,
|
|
322
|
+
};
|
|
323
|
+
return new TextEncoder().encode(JSON.stringify(obj));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Decrypt ciphertext with a specific message key. */
|
|
327
|
+
function decryptWithKey(
|
|
328
|
+
cipher: CipherSuite,
|
|
329
|
+
messageKey: Uint8Array,
|
|
330
|
+
header: RatchetHeader,
|
|
331
|
+
ciphertext: Uint8Array,
|
|
332
|
+
): Uint8Array {
|
|
333
|
+
const nonce = deriveNonce(messageKey, header.msgNum);
|
|
334
|
+
const aad = serializeHeader(header);
|
|
335
|
+
return aeadDecrypt(cipher, messageKey, nonce, ciphertext, aad);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Create a skipped key ID string from DH public key and message number. */
|
|
339
|
+
function skippedKeyId(dhPublic: Uint8Array, msgNum: number): string {
|
|
340
|
+
return bytesToHex(dhPublic) + ':' + msgNum;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Convert bytes to hex string. */
|
|
344
|
+
function bytesToHex(bytes: Uint8Array): string {
|
|
345
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Compare two Uint8Arrays for equality. */
|
|
349
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
350
|
+
if (a.length !== b.length) return false;
|
|
351
|
+
for (let i = 0; i < a.length; i++) {
|
|
352
|
+
if (a[i] !== b[i]) return false;
|
|
353
|
+
}
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { x25519 } from '@noble/curves/ed25519';
|
|
2
|
+
import { CairnError } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* An X25519 keypair for Diffie-Hellman key exchange.
|
|
6
|
+
*/
|
|
7
|
+
export class X25519Keypair {
|
|
8
|
+
private readonly secret: Uint8Array;
|
|
9
|
+
private readonly pubKey: Uint8Array;
|
|
10
|
+
|
|
11
|
+
private constructor(secret: Uint8Array, pubKey: Uint8Array) {
|
|
12
|
+
this.secret = secret;
|
|
13
|
+
this.pubKey = pubKey;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Generate a new random X25519 keypair. */
|
|
17
|
+
static generate(): X25519Keypair {
|
|
18
|
+
const secret = x25519.utils.randomSecretKey();
|
|
19
|
+
const pubKey = x25519.getPublicKey(secret);
|
|
20
|
+
return new X25519Keypair(secret, pubKey);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Restore from a 32-byte secret key. */
|
|
24
|
+
static fromBytes(secret: Uint8Array): X25519Keypair {
|
|
25
|
+
if (secret.length !== 32) {
|
|
26
|
+
throw new CairnError('CRYPTO', 'X25519 secret key must be 32 bytes');
|
|
27
|
+
}
|
|
28
|
+
const copy = new Uint8Array(secret);
|
|
29
|
+
const pubKey = x25519.getPublicKey(copy);
|
|
30
|
+
return new X25519Keypair(copy, pubKey);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Get the 32-byte public key. */
|
|
34
|
+
publicKeyBytes(): Uint8Array {
|
|
35
|
+
return new Uint8Array(this.pubKey);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Export the 32-byte secret key. */
|
|
39
|
+
secretBytes(): Uint8Array {
|
|
40
|
+
return new Uint8Array(this.secret);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Perform Diffie-Hellman key exchange with a peer's public key. Returns 32-byte shared secret. */
|
|
44
|
+
diffieHellman(peerPublic: Uint8Array): Uint8Array {
|
|
45
|
+
try {
|
|
46
|
+
return x25519.getSharedSecret(this.secret, peerPublic);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
throw new CairnError('CRYPTO', `X25519 DH error: ${e}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { hkdf } from '@noble/hashes/hkdf';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
3
|
+
import { CairnError } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
// Domain separation info strings for HKDF derivations.
|
|
6
|
+
// Must match Rust constants exactly.
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
export const HKDF_INFO_SESSION_KEY = encoder.encode('cairn-session-key-v1');
|
|
9
|
+
export const HKDF_INFO_RENDEZVOUS = encoder.encode('cairn-rendezvous-id-v1');
|
|
10
|
+
export const HKDF_INFO_SAS = encoder.encode('cairn-sas-derivation-v1');
|
|
11
|
+
export const HKDF_INFO_CHAIN_KEY = encoder.encode('cairn-chain-key-v1');
|
|
12
|
+
export const HKDF_INFO_MESSAGE_KEY = encoder.encode('cairn-message-key-v1');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Derive key material from input keying material using HKDF-SHA256 (RFC 5869).
|
|
16
|
+
*
|
|
17
|
+
* @param ikm - input keying material (e.g., DH shared secret)
|
|
18
|
+
* @param salt - optional salt (undefined uses zero-filled salt)
|
|
19
|
+
* @param info - context-specific info string for domain separation
|
|
20
|
+
* @param length - number of bytes to derive
|
|
21
|
+
*/
|
|
22
|
+
export function hkdfSha256(
|
|
23
|
+
ikm: Uint8Array,
|
|
24
|
+
salt: Uint8Array | undefined,
|
|
25
|
+
info: Uint8Array,
|
|
26
|
+
length: number,
|
|
27
|
+
): Uint8Array {
|
|
28
|
+
try {
|
|
29
|
+
return hkdf(sha256, ikm, salt, info, length);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
throw new CairnError('CRYPTO', `HKDF-SHA256 error: ${e}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import * as ed from '@noble/ed25519';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
3
|
+
import { CairnError } from '../errors.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An Ed25519 identity keypair used for signing and peer identification.
|
|
7
|
+
*/
|
|
8
|
+
export class IdentityKeypair {
|
|
9
|
+
private readonly secret: Uint8Array;
|
|
10
|
+
private readonly pubKey: Uint8Array;
|
|
11
|
+
|
|
12
|
+
private constructor(secret: Uint8Array, pubKey: Uint8Array) {
|
|
13
|
+
this.secret = secret;
|
|
14
|
+
this.pubKey = pubKey;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Generate a new random Ed25519 identity keypair. */
|
|
18
|
+
static async generate(): Promise<IdentityKeypair> {
|
|
19
|
+
const secret = ed.utils.randomPrivateKey();
|
|
20
|
+
const pubKey = await ed.getPublicKeyAsync(secret);
|
|
21
|
+
return new IdentityKeypair(secret, pubKey);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Restore from a 32-byte secret key seed. */
|
|
25
|
+
static async fromBytes(secret: Uint8Array): Promise<IdentityKeypair> {
|
|
26
|
+
if (secret.length !== 32) {
|
|
27
|
+
throw new CairnError('CRYPTO', 'Ed25519 secret key must be 32 bytes');
|
|
28
|
+
}
|
|
29
|
+
const copy = new Uint8Array(secret);
|
|
30
|
+
const pubKey = await ed.getPublicKeyAsync(copy);
|
|
31
|
+
return new IdentityKeypair(copy, pubKey);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Export the 32-byte secret key seed. */
|
|
35
|
+
secretBytes(): Uint8Array {
|
|
36
|
+
return new Uint8Array(this.secret);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Get the 32-byte public key. */
|
|
40
|
+
publicKey(): Uint8Array {
|
|
41
|
+
return new Uint8Array(this.pubKey);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Derive the Peer ID: SHA-256 hash of the Ed25519 public key bytes (32 bytes). */
|
|
45
|
+
peerId(): Uint8Array {
|
|
46
|
+
return peerIdFromPublicKey(this.pubKey);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Sign a message. Returns 64-byte signature. */
|
|
50
|
+
async sign(message: Uint8Array): Promise<Uint8Array> {
|
|
51
|
+
try {
|
|
52
|
+
return await ed.signAsync(message, this.secret);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
throw new CairnError('CRYPTO', `Ed25519 sign error: ${e}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Verify a signature against this keypair's public key. Throws on failure. */
|
|
59
|
+
async verify(message: Uint8Array, signature: Uint8Array): Promise<void> {
|
|
60
|
+
return verifySignature(this.pubKey, message, signature);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Derive Peer ID from a public key (without needing the private key). */
|
|
65
|
+
export function peerIdFromPublicKey(publicKey: Uint8Array): Uint8Array {
|
|
66
|
+
return sha256(publicKey);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Verify a signature against an arbitrary public key. Throws on failure. */
|
|
70
|
+
export async function verifySignature(
|
|
71
|
+
publicKey: Uint8Array,
|
|
72
|
+
message: Uint8Array,
|
|
73
|
+
signature: Uint8Array,
|
|
74
|
+
): Promise<void> {
|
|
75
|
+
try {
|
|
76
|
+
const valid = await ed.verifyAsync(signature, message, publicKey);
|
|
77
|
+
if (!valid) {
|
|
78
|
+
throw new CairnError('CRYPTO', 'Ed25519 signature verification failed');
|
|
79
|
+
}
|
|
80
|
+
} catch (e) {
|
|
81
|
+
if (e instanceof CairnError) throw e;
|
|
82
|
+
throw new CairnError('CRYPTO', `Ed25519 verify error: ${e}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Crypto module — Ed25519, X25519, HKDF, AEAD, Noise XX, SPAKE2, SAS, Double Ratchet (tasks 030-032)
|
|
2
|
+
|
|
3
|
+
export { IdentityKeypair, peerIdFromPublicKey, verifySignature } from './identity.js';
|
|
4
|
+
export { X25519Keypair } from './exchange.js';
|
|
5
|
+
export {
|
|
6
|
+
hkdfSha256,
|
|
7
|
+
HKDF_INFO_SESSION_KEY,
|
|
8
|
+
HKDF_INFO_RENDEZVOUS,
|
|
9
|
+
HKDF_INFO_SAS,
|
|
10
|
+
HKDF_INFO_CHAIN_KEY,
|
|
11
|
+
HKDF_INFO_MESSAGE_KEY,
|
|
12
|
+
} from './hkdf.js';
|
|
13
|
+
export { aeadEncrypt, aeadDecrypt, NONCE_SIZE, KEY_SIZE, TAG_SIZE } from './aead.js';
|
|
14
|
+
export type { Role, HandshakeResult, StepOutput } from './noise.js';
|
|
15
|
+
export { NoiseXXHandshake } from './noise.js';
|
|
16
|
+
export { deriveNumericSas, deriveEmojiSas, EMOJI_TABLE } from './sas.js';
|
|
17
|
+
export type { Spake2Role } from './spake2.js';
|
|
18
|
+
export { Spake2 } from './spake2.js';
|
|
19
|
+
export type { RatchetHeader, RatchetConfig } from './double-ratchet.js';
|
|
20
|
+
export { DoubleRatchet } from './double-ratchet.js';
|