raz-encrypt 0.3.0 → 2.1.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/LICENSE +21 -21
- package/README.md +442 -212
- package/dist/canonicalJson.d.ts +15 -0
- package/dist/canonicalJson.js +73 -13
- package/dist/canonicalJson.js.map +1 -1
- package/dist/core.d.ts +6 -2
- package/dist/core.js +4 -1
- package/dist/core.js.map +1 -1
- package/dist/crypto/classical.d.ts +2 -0
- package/dist/crypto/classical.js +4 -0
- package/dist/crypto/classical.js.map +1 -1
- package/dist/crypto/confirmedSession.d.ts +47 -0
- package/dist/crypto/confirmedSession.js +84 -0
- package/dist/crypto/confirmedSession.js.map +1 -0
- package/dist/crypto/frameCipher.js +8 -2
- package/dist/crypto/frameCipher.js.map +1 -1
- package/dist/crypto/frameSession.d.ts +40 -0
- package/dist/crypto/frameSession.js +96 -0
- package/dist/crypto/frameSession.js.map +1 -0
- package/dist/crypto/hybrid.d.ts +186 -25
- package/dist/crypto/hybrid.js +512 -54
- package/dist/crypto/hybrid.js.map +1 -1
- package/dist/crypto/nodeHybrid.d.ts +6 -4
- package/dist/crypto/nodeHybrid.js +3 -2
- package/dist/crypto/nodeHybrid.js.map +1 -1
- package/dist/crypto/pqc.d.ts +7 -2
- package/dist/crypto/pqc.js +13 -4
- package/dist/crypto/pqc.js.map +1 -1
- package/dist/crypto/ratchet.d.ts +68 -0
- package/dist/crypto/ratchet.js +94 -0
- package/dist/crypto/ratchet.js.map +1 -0
- package/dist/fileCrypto.d.ts +44 -2
- package/dist/fileCrypto.js +197 -28
- package/dist/fileCrypto.js.map +1 -1
- package/dist/identity.d.ts +69 -4
- package/dist/identity.js +111 -12
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/passphrase.d.ts +27 -0
- package/dist/passphrase.js +69 -0
- package/dist/passphrase.js.map +1 -0
- package/dist/store.d.ts +42 -5
- package/dist/store.js +254 -22
- package/dist/store.js.map +1 -1
- package/dist/trust.d.ts +143 -2
- package/dist/trust.js +376 -27
- package/dist/trust.js.map +1 -1
- package/package.json +62 -48
package/dist/crypto/hybrid.js
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { gcm } from "@noble/ciphers/aes.js";
|
|
2
|
+
import { equalBytes } from "@noble/ciphers/utils.js";
|
|
2
3
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
4
|
+
import { hmac } from "@noble/hashes/hmac.js";
|
|
3
5
|
import { sha384 } from "@noble/hashes/sha2.js";
|
|
4
6
|
import { bytesToHex, concatBytes, hexToBytes, randomBytes, utf8ToBytes } from "@noble/hashes/utils.js";
|
|
5
7
|
import { canonicalize, hashDocument } from "../canonicalJson.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
+
import { fingerprint } from "../fingerprint.js";
|
|
9
|
+
import { classicalSharedSecret, classicalSign, classicalVerify, generateClassicalKeypair } from "./classical.js";
|
|
10
|
+
import { generatePqcKemKeypair, pqcDecapsulate, pqcEncapsulate, pqcSign, pqcVerify } from "./pqc.js";
|
|
8
11
|
// No import of ../store.js or ../trust.js here (only their LogEntry TYPE,
|
|
9
12
|
// which is erased at compile time) — this file must stay resolvable by a
|
|
10
13
|
// browser bundler on its own. The node:fs-backed default wiring for
|
|
11
14
|
// sign/verify/encryptFor/decryptFrom lives in ./nodeHybrid.ts instead,
|
|
12
15
|
// which is what "." (not "./core") re-exports.
|
|
13
|
-
|
|
16
|
+
// Box v2 (ADR 0018): the key is derived under this label with a salt that binds
|
|
17
|
+
// both parties and every public value in the box; the sender dual-signs the
|
|
18
|
+
// box under this domain tag (also passed as the ML-DSA context).
|
|
19
|
+
const BOX_VERSION = 2;
|
|
20
|
+
const BOX_KEY_INFO = utf8ToBytes("raz-encrypt-box-v2|key");
|
|
21
|
+
const BOX_DOMAIN_TAG = "raz-encrypt-box-v2|sign";
|
|
22
|
+
const BOX_CONTEXT = utf8ToBytes(BOX_DOMAIN_TAG);
|
|
14
23
|
const ALGORITHMS = { classical: "ECDSA-P384", pqc: "ML-DSA-87" };
|
|
15
24
|
export class UntrustedPartyError extends Error {
|
|
16
25
|
constructor(alias) {
|
|
@@ -18,6 +27,89 @@ export class UntrustedPartyError extends Error {
|
|
|
18
27
|
this.name = "UntrustedPartyError";
|
|
19
28
|
}
|
|
20
29
|
}
|
|
30
|
+
/** Thrown by decryptFromCore for a structurally-malformed EncryptedBox or a failed AEAD decryption — both mean "this box is unusable", so one error class covers both causes. */
|
|
31
|
+
export class DecryptionError extends Error {
|
|
32
|
+
constructor(message) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "DecryptionError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** True for a non-empty, even-length string of hex digits only — what every hex-encoded field in this module expects. */
|
|
38
|
+
function isHex(value) {
|
|
39
|
+
return typeof value === "string" && value.length > 0 && value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* True only for the exact canonical format `new Date().toISOString()` itself
|
|
43
|
+
* produces — the one format this codebase ever actually writes. A round-trip
|
|
44
|
+
* check (not a regex, not bare `Date.parse`) catches wrong formats, non-ISO
|
|
45
|
+
* strings `Date.parse` would otherwise accept (implementation-defined beyond
|
|
46
|
+
* strict ISO-8601), AND calendar-overflow values some engines silently roll
|
|
47
|
+
* forward (e.g. a Feb 30th) — all in one comparison: any mismatch, whatever
|
|
48
|
+
* the cause, fails the round-trip (ADR 0010). Exported so other modules that
|
|
49
|
+
* define their own timestamp fields (e.g. trust.ts's rotation/revocation
|
|
50
|
+
* documents) can hold them to the same strict standard instead of each
|
|
51
|
+
* re-deriving their own looser check.
|
|
52
|
+
*/
|
|
53
|
+
export function isValidIsoInstant(value) {
|
|
54
|
+
const date = new Date(value);
|
|
55
|
+
return !Number.isNaN(date.getTime()) && date.toISOString() === value;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Structural shape check only — right fields, right primitive types, hex
|
|
59
|
+
* fields actually parse as hex, and (issue #4) a present `expiresAt` must be
|
|
60
|
+
* a valid ISO instant, not just a string. Runs before any
|
|
61
|
+
* hexToBytes/checkTrust call so a malformed certificate can't crash
|
|
62
|
+
* verifyCore instead of just failing it.
|
|
63
|
+
*/
|
|
64
|
+
function isValidCertificateShape(certificate) {
|
|
65
|
+
if (typeof certificate !== "object" || certificate === null)
|
|
66
|
+
return false;
|
|
67
|
+
const c = certificate;
|
|
68
|
+
if (typeof c.documentHash !== "string")
|
|
69
|
+
return false;
|
|
70
|
+
if (!isHex(c.classicalSignature))
|
|
71
|
+
return false;
|
|
72
|
+
if (!isHex(c.pqcSignature))
|
|
73
|
+
return false;
|
|
74
|
+
if (typeof c.algorithms !== "object" || c.algorithms === null)
|
|
75
|
+
return false;
|
|
76
|
+
if (typeof c.signedAt !== "string")
|
|
77
|
+
return false;
|
|
78
|
+
if (c.version !== undefined && c.version !== 2)
|
|
79
|
+
return false;
|
|
80
|
+
if (c.version === 2 && !isValidIsoInstant(c.signedAt))
|
|
81
|
+
return false;
|
|
82
|
+
if (c.expiresAt !== undefined && (typeof c.expiresAt !== "string" || !isValidIsoInstant(c.expiresAt)))
|
|
83
|
+
return false;
|
|
84
|
+
if (typeof c.signer !== "object" || c.signer === null)
|
|
85
|
+
return false;
|
|
86
|
+
const signer = c.signer;
|
|
87
|
+
if (typeof signer.alias !== "string")
|
|
88
|
+
return false;
|
|
89
|
+
if (!isHex(signer.classicalPublicKey))
|
|
90
|
+
return false;
|
|
91
|
+
if (!isHex(signer.pqcSignPublicKey))
|
|
92
|
+
return false;
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
/** Same structural-shape treatment as isValidCertificateShape, for the box encryptFor/decryptFrom exchange instead of a Certificate. Version is checked separately so a v1 box gets a clear error. */
|
|
96
|
+
function isValidEncryptedBoxShape(box) {
|
|
97
|
+
if (typeof box !== "object" || box === null)
|
|
98
|
+
return false;
|
|
99
|
+
const b = box;
|
|
100
|
+
const sig = b.signature;
|
|
101
|
+
return (isHex(b.id) &&
|
|
102
|
+
b.id.length === 32 &&
|
|
103
|
+
typeof b.signedAt === "string" &&
|
|
104
|
+
isValidIsoInstant(b.signedAt) &&
|
|
105
|
+
isHex(b.kemCipherText) &&
|
|
106
|
+
isHex(b.nonce) &&
|
|
107
|
+
isHex(b.ciphertext) &&
|
|
108
|
+
typeof sig === "object" &&
|
|
109
|
+
sig !== null &&
|
|
110
|
+
isHex(sig.classicalSignature) &&
|
|
111
|
+
isHex(sig.pqcSignature));
|
|
112
|
+
}
|
|
21
113
|
/**
|
|
22
114
|
* The exact bytes both signatures are computed over. Binds `expiresAt` into
|
|
23
115
|
* the signed payload (not just `documentHash`) so a certificate's expiry
|
|
@@ -29,6 +121,17 @@ export class UntrustedPartyError extends Error {
|
|
|
29
121
|
function signedMessage(documentHash, expiresAt) {
|
|
30
122
|
return utf8ToBytes(canonicalize({ documentHash, expiresAt: expiresAt ?? null }));
|
|
31
123
|
}
|
|
124
|
+
const CERTIFICATE_V2_DOMAIN = "raz-encrypt/certificate/v2";
|
|
125
|
+
/** v2 signed payload: also binds the signer's alias and signedAt, under a domain-separation tag (ADR 0017). */
|
|
126
|
+
function signedMessageV2(documentHash, expiresAt, alias, signedAt) {
|
|
127
|
+
return utf8ToBytes(canonicalize({ domain: CERTIFICATE_V2_DOMAIN, documentHash, expiresAt: expiresAt ?? null, alias, signedAt }));
|
|
128
|
+
}
|
|
129
|
+
/** The exact bytes `certificate`'s signatures are over, by its format version. */
|
|
130
|
+
function certificateMessage(certificate) {
|
|
131
|
+
return certificate.version === 2
|
|
132
|
+
? signedMessageV2(certificate.documentHash, certificate.expiresAt, certificate.signer.alias, certificate.signedAt)
|
|
133
|
+
: signedMessage(certificate.documentHash, certificate.expiresAt);
|
|
134
|
+
}
|
|
32
135
|
async function requireTrusted(checkTrust, counterpart) {
|
|
33
136
|
const status = await checkTrust(counterpart);
|
|
34
137
|
if (status !== "trusted")
|
|
@@ -45,9 +148,14 @@ async function log(onLog, entry) {
|
|
|
45
148
|
* signing never checks anyone else's trust status, only your own keys.
|
|
46
149
|
*/
|
|
47
150
|
export async function signCore(identity, document, options = {}, deps = {}) {
|
|
151
|
+
if (options.expiresAt !== undefined && !isValidIsoInstant(options.expiresAt)) {
|
|
152
|
+
throw new Error(`options.expiresAt must be a valid ISO-8601 instant in the exact format new Date().toISOString() produces (e.g. "2024-01-01T00:00:00.000Z"); got ${JSON.stringify(options.expiresAt)}.`);
|
|
153
|
+
}
|
|
48
154
|
const documentHash = hashDocument(document);
|
|
49
|
-
const
|
|
155
|
+
const signedAt = new Date().toISOString();
|
|
156
|
+
const message = signedMessageV2(documentHash, options.expiresAt, identity.alias, signedAt);
|
|
50
157
|
const certificate = {
|
|
158
|
+
version: 2,
|
|
51
159
|
documentHash,
|
|
52
160
|
classicalSignature: bytesToHex(classicalSign(message, identity.classical.secretKey)),
|
|
53
161
|
pqcSignature: bytesToHex(pqcSign(message, identity.pqcSign.secretKey)),
|
|
@@ -57,7 +165,7 @@ export async function signCore(identity, document, options = {}, deps = {}) {
|
|
|
57
165
|
classicalPublicKey: bytesToHex(identity.classical.publicKey),
|
|
58
166
|
pqcSignPublicKey: bytesToHex(identity.pqcSign.publicKey),
|
|
59
167
|
},
|
|
60
|
-
signedAt
|
|
168
|
+
signedAt,
|
|
61
169
|
...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
|
|
62
170
|
};
|
|
63
171
|
await log(deps.onLog, { action: "sign", documentHash });
|
|
@@ -70,8 +178,18 @@ export async function signCore(identity, document, options = {}, deps = {}) {
|
|
|
70
178
|
* fails the whole verification.
|
|
71
179
|
*/
|
|
72
180
|
export async function verifyCore(identity, document, certificate, deps) {
|
|
73
|
-
|
|
74
|
-
|
|
181
|
+
if (!isValidCertificateShape(certificate)) {
|
|
182
|
+
const result = { valid: false, reason: "malformed-certificate" };
|
|
183
|
+
// No real signer to name — certificate.signer may not even exist. The
|
|
184
|
+
// audit log still gets an entry so a verify attempt that happened isn't
|
|
185
|
+
// silently uncounted just because the input was garbage. Awaited, same
|
|
186
|
+
// as every log() call below — a failed write propagates to the caller
|
|
187
|
+
// instead of becoming an unhandled rejection (issue #5).
|
|
188
|
+
await log(deps.onLog, { action: "verify", result: result.reason });
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
const record = async (result) => {
|
|
192
|
+
await log(deps.onLog, {
|
|
75
193
|
action: "verify",
|
|
76
194
|
documentHash: certificate.documentHash,
|
|
77
195
|
counterpartAlias: certificate.signer.alias,
|
|
@@ -92,7 +210,7 @@ export async function verifyCore(identity, document, certificate, deps) {
|
|
|
92
210
|
if (certificate.expiresAt && Date.now() >= Date.parse(certificate.expiresAt)) {
|
|
93
211
|
return record({ valid: false, reason: "expired" });
|
|
94
212
|
}
|
|
95
|
-
const message =
|
|
213
|
+
const message = certificateMessage(certificate);
|
|
96
214
|
const classicalOk = classicalVerify(hexToBytes(certificate.classicalSignature), message, hexToBytes(certificate.signer.classicalPublicKey));
|
|
97
215
|
if (!classicalOk)
|
|
98
216
|
return record({ valid: false, reason: "classical-signature-mismatch" });
|
|
@@ -101,80 +219,420 @@ export async function verifyCore(identity, document, certificate, deps) {
|
|
|
101
219
|
return record({ valid: false, reason: "pqc-signature-mismatch" });
|
|
102
220
|
return record({ valid: true });
|
|
103
221
|
}
|
|
104
|
-
|
|
105
|
-
|
|
222
|
+
/** Thrown by decryptFromCore when the caller's `isReplay` hook reports the (otherwise valid) box was already seen. */
|
|
223
|
+
export class ReplayedBoxError extends Error {
|
|
224
|
+
constructor(id) {
|
|
225
|
+
super(`Box ${id} has already been seen and was rejected as a replay.`);
|
|
226
|
+
this.name = "ReplayedBoxError";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Everything both sides know about a box before decryption: the parties and every public value in it. */
|
|
230
|
+
function boxContext(sender, recipient, box) {
|
|
231
|
+
return {
|
|
232
|
+
version: BOX_VERSION,
|
|
233
|
+
sender: { fingerprint: fingerprint(sender), classicalPublicKey: sender.classicalPublicKey },
|
|
234
|
+
recipient: {
|
|
235
|
+
fingerprint: fingerprint(recipient),
|
|
236
|
+
classicalPublicKey: recipient.classicalPublicKey,
|
|
237
|
+
pqcKemPublicKey: recipient.pqcKemPublicKey,
|
|
238
|
+
},
|
|
239
|
+
id: box.id,
|
|
240
|
+
signedAt: box.signedAt,
|
|
241
|
+
kemCipherText: box.kemCipherText,
|
|
242
|
+
nonce: box.nonce,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function deriveBoxKey(ecdhSecret, kemSecret, context) {
|
|
246
|
+
const salt = sha384(utf8ToBytes(canonicalize(context)));
|
|
247
|
+
return hkdf(sha384, concatBytes(ecdhSecret, kemSecret), salt, BOX_KEY_INFO, 32);
|
|
248
|
+
}
|
|
249
|
+
function boxAad(context) {
|
|
250
|
+
return utf8ToBytes(canonicalize(context));
|
|
251
|
+
}
|
|
252
|
+
function boxSignedMessage(context, ciphertext) {
|
|
253
|
+
return utf8ToBytes(canonicalize({ tag: BOX_DOMAIN_TAG, context, ciphertextHash: bytesToHex(sha384(hexToBytes(ciphertext))) }));
|
|
106
254
|
}
|
|
107
255
|
/**
|
|
108
|
-
* Encrypts `data` for `counterpart`
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
256
|
+
* Encrypts `data` for `counterpart` (box v2, ADR 0018). The key comes from
|
|
257
|
+
* ECDH-P384 and an ML-KEM-1024 encapsulation, combined by HKDF-SHA384 with a
|
|
258
|
+
* salt that binds both parties and every public value in the box. AES-256-GCM
|
|
259
|
+
* authenticates the same context as AAD, and the sender dual-signs the box so
|
|
260
|
+
* it cannot be forged by the recipient or anyone else. Throws
|
|
261
|
+
* UntrustedPartyError unless `deps.checkTrust(counterpart)` reports "trusted".
|
|
262
|
+
*
|
|
263
|
+
* There is still NO forward secrecy: the ECDH half uses long-term keys. Use a
|
|
264
|
+
* session handshake for anything that needs it.
|
|
112
265
|
*/
|
|
113
266
|
export async function encryptForCore(identity, counterpart, data, deps) {
|
|
114
267
|
await requireTrusted(deps.checkTrust, counterpart);
|
|
268
|
+
const sender = publicCardOf(identity);
|
|
115
269
|
const ecdhSecret = classicalSharedSecret(identity.classical.secretKey, hexToBytes(counterpart.classicalPublicKey));
|
|
116
|
-
const { cipherText
|
|
117
|
-
const key = deriveHybridKey(ecdhSecret, kemSecret);
|
|
270
|
+
const { cipherText, sharedSecret: kemSecret } = pqcEncapsulate(hexToBytes(counterpart.pqcKemPublicKey));
|
|
118
271
|
const nonce = randomBytes(12);
|
|
119
|
-
const
|
|
272
|
+
const head = {
|
|
273
|
+
id: bytesToHex(randomBytes(16)),
|
|
274
|
+
signedAt: new Date().toISOString(),
|
|
275
|
+
kemCipherText: bytesToHex(cipherText),
|
|
276
|
+
nonce: bytesToHex(nonce),
|
|
277
|
+
};
|
|
278
|
+
const context = boxContext(sender, counterpart, head);
|
|
279
|
+
const key = deriveBoxKey(ecdhSecret, kemSecret, context);
|
|
280
|
+
const ciphertext = bytesToHex(gcm(key, nonce, boxAad(context)).encrypt(utf8ToBytes(canonicalize(data))));
|
|
281
|
+
const message = boxSignedMessage(context, ciphertext);
|
|
282
|
+
const signature = {
|
|
283
|
+
classicalSignature: bytesToHex(classicalSign(message, identity.classical.secretKey)),
|
|
284
|
+
pqcSignature: bytesToHex(pqcSign(message, identity.pqcSign.secretKey, BOX_CONTEXT)),
|
|
285
|
+
};
|
|
120
286
|
await log(deps.onLog, { action: "encrypt", counterpartAlias: counterpart.alias });
|
|
121
|
-
return {
|
|
287
|
+
return { version: BOX_VERSION, ...head, ciphertext, signature };
|
|
122
288
|
}
|
|
123
289
|
/**
|
|
124
290
|
* Decrypts a box produced by `encryptFor`. `counterpart` is the sender's
|
|
125
291
|
* public card. Throws UntrustedPartyError unless `deps.checkTrust(counterpart)`
|
|
126
|
-
* reports "trusted"
|
|
127
|
-
*
|
|
292
|
+
* reports "trusted". The sender's signature and the recipient binding are
|
|
293
|
+
* verified BEFORE any decapsulation or decryption; a v1 box, a box sealed for
|
|
294
|
+
* someone else, or a box not signed by `counterpart` is a DecryptionError. If
|
|
295
|
+
* `deps.isReplay` is given it is consulted only after the signature checks
|
|
296
|
+
* out, and a true result throws ReplayedBoxError.
|
|
128
297
|
*/
|
|
129
298
|
export async function decryptFromCore(identity, counterpart, box, deps) {
|
|
130
299
|
await requireTrusted(deps.checkTrust, counterpart);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
300
|
+
if (typeof box !== "object" || box === null || box.version !== BOX_VERSION) {
|
|
301
|
+
throw new DecryptionError("Unsupported box version: only version 2 boxes are accepted (1.x boxes are rejected, see ADR 0018).");
|
|
302
|
+
}
|
|
303
|
+
if (!isValidEncryptedBoxShape(box)) {
|
|
304
|
+
throw new DecryptionError("Malformed EncryptedBox: id/signedAt/kemCipherText/nonce/ciphertext/signature are not all valid.");
|
|
305
|
+
}
|
|
306
|
+
const recipient = publicCardOf(identity);
|
|
307
|
+
const context = boxContext(counterpart, recipient, box);
|
|
308
|
+
// Verify before touching the KEM or the cipher.
|
|
309
|
+
const message = boxSignedMessage(context, box.ciphertext);
|
|
310
|
+
const classicalOk = classicalVerify(hexToBytes(box.signature.classicalSignature), message, hexToBytes(counterpart.classicalPublicKey));
|
|
311
|
+
const pqcOk = pqcVerify(hexToBytes(box.signature.pqcSignature), message, hexToBytes(counterpart.pqcSignPublicKey), BOX_CONTEXT);
|
|
312
|
+
if (!classicalOk || !pqcOk) {
|
|
313
|
+
throw new DecryptionError(`Box from "${counterpart.alias}" was rejected: its signature does not match this sender and recipient.`);
|
|
314
|
+
}
|
|
315
|
+
if (deps.isReplay && (await deps.isReplay({ id: box.id, signedAt: box.signedAt, senderFingerprint: fingerprint(counterpart) }))) {
|
|
316
|
+
throw new ReplayedBoxError(box.id);
|
|
317
|
+
}
|
|
318
|
+
let plaintext;
|
|
319
|
+
try {
|
|
320
|
+
const ecdhSecret = classicalSharedSecret(identity.classical.secretKey, hexToBytes(counterpart.classicalPublicKey));
|
|
321
|
+
const kemSecret = pqcDecapsulate(hexToBytes(box.kemCipherText), identity.pqcKem.secretKey);
|
|
322
|
+
const key = deriveBoxKey(ecdhSecret, kemSecret, context);
|
|
323
|
+
plaintext = gcm(key, hexToBytes(box.nonce), boxAad(context)).decrypt(hexToBytes(box.ciphertext));
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
throw new DecryptionError(`Failed to decrypt box from "${counterpart.alias}": ${err instanceof Error ? err.message : String(err)}`);
|
|
327
|
+
}
|
|
135
328
|
await log(deps.onLog, { action: "decrypt", counterpartAlias: counterpart.alias });
|
|
136
|
-
|
|
329
|
+
try {
|
|
330
|
+
return JSON.parse(new TextDecoder().decode(plaintext));
|
|
331
|
+
}
|
|
332
|
+
catch (err) {
|
|
333
|
+
// Decryption succeeded (so the sender really did send these exact
|
|
334
|
+
// bytes), but they aren't valid JSON — a malicious or buggy sender, not
|
|
335
|
+
// a tampering attacker. Fail closed with the same error type as every
|
|
336
|
+
// other rejection here, instead of crashing the caller on a raw
|
|
337
|
+
// SyntaxError (found by fuzzing, issue #42).
|
|
338
|
+
throw new DecryptionError(`Box from "${counterpart.alias}" decrypted, but its plaintext is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
339
|
+
}
|
|
137
340
|
}
|
|
138
341
|
// --- Session handshake ------------------------------------------------
|
|
139
342
|
//
|
|
140
343
|
// encryptForCore/decryptFromCore above do a full ECDH + ML-KEM encapsulation
|
|
141
344
|
// PER CALL — fine for a one-off document, prohibitively expensive if called
|
|
142
|
-
// once per 16 KiB chunk of a large file transfer. The functions below run
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
345
|
+
// once per 16 KiB chunk of a large file transfer. The functions below run a
|
|
346
|
+
// hybrid exchange ONCE per connection and hand back a pair of directional
|
|
347
|
+
// keys the caller reuses (via a separate per-frame cipher, not implemented
|
|
348
|
+
// here) for as many messages as the connection carries.
|
|
349
|
+
//
|
|
350
|
+
// This handshake is ephemeral and authenticated (see docs/adr/0006, which
|
|
351
|
+
// supersedes ADR 0004's single-encapsulation design): each side generates a
|
|
352
|
+
// fresh, single-use ECDH-P384 keypair per session attempt (the responder
|
|
353
|
+
// also a fresh, single-use ML-KEM-1024 keypair), signs its own ephemeral
|
|
354
|
+
// public key(s) plus a per-handshake nonce with its long-term classical and
|
|
355
|
+
// PQ signing keys (the same dual-signature mechanism signCore/verifyCore use
|
|
356
|
+
// for documents), and the counterpart verifies both signatures before
|
|
357
|
+
// trusting the signed ephemeral key(s). The combined secret folds together
|
|
358
|
+
// the fresh ephemeral ECDH secret, the KEM-encapsulated secret, AND the
|
|
359
|
+
// existing long-term (static) ECDH secret (defense-in-depth; forward
|
|
360
|
+
// secrecy itself rests entirely on the ephemeral secrets being discarded).
|
|
361
|
+
// None of the ephemeral secret keys are ever attached to Identity,
|
|
362
|
+
// persisted, or logged — they live only in the return value of
|
|
363
|
+
// createResponderHandshakeCore and the stack of establishSessionInitiatorCore,
|
|
364
|
+
// for the lifetime of one handshake attempt.
|
|
365
|
+
//
|
|
366
|
+
// Wire shape: the responder generates its ephemeral keys first and sends
|
|
367
|
+
// ResponderHandshakeMessage (this can ride alongside the responder's
|
|
368
|
+
// PublicCard, since nothing in it depends on anything from the initiator);
|
|
369
|
+
// the initiator replies with InitiatorHandshakeMessage, which is the only
|
|
370
|
+
// message carrying the KEM ciphertext (the initiator is the one who
|
|
371
|
+
// encapsulates, same initiator/responder asymmetry ADR 0004 already
|
|
372
|
+
// established); the responder completes the handshake from that reply.
|
|
373
|
+
const SESSION_INFO_INITIATOR_TO_RESPONDER = utf8ToBytes("raz-encrypt-session-v3|initiator-to-responder");
|
|
374
|
+
const SESSION_INFO_RESPONDER_TO_INITIATOR = utf8ToBytes("raz-encrypt-session-v3|responder-to-initiator");
|
|
375
|
+
const SESSION_INFO_KEY_CONFIRMATION = utf8ToBytes("raz-encrypt-session-v3|key-confirmation");
|
|
376
|
+
// A fourth, additional HKDF output (issue #29/raz-encrypt #29) from the exact
|
|
377
|
+
// same combined input key material and transcript salt as the three outputs
|
|
378
|
+
// above — NOT a repurposing of any of them. This is the seed for a two-party
|
|
379
|
+
// ratchet (see ./ratchet.ts) built on top of the handshake, never itself used
|
|
380
|
+
// to encrypt a frame directly.
|
|
381
|
+
const SESSION_INFO_ROOT_KEY = utf8ToBytes("raz-encrypt-session-v3|root-key");
|
|
382
|
+
// Domain-separation tag for handshake signatures (ADR 0008, raz-encrypt
|
|
383
|
+
// #2/issue #2): embedded as a `tag` field inside each handshake message's
|
|
384
|
+
// signed payload AND passed as ML-DSA's native `context` option (FIPS 204
|
|
385
|
+
// §5.4) — belt-and-suspenders, not redundant: the payload field survives
|
|
386
|
+
// even if a caller signs with a different PQC primitive later, the `context`
|
|
387
|
+
// option is enforced by the algorithm itself. One version counter shared
|
|
388
|
+
// with the SESSION_INFO_* labels above, not a separately-tracked one.
|
|
389
|
+
const HANDSHAKE_DOMAIN_TAG = "raz-encrypt-session-v3|handshake";
|
|
390
|
+
const HANDSHAKE_CONTEXT = utf8ToBytes(HANDSHAKE_DOMAIN_TAG);
|
|
391
|
+
export class HandshakeVerificationError extends Error {
|
|
392
|
+
constructor(message) {
|
|
393
|
+
super(message);
|
|
394
|
+
this.name = "HandshakeVerificationError";
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
/** Thrown for a structurally-malformed handshake message (non-hex fields) — distinct from HandshakeVerificationError, which means the shape was fine but the signature didn't check out. */
|
|
398
|
+
export class MalformedHandshakeMessageError extends Error {
|
|
399
|
+
constructor(message) {
|
|
400
|
+
super(message);
|
|
401
|
+
this.name = "MalformedHandshakeMessageError";
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function isValidHandshakeSignatureShape(signature) {
|
|
405
|
+
if (typeof signature !== "object" || signature === null)
|
|
406
|
+
return false;
|
|
407
|
+
const s = signature;
|
|
408
|
+
return isHex(s.classicalSignature) && isHex(s.pqcSignature);
|
|
409
|
+
}
|
|
410
|
+
function isValidResponderHandshakeMessageShape(message) {
|
|
411
|
+
if (typeof message !== "object" || message === null)
|
|
412
|
+
return false;
|
|
413
|
+
const m = message;
|
|
414
|
+
return isHex(m.ephemeralClassicalPublicKey) && isHex(m.ephemeralKemPublicKey) && isHex(m.nonce) && isValidHandshakeSignatureShape(m.signature);
|
|
415
|
+
}
|
|
416
|
+
function isValidInitiatorHandshakeMessageShape(message) {
|
|
417
|
+
if (typeof message !== "object" || message === null)
|
|
418
|
+
return false;
|
|
419
|
+
const m = message;
|
|
420
|
+
return isHex(m.ephemeralClassicalPublicKey) && isHex(m.nonce) && isHex(m.kemCipherText) && isValidHandshakeSignatureShape(m.signature);
|
|
421
|
+
}
|
|
422
|
+
/** Reconstructs the counterpart-facing PublicCard shape from an Identity, without importing identity.js (which pulls in node:fs) — keeps this module browser-safe. */
|
|
423
|
+
function publicCardOf(identity) {
|
|
150
424
|
return {
|
|
151
|
-
|
|
152
|
-
|
|
425
|
+
alias: identity.alias,
|
|
426
|
+
classicalPublicKey: bytesToHex(identity.classical.publicKey),
|
|
427
|
+
pqcSignPublicKey: bytesToHex(identity.pqcSign.publicKey),
|
|
428
|
+
pqcKemPublicKey: bytesToHex(identity.pqcKem.publicKey),
|
|
153
429
|
};
|
|
154
430
|
}
|
|
431
|
+
/** The exact bytes a handshake signature is computed over — mirrors `signedMessage`'s canonicalize-then-sign-directly shape, applied to ephemeral handshake material instead of a document. */
|
|
432
|
+
function handshakeSignedMessage(payload) {
|
|
433
|
+
return utf8ToBytes(canonicalize(payload));
|
|
434
|
+
}
|
|
435
|
+
function signHandshakePayload(identity, payload) {
|
|
436
|
+
const message = handshakeSignedMessage(payload);
|
|
437
|
+
return {
|
|
438
|
+
classicalSignature: bytesToHex(classicalSign(message, identity.classical.secretKey)),
|
|
439
|
+
pqcSignature: bytesToHex(pqcSign(message, identity.pqcSign.secretKey, HANDSHAKE_CONTEXT)),
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
/** Throws HandshakeVerificationError unless BOTH the classical and PQ signatures check out — mirrors verifyCore's all-or-nothing dual-signature requirement. */
|
|
443
|
+
function verifyHandshakeSignature(signer, payload, signature) {
|
|
444
|
+
const message = handshakeSignedMessage(payload);
|
|
445
|
+
const classicalOk = classicalVerify(hexToBytes(signature.classicalSignature), message, hexToBytes(signer.classicalPublicKey));
|
|
446
|
+
if (!classicalOk)
|
|
447
|
+
throw new HandshakeVerificationError(`Handshake rejected: classical signature from "${signer.alias}" did not verify.`);
|
|
448
|
+
const pqcOk = pqcVerify(hexToBytes(signature.pqcSignature), message, hexToBytes(signer.pqcSignPublicKey), HANDSHAKE_CONTEXT);
|
|
449
|
+
if (!pqcOk)
|
|
450
|
+
throw new HandshakeVerificationError(`Handshake rejected: PQC signature from "${signer.alias}" did not verify.`);
|
|
451
|
+
}
|
|
452
|
+
const KEY_CONFIRMATION_DOMAIN = "raz-encrypt/key-confirmation/v1";
|
|
453
|
+
function keyConfirmationBytes(role, transcript) {
|
|
454
|
+
if (role !== "initiator" && role !== "responder")
|
|
455
|
+
throw new TypeError(`Unknown handshake role: ${String(role)}`);
|
|
456
|
+
return utf8ToBytes(canonicalize({ domain: KEY_CONFIRMATION_DOMAIN, role, ...transcript }));
|
|
457
|
+
}
|
|
155
458
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
459
|
+
* The tag to send the counterpart as proof you derived the same secrets:
|
|
460
|
+
* HMAC-SHA384, keyed by the handshake's `confirmationKey`, over the full
|
|
461
|
+
* transcript and YOUR role (hex, 96 characters). Folding the role in means
|
|
462
|
+
* your own tag can never be replayed back to you as the counterpart's.
|
|
463
|
+
* Send it only after the handshake call returned, and treat the connection
|
|
464
|
+
* as unauthenticated until `verifyKeyConfirmation` succeeds on the tag you
|
|
465
|
+
* receive.
|
|
163
466
|
*/
|
|
164
|
-
export function
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
return
|
|
467
|
+
export function makeKeyConfirmation(confirmationKey, role, transcript) {
|
|
468
|
+
if (confirmationKey.length !== 32)
|
|
469
|
+
throw new TypeError("confirmationKey must be 32 bytes");
|
|
470
|
+
return bytesToHex(hmac(sha384, confirmationKey, keyConfirmationBytes(role, transcript)));
|
|
168
471
|
}
|
|
169
472
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
473
|
+
* Checks the tag received from the counterpart. Pass the COUNTERPART's role
|
|
474
|
+
* (an initiator verifies "responder"). Returns false — never throws — for a
|
|
475
|
+
* wrong tag, a malformed one (any non-string, odd length, non-hex, wrong
|
|
476
|
+
* length, uppercase) or an unusable key/role, and compares in constant time.
|
|
174
477
|
*/
|
|
175
|
-
export function
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
478
|
+
export function verifyKeyConfirmation(confirmationKey, counterpartRole, transcript, tag) {
|
|
479
|
+
if (typeof tag !== "string" || !/^[0-9a-f]{96}$/.test(tag))
|
|
480
|
+
return false;
|
|
481
|
+
let expected;
|
|
482
|
+
try {
|
|
483
|
+
expected = makeKeyConfirmation(confirmationKey, counterpartRole, transcript);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
return equalBytes(hexToBytes(tag), hexToBytes(expected));
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Hashes the FULL transcript — both messages in their entirety, including
|
|
492
|
+
* each one's own signature and (for the initiator's message) the KEM
|
|
493
|
+
* ciphertext — not just the stripped ephemeral-key/nonce subset. Binding the
|
|
494
|
+
* signatures and kemCipherText into the salt means the derived Session Keys
|
|
495
|
+
* depend on the complete handshake as actually exchanged, not a partial
|
|
496
|
+
* view of it.
|
|
497
|
+
*/
|
|
498
|
+
function handshakeTranscriptSalt(initiatorCard, responderCard, responderMessage, initiatorMessage) {
|
|
499
|
+
return sha384(utf8ToBytes(canonicalize({ initiatorCard, responderCard, responderMessage, initiatorMessage })));
|
|
500
|
+
}
|
|
501
|
+
function deriveSessionSecrets(ephemeralEcdhSecret, kemSecret, staticEcdhSecret, transcriptSalt) {
|
|
502
|
+
const ikm = concatBytes(ephemeralEcdhSecret, kemSecret, staticEcdhSecret);
|
|
503
|
+
return {
|
|
504
|
+
keys: {
|
|
505
|
+
initiatorToResponder: hkdf(sha384, ikm, transcriptSalt, SESSION_INFO_INITIATOR_TO_RESPONDER, 32),
|
|
506
|
+
responderToInitiator: hkdf(sha384, ikm, transcriptSalt, SESSION_INFO_RESPONDER_TO_INITIATOR, 32),
|
|
507
|
+
},
|
|
508
|
+
confirmationKey: hkdf(sha384, ikm, transcriptSalt, SESSION_INFO_KEY_CONFIRMATION, 32),
|
|
509
|
+
rootKey: hkdf(sha384, ikm, transcriptSalt, SESSION_INFO_ROOT_KEY, 32),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* First step of the handshake, run by the connection's responder: generates
|
|
514
|
+
* one fresh, single-use ECDH-P384 keypair and one fresh, single-use
|
|
515
|
+
* ML-KEM-1024 keypair, a per-handshake nonce, and dual-signs all three with
|
|
516
|
+
* the responder's long-term classical+PQ signing keys. `message` is sent to
|
|
517
|
+
* the initiator (it can ride alongside the responder's PublicCard); `ephemeral`
|
|
518
|
+
* must be held in memory only and passed straight into
|
|
519
|
+
* `establishSessionResponderCore` once the initiator's reply arrives, then
|
|
520
|
+
* discarded.
|
|
521
|
+
*/
|
|
522
|
+
export function createResponderHandshakeCore(identity) {
|
|
523
|
+
const ephemeralClassical = generateClassicalKeypair();
|
|
524
|
+
const ephemeralKem = generatePqcKemKeypair();
|
|
525
|
+
const nonce = bytesToHex(randomBytes(32));
|
|
526
|
+
// No `counterpart` parameter here: the responder speaks first, before it
|
|
527
|
+
// knows who it's talking to, so its own signed payload can only bind what
|
|
528
|
+
// it already knows — tag, role, its own fingerprint, its own ephemeral
|
|
529
|
+
// keys and nonce. See ADR 0008 for why this asymmetry (vs. the
|
|
530
|
+
// initiator's reply, which CAN bind the peer) is accepted, not closed.
|
|
531
|
+
const signedPayload = {
|
|
532
|
+
tag: HANDSHAKE_DOMAIN_TAG,
|
|
533
|
+
role: "responder",
|
|
534
|
+
ownFingerprint: fingerprint(publicCardOf(identity)),
|
|
535
|
+
ephemeralClassicalPublicKey: bytesToHex(ephemeralClassical.publicKey),
|
|
536
|
+
ephemeralKemPublicKey: bytesToHex(ephemeralKem.publicKey),
|
|
537
|
+
nonce,
|
|
538
|
+
};
|
|
539
|
+
const signature = signHandshakePayload(identity, signedPayload);
|
|
540
|
+
const message = {
|
|
541
|
+
ephemeralClassicalPublicKey: signedPayload.ephemeralClassicalPublicKey,
|
|
542
|
+
ephemeralKemPublicKey: signedPayload.ephemeralKemPublicKey,
|
|
543
|
+
nonce,
|
|
544
|
+
signature,
|
|
545
|
+
};
|
|
546
|
+
return {
|
|
547
|
+
message,
|
|
548
|
+
ephemeral: {
|
|
549
|
+
ephemeralClassicalSecretKey: ephemeralClassical.secretKey,
|
|
550
|
+
ephemeralKemSecretKey: ephemeralKem.secretKey,
|
|
551
|
+
message,
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Establishes a session as the connection's initiator, given the
|
|
557
|
+
* responder's `ResponderHandshakeMessage`. Verifies the responder's dual
|
|
558
|
+
* signature (throws `HandshakeVerificationError` on failure), generates its
|
|
559
|
+
* own fresh, single-use ECDH-P384 keypair and nonce, dual-signs them,
|
|
560
|
+
* encapsulates toward the responder's ephemeral KEM public key, and derives
|
|
561
|
+
* the Session Keys plus a key-confirmation key from the combined ephemeral
|
|
562
|
+
* ECDH + KEM + long-term ECDH secret, salted by a hash of the full
|
|
563
|
+
* handshake transcript. The returned `message` is sent to the responder to
|
|
564
|
+
* complete the handshake. No trust dependency: establishing a session never
|
|
565
|
+
* checks anyone's trust status, same as signCore.
|
|
566
|
+
*/
|
|
567
|
+
export function establishSessionInitiatorCore(identity, counterpart, responderMessage) {
|
|
568
|
+
if (!isValidResponderHandshakeMessageShape(responderMessage)) {
|
|
569
|
+
throw new MalformedHandshakeMessageError("Malformed ResponderHandshakeMessage: ephemeral keys/nonce/signature must all be hex strings.");
|
|
570
|
+
}
|
|
571
|
+
verifyHandshakeSignature(counterpart, {
|
|
572
|
+
tag: HANDSHAKE_DOMAIN_TAG,
|
|
573
|
+
role: "responder",
|
|
574
|
+
ownFingerprint: fingerprint(counterpart),
|
|
575
|
+
ephemeralClassicalPublicKey: responderMessage.ephemeralClassicalPublicKey,
|
|
576
|
+
ephemeralKemPublicKey: responderMessage.ephemeralKemPublicKey,
|
|
577
|
+
nonce: responderMessage.nonce,
|
|
578
|
+
}, responderMessage.signature);
|
|
579
|
+
const ephemeralClassical = generateClassicalKeypair();
|
|
580
|
+
const nonce = bytesToHex(randomBytes(32));
|
|
581
|
+
const { cipherText, sharedSecret: kemSecret } = pqcEncapsulate(hexToBytes(responderMessage.ephemeralKemPublicKey));
|
|
582
|
+
// Unlike the responder, the initiator already knows exactly who it's
|
|
583
|
+
// talking to and has the responder's message in hand — so its signed
|
|
584
|
+
// payload binds everything issue #2 asks for: both fingerprints, both
|
|
585
|
+
// nonces, and the KEM ciphertext this message carries.
|
|
586
|
+
const signedPayload = {
|
|
587
|
+
tag: HANDSHAKE_DOMAIN_TAG,
|
|
588
|
+
role: "initiator",
|
|
589
|
+
ownFingerprint: fingerprint(publicCardOf(identity)),
|
|
590
|
+
peerFingerprint: fingerprint(counterpart),
|
|
591
|
+
ephemeralClassicalPublicKey: bytesToHex(ephemeralClassical.publicKey),
|
|
592
|
+
nonce,
|
|
593
|
+
peerNonce: responderMessage.nonce,
|
|
594
|
+
kemCipherText: bytesToHex(cipherText),
|
|
595
|
+
};
|
|
596
|
+
const signature = signHandshakePayload(identity, signedPayload);
|
|
597
|
+
const message = {
|
|
598
|
+
ephemeralClassicalPublicKey: signedPayload.ephemeralClassicalPublicKey,
|
|
599
|
+
nonce,
|
|
600
|
+
signature,
|
|
601
|
+
kemCipherText: signedPayload.kemCipherText,
|
|
602
|
+
};
|
|
603
|
+
const ephemeralEcdhSecret = classicalSharedSecret(ephemeralClassical.secretKey, hexToBytes(responderMessage.ephemeralClassicalPublicKey));
|
|
604
|
+
const staticEcdhSecret = classicalSharedSecret(identity.classical.secretKey, hexToBytes(counterpart.classicalPublicKey));
|
|
605
|
+
const transcriptSalt = handshakeTranscriptSalt(publicCardOf(identity), counterpart, responderMessage, message);
|
|
606
|
+
const { keys, confirmationKey, rootKey } = deriveSessionSecrets(ephemeralEcdhSecret, kemSecret, staticEcdhSecret, transcriptSalt);
|
|
607
|
+
return { message, keys, confirmationKey, rootKey };
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Completes the handshake as the connection's responder, given the
|
|
611
|
+
* `ephemeral` secrets returned by `createResponderHandshakeCore` and the
|
|
612
|
+
* initiator's `InitiatorHandshakeMessage`. Verifies the initiator's dual
|
|
613
|
+
* signature (throws `HandshakeVerificationError` on failure), decapsulates
|
|
614
|
+
* the KEM ciphertext with the ephemeral KEM secret key, computes the
|
|
615
|
+
* ephemeral ECDH secret, and derives the same Session Keys and
|
|
616
|
+
* confirmation key the initiator derived.
|
|
617
|
+
*/
|
|
618
|
+
export function establishSessionResponderCore(identity, counterpart, ephemeral, initiatorMessage) {
|
|
619
|
+
if (!isValidInitiatorHandshakeMessageShape(initiatorMessage)) {
|
|
620
|
+
throw new MalformedHandshakeMessageError("Malformed InitiatorHandshakeMessage: ephemeral key/nonce/signature/kemCipherText must all be hex strings.");
|
|
621
|
+
}
|
|
622
|
+
verifyHandshakeSignature(counterpart, {
|
|
623
|
+
tag: HANDSHAKE_DOMAIN_TAG,
|
|
624
|
+
role: "initiator",
|
|
625
|
+
ownFingerprint: fingerprint(counterpart),
|
|
626
|
+
peerFingerprint: fingerprint(publicCardOf(identity)),
|
|
627
|
+
ephemeralClassicalPublicKey: initiatorMessage.ephemeralClassicalPublicKey,
|
|
628
|
+
nonce: initiatorMessage.nonce,
|
|
629
|
+
peerNonce: ephemeral.message.nonce,
|
|
630
|
+
kemCipherText: initiatorMessage.kemCipherText,
|
|
631
|
+
}, initiatorMessage.signature);
|
|
632
|
+
const ephemeralEcdhSecret = classicalSharedSecret(ephemeral.ephemeralClassicalSecretKey, hexToBytes(initiatorMessage.ephemeralClassicalPublicKey));
|
|
633
|
+
const kemSecret = pqcDecapsulate(hexToBytes(initiatorMessage.kemCipherText), ephemeral.ephemeralKemSecretKey);
|
|
634
|
+
const staticEcdhSecret = classicalSharedSecret(identity.classical.secretKey, hexToBytes(counterpart.classicalPublicKey));
|
|
635
|
+
const transcriptSalt = handshakeTranscriptSalt(counterpart, publicCardOf(identity), ephemeral.message, initiatorMessage);
|
|
636
|
+
return deriveSessionSecrets(ephemeralEcdhSecret, kemSecret, staticEcdhSecret, transcriptSalt);
|
|
179
637
|
}
|
|
180
638
|
//# sourceMappingURL=hybrid.js.map
|