@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 CHANGED
@@ -8,8 +8,7 @@
8
8
  <h1 align="center">Conseal</h1>
9
9
 
10
10
  <p align="center">
11
- Browser-side zero-knowledge cryptography library.<br>
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
- /** Unwraps a CryptoKey. Always returns extractable: false. */
37
- declare function unwrapKey(passphrase: string, wrappedKey: ArrayBuffer, salt: Uint8Array, secretKey?: Uint8Array): Promise<CryptoKey>;
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 not strings.
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
- export { AEK_KEY_ID, type SealedEnvelope, combinePassphraseAndSecretKey, decodeEnvelope, deleteCryptoKey, digest, encodeEnvelope, exportPublicKeyAsJwk, fromBase64, fromBase64Url, generateAesKey, generateECDHKeyPair, generateECDSAKeyPair, generateMnemonic, generateSecretKey, importAesKey, importPublicKeyFromJwk, init, loadCryptoKey, recoverWithMnemonic, rekey, rekeySecretKey, saveCryptoKey, seal, sealEnvelope, sealMessage, sign, toBase64, toBase64Url, unseal, unsealEnvelope, unsealMessage, unwrapKey, verify, wrapKey };
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, false);
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
- false,
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
- }, null, 2);
3016
+ });
3017
3017
  }
3018
3018
  function decodeEnvelope(json) {
3019
3019
  const p = JSON.parse(json);
3020
- const required = ["ciphertext", "iv", "wrappedKey", "salt"];
3021
- for (const field of required) {
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codesense/conseal",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "Browser-side zero-knowledge cryptography library using SubtleCrypto.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",