@codesense/conseal 0.3.2 → 0.4.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 +33 -4
- package/dist/index.d.ts +113 -5
- package/dist/index.js +89 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
<h1 align="center">Conseal</h1>
|
|
9
9
|
|
|
10
10
|
<p align="center">
|
|
11
|
-
|
|
12
|
-
All crypto runs in the browser via <a href="https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto">SubtleCrypto</a> — the server never sees plaintext or key material.
|
|
11
|
+
Zero-knowledge cryptography and private communication.
|
|
13
12
|
</p>
|
|
14
13
|
|
|
15
14
|
---
|
|
@@ -78,6 +77,36 @@ const result = await unseal(key, ciphertext, iv)
|
|
|
78
77
|
| `encodeEnvelope(envelope)` | Serialises a `SealedEnvelope` to JSON. |
|
|
79
78
|
| `decodeEnvelope(json)` | Deserialises JSON back to a `SealedEnvelope`. |
|
|
80
79
|
|
|
80
|
+
### Multi-device private communication (Circle)
|
|
81
|
+
|
|
82
|
+
Establishes a bounded group of trusted devices that all hold the same Account Encryption Key (AEK). The mnemonic is the root of trust — the AEK is always derived from it, so any device can recover the AEK from the mnemonic alone if the passphrase or wrapped key is lost.
|
|
83
|
+
|
|
84
|
+
Account creation flow:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
const mnemonic = generateMnemonic() // show to user — write it down, never store it
|
|
88
|
+
const { wrappedAEK, aekCommitment, deviceId } = await initCircle(mnemonic, passphrase, secretKey)
|
|
89
|
+
// store wrappedAEK + aekCommitment server-side
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Recovery flow (lost passphrase or wrapped key):
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
const aek = await recoverWithMnemonic(mnemonic, true) // re-derive AEK from mnemonic
|
|
96
|
+
const { wrappedKey, salt } = await wrapKey(newPassphrase, aek, newSecretKey)
|
|
97
|
+
// upload new wrappedKey to server
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Four functions cover the full device-registration ceremony:
|
|
101
|
+
|
|
102
|
+
| Function | Description |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `initCircle(mnemonic, passphrase, secretKey)` | Founding device derives the shared AEK from the mnemonic, wraps it, and returns `wrappedAEK`, `aekCommitment`, and `deviceId`. |
|
|
105
|
+
| `createJoinRequest(deviceMeta?)` | New device generates an ephemeral ECDH key pair. Returns the join request payload, the ephemeral private key (memory-only), and a `verificationCode` to display to the user. |
|
|
106
|
+
| `authorizeJoin(joinRequest, wrappedAEK, passphrase, secretKey)` | Trusted device unwraps its AEK and seals it for the new device via ECDH. Rejects requests older than 5 minutes. |
|
|
107
|
+
| `finalizeJoin(sealedAEK, ephemeralPrivateKey, passphrase, secretKey, aekCommitment)` | New device unseals the AEK, verifies the commitment, and re-wraps it under its own credentials. Throws on commitment mismatch. |
|
|
108
|
+
| `deriveVerificationCode(ephemeralPublicKey)` | Derives a `XX-XX-XX` hex code from a public key. Both devices must show matching codes before approval to prevent MITM. |
|
|
109
|
+
|
|
81
110
|
### Device initialisation
|
|
82
111
|
|
|
83
112
|
| Function | Description |
|
|
@@ -89,8 +118,8 @@ const result = await unseal(key, ciphertext, iv)
|
|
|
89
118
|
|
|
90
119
|
| Function | Description |
|
|
91
120
|
|---|---|
|
|
92
|
-
| `generateMnemonic()` | Generates a 24-word recovery phrase. |
|
|
93
|
-
| `recoverWithMnemonic(mnemonic)` | Derives the AEK from the mnemonic. |
|
|
121
|
+
| `generateMnemonic()` | Generates a 24-word recovery phrase (256 bits of entropy). |
|
|
122
|
+
| `recoverWithMnemonic(mnemonic, extractable?)` | Derives the AEK from the mnemonic. Pass `extractable: true` when passing to `initCircle` or `wrapKey`. |
|
|
94
123
|
|
|
95
124
|
### Key serialisation (JWK)
|
|
96
125
|
|
package/dist/index.d.ts
CHANGED
|
@@ -33,8 +33,12 @@ declare function wrapKey(passphrase: string, key: CryptoKey, secretKey?: Uint8Ar
|
|
|
33
33
|
wrappedKey: ArrayBuffer;
|
|
34
34
|
salt: Uint8Array;
|
|
35
35
|
}>;
|
|
36
|
-
/**
|
|
37
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Unwraps a CryptoKey. Returns extractable: false by default.
|
|
38
|
+
* Pass extractable: true only when the raw key bytes are needed for transfer
|
|
39
|
+
* (e.g. circle join ceremony) — export and discard as quickly as possible.
|
|
40
|
+
*/
|
|
41
|
+
declare function unwrapKey(passphrase: string, wrappedKey: ArrayBuffer, salt: Uint8Array, secretKey?: Uint8Array, extractable?: boolean): Promise<CryptoKey>;
|
|
38
42
|
/**
|
|
39
43
|
* Changes the passphrase protecting the AEK without re-encrypting any content.
|
|
40
44
|
* The Secret Key (if any) stays the same — it is used on both the unwrap and re-wrap sides.
|
|
@@ -128,8 +132,11 @@ declare function generateMnemonic(): string;
|
|
|
128
132
|
/**
|
|
129
133
|
* Derives a deterministic AES-256-GCM CryptoKey from a BIP-39 mnemonic.
|
|
130
134
|
* Throws if the mnemonic is invalid or not in the BIP-39 word list.
|
|
135
|
+
*
|
|
136
|
+
* Pass extractable: true when the key must be wrapped before storage —
|
|
137
|
+
* e.g. when passing it to initCircle().
|
|
131
138
|
*/
|
|
132
|
-
declare function recoverWithMnemonic(mnemonic: string): Promise<CryptoKey>;
|
|
139
|
+
declare function recoverWithMnemonic(mnemonic: string, extractable?: boolean): Promise<CryptoKey>;
|
|
133
140
|
|
|
134
141
|
/** Encrypts plaintext and wraps the key with a passcode, returning a SealedEnvelope. */
|
|
135
142
|
declare function sealEnvelope(plaintext: ArrayBuffer, passcode: string): Promise<SealedEnvelope>;
|
|
@@ -137,6 +144,8 @@ declare function sealEnvelope(plaintext: ArrayBuffer, passcode: string): Promise
|
|
|
137
144
|
declare function unsealEnvelope(envelope: SealedEnvelope, passcode: string): Promise<ArrayBuffer>;
|
|
138
145
|
/** The fields produced by sealEnvelope(), ready for JSON serialisation. */
|
|
139
146
|
interface SealedEnvelope {
|
|
147
|
+
/** Format version — always 1 for envelopes produced by this library. */
|
|
148
|
+
version: 1;
|
|
140
149
|
ciphertext: ArrayBuffer;
|
|
141
150
|
iv: Uint8Array;
|
|
142
151
|
wrappedKey: ArrayBuffer;
|
|
@@ -150,7 +159,7 @@ declare function encodeEnvelope(envelope: SealedEnvelope): string;
|
|
|
150
159
|
/**
|
|
151
160
|
* Deserialises a JSON string produced by encodeEnvelope() back to a SealedEnvelope.
|
|
152
161
|
* Throws SyntaxError if the string is not valid JSON.
|
|
153
|
-
* Throws TypeError if required fields are missing or
|
|
162
|
+
* Throws TypeError if required fields are missing, the wrong type, or the version is unsupported.
|
|
154
163
|
*/
|
|
155
164
|
declare function decodeEnvelope(json: string): SealedEnvelope;
|
|
156
165
|
|
|
@@ -197,4 +206,103 @@ declare function loadCryptoKey(name: string): Promise<CryptoKey | null>;
|
|
|
197
206
|
/** Removes a CryptoKey from IndexedDB. No-op if the name does not exist. */
|
|
198
207
|
declare function deleteCryptoKey(name: string): Promise<void>;
|
|
199
208
|
|
|
200
|
-
|
|
209
|
+
/** Wrapped AEK — opaque blob stored server-side per device. */
|
|
210
|
+
interface WrappedAEK {
|
|
211
|
+
wrappedKey: ArrayBuffer;
|
|
212
|
+
salt: Uint8Array;
|
|
213
|
+
}
|
|
214
|
+
/** Serializable join request payload sent by a new device to the backend. */
|
|
215
|
+
interface JoinRequest {
|
|
216
|
+
deviceId: string;
|
|
217
|
+
ephemeralPublicKey: JsonWebKey;
|
|
218
|
+
createdAt: string;
|
|
219
|
+
deviceMeta?: {
|
|
220
|
+
name?: string;
|
|
221
|
+
platform?: string;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** AEK sealed for a specific device's ephemeral public key via ECDH. */
|
|
225
|
+
interface SealedAEK {
|
|
226
|
+
ciphertext: ArrayBuffer;
|
|
227
|
+
iv: Uint8Array;
|
|
228
|
+
ephemeralPublicKey: JsonWebKey;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Derives a human-readable verification code from an ECDH public key JWK.
|
|
232
|
+
*
|
|
233
|
+
* Exports the key as its uncompressed point bytes (65 bytes for P-256),
|
|
234
|
+
* SHA-256 hashes them, and formats the first 3 bytes as uppercase hex pairs
|
|
235
|
+
* separated by dashes: e.g. "A3-K9-F2".
|
|
236
|
+
*
|
|
237
|
+
* Both the new device (createJoinRequest) and the authorizing device
|
|
238
|
+
* (authorizeJoin) call this with the same JWK and must get the same code.
|
|
239
|
+
* The user confirms the codes match out-of-band before approval proceeds.
|
|
240
|
+
*/
|
|
241
|
+
declare function deriveVerificationCode(ephemeralPublicKey: JsonWebKey): Promise<string>;
|
|
242
|
+
/**
|
|
243
|
+
* Called once by the founding device when creating a new account.
|
|
244
|
+
*
|
|
245
|
+
* Derives the shared AEK from the mnemonic (so it can always be recovered),
|
|
246
|
+
* publishes its SHA-256 commitment (so joining devices can verify the AEK was
|
|
247
|
+
* not substituted), and wraps the AEK under the founding device's own
|
|
248
|
+
* passphrase + secretKey.
|
|
249
|
+
*
|
|
250
|
+
* The mnemonic is the root of trust — it must be shown to the user at account
|
|
251
|
+
* creation and never stored. The app stores wrappedAEK and aekCommitment on
|
|
252
|
+
* the server.
|
|
253
|
+
*/
|
|
254
|
+
declare function initCircle(mnemonic: string, passphrase: string, secretKey: Uint8Array): Promise<{
|
|
255
|
+
wrappedAEK: WrappedAEK;
|
|
256
|
+
aekCommitment: ArrayBuffer;
|
|
257
|
+
deviceId: string;
|
|
258
|
+
}>;
|
|
259
|
+
/**
|
|
260
|
+
* Called by a new device that wants to join the circle.
|
|
261
|
+
*
|
|
262
|
+
* Generates an ephemeral ECDH P-256 key pair for the one-time ceremony.
|
|
263
|
+
* The ephemeral private key is returned to the caller and must be held in
|
|
264
|
+
* memory only — never persisted — until finalizeJoin completes.
|
|
265
|
+
*
|
|
266
|
+
* The verification code is derived from the ephemeral public key and must
|
|
267
|
+
* be displayed prominently so the user can confirm it matches the code shown
|
|
268
|
+
* on the authorizing device.
|
|
269
|
+
*/
|
|
270
|
+
declare function createJoinRequest(deviceMeta?: {
|
|
271
|
+
name?: string;
|
|
272
|
+
platform?: string;
|
|
273
|
+
}): Promise<{
|
|
274
|
+
request: JoinRequest;
|
|
275
|
+
ephemeralPrivateKey: CryptoKey;
|
|
276
|
+
verificationCode: string;
|
|
277
|
+
}>;
|
|
278
|
+
/**
|
|
279
|
+
* Called by a trusted device to approve a new device joining the circle.
|
|
280
|
+
*
|
|
281
|
+
* Rejects stale join requests (older than 5 minutes) regardless of server
|
|
282
|
+
* challenge state. The caller must present the verification code from
|
|
283
|
+
* joinRequest.ephemeralPublicKey and require explicit user confirmation that
|
|
284
|
+
* it matches the code on the new device before calling this function —
|
|
285
|
+
* calling without confirmation bypasses the primary MITM defence.
|
|
286
|
+
*
|
|
287
|
+
* Unwraps the AEK and seals its raw bytes for the new device's ephemeral
|
|
288
|
+
* public key via ECDH. Only the ephemeral private key held by the new device
|
|
289
|
+
* can open it.
|
|
290
|
+
*/
|
|
291
|
+
declare function authorizeJoin(joinRequest: JoinRequest, wrappedAEK: WrappedAEK, passphrase: string, secretKey: Uint8Array): Promise<SealedAEK>;
|
|
292
|
+
/**
|
|
293
|
+
* Called by the new device after authorization.
|
|
294
|
+
*
|
|
295
|
+
* Unseals the AEK using the ephemeral private key generated in
|
|
296
|
+
* createJoinRequest, verifies it against aekCommitment (SHA-256 of the raw
|
|
297
|
+
* AEK published by initCircle), then re-wraps it under the new device's own
|
|
298
|
+
* passphrase + secretKey. The ephemeral private key is not needed after this
|
|
299
|
+
* call and should be discarded.
|
|
300
|
+
*
|
|
301
|
+
* Throws if the commitment check fails — the AEK was substituted or tampered
|
|
302
|
+
* with in transit. Do not use the returned wrappedAEK if this throws.
|
|
303
|
+
*/
|
|
304
|
+
declare function finalizeJoin(sealedAEK: SealedAEK, ephemeralPrivateKey: CryptoKey, passphrase: string, secretKey: Uint8Array, aekCommitment: ArrayBuffer): Promise<{
|
|
305
|
+
wrappedAEK: WrappedAEK;
|
|
306
|
+
}>;
|
|
307
|
+
|
|
308
|
+
export { AEK_KEY_ID, type JoinRequest, type SealedAEK, type SealedEnvelope, type WrappedAEK, authorizeJoin, combinePassphraseAndSecretKey, createJoinRequest, decodeEnvelope, deleteCryptoKey, deriveVerificationCode, digest, encodeEnvelope, exportPublicKeyAsJwk, finalizeJoin, fromBase64, fromBase64Url, generateAesKey, generateECDHKeyPair, generateECDSAKeyPair, generateMnemonic, generateSecretKey, importAesKey, importPublicKeyFromJwk, init, initCircle, loadCryptoKey, recoverWithMnemonic, rekey, rekeySecretKey, saveCryptoKey, seal, sealEnvelope, sealMessage, sign, toBase64, toBase64Url, unseal, unsealEnvelope, unsealMessage, unwrapKey, verify, wrapKey };
|
package/dist/index.js
CHANGED
|
@@ -105,10 +105,10 @@ async function wrapKey(passphrase, key, secretKey) {
|
|
|
105
105
|
wrapped.set(new Uint8Array(ciphertext), IV_LENGTH);
|
|
106
106
|
return { wrappedKey: wrapped.buffer, salt };
|
|
107
107
|
}
|
|
108
|
-
async function unwrapKey(passphrase, wrappedKey, salt, secretKey) {
|
|
108
|
+
async function unwrapKey(passphrase, wrappedKey, salt, secretKey, extractable = false) {
|
|
109
109
|
const effective = await resolvePassphrase(passphrase, secretKey);
|
|
110
110
|
const wrappingKey = await deriveWrappingKey(effective, salt);
|
|
111
|
-
return decryptWrappedKey(wrappingKey, wrappedKey,
|
|
111
|
+
return decryptWrappedKey(wrappingKey, wrappedKey, extractable);
|
|
112
112
|
}
|
|
113
113
|
async function rekey(oldPassphrase, newPassphrase, wrappedKey, salt, secretKey) {
|
|
114
114
|
const effectiveOld = await resolvePassphrase(oldPassphrase, secretKey);
|
|
@@ -2977,7 +2977,7 @@ zoo`.split("\n");
|
|
|
2977
2977
|
function generateMnemonic2() {
|
|
2978
2978
|
return generateMnemonic(wordlist, 256);
|
|
2979
2979
|
}
|
|
2980
|
-
async function recoverWithMnemonic(mnemonic) {
|
|
2980
|
+
async function recoverWithMnemonic(mnemonic, extractable = false) {
|
|
2981
2981
|
if (!validateMnemonic(mnemonic, wordlist)) {
|
|
2982
2982
|
throw new Error("Invalid mnemonic: phrase does not pass BIP-39 checksum validation");
|
|
2983
2983
|
}
|
|
@@ -2986,8 +2986,7 @@ async function recoverWithMnemonic(mnemonic) {
|
|
|
2986
2986
|
"raw",
|
|
2987
2987
|
entropy,
|
|
2988
2988
|
{ name: "AES-GCM", length: 256 },
|
|
2989
|
-
|
|
2990
|
-
// extractable: false — key is for use only, never exported
|
|
2989
|
+
extractable,
|
|
2991
2990
|
["encrypt", "decrypt"]
|
|
2992
2991
|
);
|
|
2993
2992
|
}
|
|
@@ -3001,7 +3000,7 @@ async function sealEnvelope(plaintext, passcode) {
|
|
|
3001
3000
|
);
|
|
3002
3001
|
const { ciphertext, iv } = await seal(dek, plaintext);
|
|
3003
3002
|
const { wrappedKey, salt } = await wrapKey(passcode, dek);
|
|
3004
|
-
return { ciphertext, iv, wrappedKey, salt };
|
|
3003
|
+
return { version: 1, ciphertext, iv, wrappedKey, salt };
|
|
3005
3004
|
}
|
|
3006
3005
|
async function unsealEnvelope(envelope, passcode) {
|
|
3007
3006
|
const dek = await unwrapKey(passcode, envelope.wrappedKey, envelope.salt);
|
|
@@ -3009,36 +3008,116 @@ async function unsealEnvelope(envelope, passcode) {
|
|
|
3009
3008
|
}
|
|
3010
3009
|
function encodeEnvelope(envelope) {
|
|
3011
3010
|
return JSON.stringify({
|
|
3011
|
+
version: envelope.version,
|
|
3012
3012
|
ciphertext: toBase64(envelope.ciphertext),
|
|
3013
3013
|
iv: toBase64(envelope.iv),
|
|
3014
3014
|
wrappedKey: toBase64(envelope.wrappedKey),
|
|
3015
3015
|
salt: toBase64(envelope.salt)
|
|
3016
|
-
}
|
|
3016
|
+
});
|
|
3017
3017
|
}
|
|
3018
3018
|
function decodeEnvelope(json) {
|
|
3019
3019
|
const p = JSON.parse(json);
|
|
3020
|
-
|
|
3021
|
-
|
|
3020
|
+
if (p["version"] !== 1) {
|
|
3021
|
+
throw new TypeError(`Invalid envelope: unsupported or missing 'version' field (got ${JSON.stringify(p["version"])})`);
|
|
3022
|
+
}
|
|
3023
|
+
const binaryFields = ["ciphertext", "iv", "wrappedKey", "salt"];
|
|
3024
|
+
for (const field of binaryFields) {
|
|
3022
3025
|
if (typeof p[field] !== "string") {
|
|
3023
3026
|
throw new TypeError(`Invalid envelope: missing or invalid '${field}' field`);
|
|
3024
3027
|
}
|
|
3025
3028
|
}
|
|
3026
3029
|
const validated = p;
|
|
3030
|
+
for (const field of binaryFields) {
|
|
3031
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(validated[field])) {
|
|
3032
|
+
throw new TypeError(`Invalid envelope: '${field}' is not valid base64`);
|
|
3033
|
+
}
|
|
3034
|
+
}
|
|
3027
3035
|
return {
|
|
3036
|
+
version: 1,
|
|
3028
3037
|
ciphertext: fromBase64(validated.ciphertext).buffer,
|
|
3029
3038
|
iv: fromBase64(validated.iv),
|
|
3030
3039
|
wrappedKey: fromBase64(validated.wrappedKey).buffer,
|
|
3031
3040
|
salt: fromBase64(validated.salt)
|
|
3032
3041
|
};
|
|
3033
3042
|
}
|
|
3043
|
+
|
|
3044
|
+
// src/circle.ts
|
|
3045
|
+
var JOIN_TTL_MS = 5 * 60 * 1e3;
|
|
3046
|
+
function buffersEqual(a, b) {
|
|
3047
|
+
const ua = new Uint8Array(a);
|
|
3048
|
+
const ub = new Uint8Array(b);
|
|
3049
|
+
if (ua.length !== ub.length) return false;
|
|
3050
|
+
let result = 0;
|
|
3051
|
+
for (let i = 0; i < ua.length; i++) result |= ua[i] ^ ub[i];
|
|
3052
|
+
return result === 0;
|
|
3053
|
+
}
|
|
3054
|
+
async function deriveVerificationCode(ephemeralPublicKey) {
|
|
3055
|
+
const key = await importPublicKeyFromJwk(ephemeralPublicKey, "ECDH");
|
|
3056
|
+
const raw = await crypto.subtle.exportKey("raw", key);
|
|
3057
|
+
const hash = await digest(raw);
|
|
3058
|
+
const bytes = new Uint8Array(hash);
|
|
3059
|
+
const hex = (b) => b.toString(16).padStart(2, "0").toUpperCase();
|
|
3060
|
+
return `${hex(bytes[0])}-${hex(bytes[1])}-${hex(bytes[2])}`;
|
|
3061
|
+
}
|
|
3062
|
+
async function initCircle(mnemonic, passphrase, secretKey) {
|
|
3063
|
+
const aek = await recoverWithMnemonic(mnemonic, true);
|
|
3064
|
+
const rawAEK = await crypto.subtle.exportKey("raw", aek);
|
|
3065
|
+
const aekCommitment = await digest(rawAEK);
|
|
3066
|
+
const { wrappedKey, salt } = await wrapKey(passphrase, aek, secretKey);
|
|
3067
|
+
const deviceId = crypto.randomUUID();
|
|
3068
|
+
return { wrappedAEK: { wrappedKey, salt }, aekCommitment, deviceId };
|
|
3069
|
+
}
|
|
3070
|
+
async function createJoinRequest(deviceMeta) {
|
|
3071
|
+
const { publicKey, privateKey } = await generateECDHKeyPair();
|
|
3072
|
+
const ephemeralPublicKey = await exportPublicKeyAsJwk(publicKey);
|
|
3073
|
+
const verificationCode = await deriveVerificationCode(ephemeralPublicKey);
|
|
3074
|
+
const deviceId = crypto.randomUUID();
|
|
3075
|
+
const request = {
|
|
3076
|
+
deviceId,
|
|
3077
|
+
ephemeralPublicKey,
|
|
3078
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3079
|
+
...deviceMeta ? { deviceMeta } : {}
|
|
3080
|
+
};
|
|
3081
|
+
return { request, ephemeralPrivateKey: privateKey, verificationCode };
|
|
3082
|
+
}
|
|
3083
|
+
async function authorizeJoin(joinRequest, wrappedAEK, passphrase, secretKey) {
|
|
3084
|
+
const age = Date.now() - new Date(joinRequest.createdAt).getTime();
|
|
3085
|
+
if (age > JOIN_TTL_MS) {
|
|
3086
|
+
throw new Error("authorizeJoin: join request has expired (older than 5 minutes)");
|
|
3087
|
+
}
|
|
3088
|
+
const aek = await unwrapKey(passphrase, wrappedAEK.wrappedKey, wrappedAEK.salt, secretKey, true);
|
|
3089
|
+
const rawAEK = await crypto.subtle.exportKey("raw", aek);
|
|
3090
|
+
const recipientPublicKey = await importPublicKeyFromJwk(joinRequest.ephemeralPublicKey, "ECDH");
|
|
3091
|
+
const { ciphertext, iv, ephemeralPublicKey } = await sealMessage(recipientPublicKey, rawAEK);
|
|
3092
|
+
return { ciphertext, iv, ephemeralPublicKey };
|
|
3093
|
+
}
|
|
3094
|
+
async function finalizeJoin(sealedAEK, ephemeralPrivateKey, passphrase, secretKey, aekCommitment) {
|
|
3095
|
+
const rawAEK = await unsealMessage(
|
|
3096
|
+
ephemeralPrivateKey,
|
|
3097
|
+
sealedAEK.ciphertext,
|
|
3098
|
+
sealedAEK.iv,
|
|
3099
|
+
sealedAEK.ephemeralPublicKey
|
|
3100
|
+
);
|
|
3101
|
+
const actualCommitment = await digest(rawAEK);
|
|
3102
|
+
if (!buffersEqual(actualCommitment, aekCommitment)) {
|
|
3103
|
+
throw new Error("finalizeJoin: AEK commitment mismatch \u2014 key may have been tampered with");
|
|
3104
|
+
}
|
|
3105
|
+
const aek = await importAesKey(rawAEK, true);
|
|
3106
|
+
const { wrappedKey, salt } = await wrapKey(passphrase, aek, secretKey);
|
|
3107
|
+
return { wrappedAEK: { wrappedKey, salt } };
|
|
3108
|
+
}
|
|
3034
3109
|
export {
|
|
3035
3110
|
AEK_KEY_ID,
|
|
3111
|
+
authorizeJoin,
|
|
3036
3112
|
combinePassphraseAndSecretKey,
|
|
3113
|
+
createJoinRequest,
|
|
3037
3114
|
decodeEnvelope,
|
|
3038
3115
|
deleteCryptoKey,
|
|
3116
|
+
deriveVerificationCode,
|
|
3039
3117
|
digest,
|
|
3040
3118
|
encodeEnvelope,
|
|
3041
3119
|
exportPublicKeyAsJwk,
|
|
3120
|
+
finalizeJoin,
|
|
3042
3121
|
fromBase64,
|
|
3043
3122
|
fromBase64Url,
|
|
3044
3123
|
generateAesKey,
|
|
@@ -3049,6 +3128,7 @@ export {
|
|
|
3049
3128
|
importAesKey,
|
|
3050
3129
|
importPublicKeyFromJwk,
|
|
3051
3130
|
init,
|
|
3131
|
+
initCircle,
|
|
3052
3132
|
loadCryptoKey,
|
|
3053
3133
|
recoverWithMnemonic,
|
|
3054
3134
|
rekey,
|