@consealdev/conseal 0.0.0-stage → 0.5.2
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 +661 -0
- package/README.md +192 -2
- package/dist/.gitkeep +0 -0
- package/dist/index.d.ts +347 -0
- package/dist/index.js +3188 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -1,3 +1,193 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/consealdev/conseal/main/docs/assets/logo-dark.svg">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/consealdev/conseal/main/docs/assets/logo-light.svg" style="width: 4rem; height: 4rem;" alt="Conseal logo">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
<h1 align="center">Conseal</h1>
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
Zero-knowledge cryptography and private communication.
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @consealdev/conseal
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { seal, unseal, generateAesKey } from '@consealdev/conseal'
|
|
26
|
+
|
|
27
|
+
// Generate a key and encrypt
|
|
28
|
+
const key = await generateAesKey()
|
|
29
|
+
const plaintext = await file.arrayBuffer()
|
|
30
|
+
const { ciphertext, iv } = await seal(key, plaintext)
|
|
31
|
+
|
|
32
|
+
// Decrypt
|
|
33
|
+
const result = await unseal(key, ciphertext, iv)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## API
|
|
37
|
+
|
|
38
|
+
### Symmetric encryption (AES-256-GCM)
|
|
39
|
+
|
|
40
|
+
| Function | Description |
|
|
41
|
+
|---|---|
|
|
42
|
+
| `seal(key, plaintext, additionalData?)` | Encrypts with a random 96-bit IV. Returns `{ ciphertext, iv }`. |
|
|
43
|
+
| `unseal(key, ciphertext, iv, additionalData?)` | Decrypts. Throws on tampered data or AAD mismatch. |
|
|
44
|
+
| `generateAesKey(extractable?)` | Generates a random AES-256 key. |
|
|
45
|
+
| `importAesKey(raw, extractable?)` | Imports raw key bytes as a CryptoKey. |
|
|
46
|
+
|
|
47
|
+
### Passphrase key wrapping (PBKDF2 + AES-KW)
|
|
48
|
+
|
|
49
|
+
| Function | Description |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `wrapKey(passphrase, key)` | Wraps a CryptoKey with a passphrase. Returns `{ wrappedKey, salt }`. |
|
|
52
|
+
| `unwrapKey(passphrase, wrappedKey, salt)` | Unwraps. Throws on wrong passphrase. |
|
|
53
|
+
| `rekey(oldPass, newPass, wrappedKey, salt)` | Changes passphrase without re-encrypting data. |
|
|
54
|
+
|
|
55
|
+
### Asymmetric encryption (ECDH P-256)
|
|
56
|
+
|
|
57
|
+
| Function | Description |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `sealMessage(recipientPublicKey, plaintext)` | Encrypts for a recipient using ephemeral ECDH. |
|
|
60
|
+
| `unsealMessage(privateKey, ciphertext, iv, ephemeralPublicKey)` | Decrypts with the recipient's private key. |
|
|
61
|
+
| `generateECDHKeyPair()` | Generates a P-256 ECDH key pair. |
|
|
62
|
+
|
|
63
|
+
### Digital signatures (ECDSA P-256)
|
|
64
|
+
|
|
65
|
+
| Function | Description |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `sign(privateKey, data)` | Signs data with ECDSA-SHA256. |
|
|
68
|
+
| `verify(publicKey, signature, data)` | Verifies a signature. Returns `true` or `false`. |
|
|
69
|
+
| `generateECDSAKeyPair()` | Generates a P-256 ECDSA key pair. |
|
|
70
|
+
|
|
71
|
+
### Envelope encryption (passcode-protected)
|
|
72
|
+
|
|
73
|
+
| Function | Description |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `sealEnvelope(plaintext, passcode)` | Encrypts for a recipient without a Conseal account. |
|
|
76
|
+
| `unsealEnvelope(envelope, passcode)` | Decrypts with the passcode. |
|
|
77
|
+
| `encodeEnvelope(envelope)` | Serialises a `SealedEnvelope` to JSON. |
|
|
78
|
+
| `decodeEnvelope(json)` | Deserialises JSON back to a `SealedEnvelope`. |
|
|
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
|
+
|
|
110
|
+
### Device initialisation
|
|
111
|
+
|
|
112
|
+
| Function | Description |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `init(wrappedKey, salt, passphrase)` | Unwraps the AEK and stores it in IndexedDB. |
|
|
115
|
+
| `AEK_KEY_ID` | The IndexedDB key id for the AEK (`'aek'`). |
|
|
116
|
+
|
|
117
|
+
### Mnemonic recovery (BIP-39)
|
|
118
|
+
|
|
119
|
+
| Function | Description |
|
|
120
|
+
|---|---|
|
|
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`. |
|
|
123
|
+
|
|
124
|
+
### Key serialisation (JWK)
|
|
125
|
+
|
|
126
|
+
| Function | Description |
|
|
127
|
+
|---|---|
|
|
128
|
+
| `exportPublicKeyAsJwk(key)` | Exports a public CryptoKey to JWK. |
|
|
129
|
+
| `importPublicKeyFromJwk(jwk, algorithm)` | Imports a JWK as a CryptoKey (`'ECDH'` or `'ECDSA'`). |
|
|
130
|
+
|
|
131
|
+
### IndexedDB key storage
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { saveCryptoKey, loadCryptoKey, deleteCryptoKey } from '@consealdev/conseal'
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Function | Description |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `saveCryptoKey(name, key)` | Persists a CryptoKey to IndexedDB. |
|
|
140
|
+
| `loadCryptoKey(name)` | Loads a CryptoKey. Returns `null` if not found. |
|
|
141
|
+
| `deleteCryptoKey(name)` | Deletes a CryptoKey. |
|
|
142
|
+
|
|
143
|
+
### Utilities
|
|
144
|
+
|
|
145
|
+
| Function | Description |
|
|
146
|
+
|---|---|
|
|
147
|
+
| `toBase64(buf)` / `fromBase64(b64)` | Standard base64 encoding/decoding. |
|
|
148
|
+
| `toBase64Url(buf)` / `fromBase64Url(b64)` | URL-safe base64 (no padding). |
|
|
149
|
+
| `digest(data)` | SHA-256 hash. |
|
|
150
|
+
|
|
151
|
+
## Design
|
|
152
|
+
|
|
153
|
+
- **Zero runtime secrets on the server.** All encryption and decryption happens in the browser. The server stores only wrapped keys and ciphertext.
|
|
154
|
+
- **SubtleCrypto everywhere.** No OpenSSL, no polyfills, no WASM. The only runtime dependency is [`@scure/bip39`](https://github.com/nicolo-ribaudo/noble-bip39) for mnemonic wordlists (bundled into the output).
|
|
155
|
+
- **Non-extractable keys.** Keys stored in IndexedDB have `extractable: false` — JavaScript cannot read the raw bytes, only use them for encrypt/decrypt.
|
|
156
|
+
- **PBKDF2 at 600,000 iterations.** Passphrase-derived keys use SHA-256 with a 128-bit random salt per wrap. Intentionally slow to resist offline brute-force.
|
|
157
|
+
|
|
158
|
+
## Requirements
|
|
159
|
+
|
|
160
|
+
- Browser with [SubtleCrypto](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto) support (all modern browsers)
|
|
161
|
+
- Node.js >= 18 (for testing / SSR with `globalThis.crypto`)
|
|
162
|
+
|
|
163
|
+
## Development
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm install
|
|
167
|
+
npm test # unit tests (Vitest + happy-dom)
|
|
168
|
+
npm run build # build to dist/
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Cross-browser tests
|
|
172
|
+
|
|
173
|
+
SubtleCrypto behaviour is not identical across engines. The browser suite runs the full test suite in real Chromium, Firefox, and WebKit engines via Playwright.
|
|
174
|
+
|
|
175
|
+
First-time setup — download browser binaries (~300 MB, one-off):
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
npx playwright install
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Then run:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
npm run test:browser
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
WebKit is the highest-value target: every browser on iOS uses WebKit under the hood regardless of brand, so this provides real Safari/iOS coverage without a device.
|
|
188
|
+
|
|
189
|
+
Both suites run automatically on CI for every push and pull request to `main`.
|
|
190
|
+
|
|
191
|
+
## License
|
|
192
|
+
|
|
193
|
+
Dual-licensed under [AGPL-3.0](LICENSE) and a [commercial license](COMMERCIAL-LICENSE.md).
|
package/dist/.gitkeep
ADDED
|
File without changes
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
/** Result of a seal() call — ciphertext with auth tag appended, and the IV used. */
|
|
2
|
+
type SealedData = {
|
|
3
|
+
ciphertext: ArrayBuffer;
|
|
4
|
+
iv: Uint8Array;
|
|
5
|
+
};
|
|
6
|
+
/** Encrypts plaintext with AES-256-GCM. Returns ciphertext (with auth tag appended) and IV. */
|
|
7
|
+
declare function seal(key: CryptoKey, plaintext: ArrayBuffer, additionalData?: ArrayBuffer | Uint8Array): Promise<SealedData>;
|
|
8
|
+
/** Decrypts AES-256-GCM ciphertext. Throws if the auth tag check fails. */
|
|
9
|
+
declare function unseal(key: CryptoKey, ciphertext: ArrayBuffer, iv: Uint8Array, additionalData?: ArrayBuffer | Uint8Array): Promise<ArrayBuffer>;
|
|
10
|
+
/**
|
|
11
|
+
* Generates a random AES-256-GCM CryptoKey.
|
|
12
|
+
* Pass extractable: true when the key must be wrapped before storage (e.g. via wrapKey).
|
|
13
|
+
*/
|
|
14
|
+
declare function generateAesKey(extractable?: boolean): Promise<CryptoKey>;
|
|
15
|
+
/**
|
|
16
|
+
* Imports raw key bytes as an AES-256-GCM CryptoKey.
|
|
17
|
+
* Pass extractable: true when the key must be wrapped before storage (e.g. via wrapKey).
|
|
18
|
+
*/
|
|
19
|
+
declare function importAesKey(raw: ArrayBuffer | Uint8Array, extractable?: boolean): Promise<CryptoKey>;
|
|
20
|
+
|
|
21
|
+
/** Wraps a CryptoKey with a passphrase (and optional Secret Key). The input key must have extractable: true. */
|
|
22
|
+
declare function wrapKey(passphrase: string, key: CryptoKey, secretKey?: Uint8Array): Promise<{
|
|
23
|
+
wrappedKey: ArrayBuffer;
|
|
24
|
+
salt: Uint8Array;
|
|
25
|
+
}>;
|
|
26
|
+
/**
|
|
27
|
+
* Unwraps a CryptoKey. Returns extractable: false by default.
|
|
28
|
+
* Pass extractable: true only when the raw key bytes are needed for transfer
|
|
29
|
+
* (e.g. circle join ceremony) — export and discard as quickly as possible.
|
|
30
|
+
*/
|
|
31
|
+
declare function unwrapKey(passphrase: string, wrappedKey: ArrayBuffer, salt: Uint8Array, secretKey?: Uint8Array, extractable?: boolean): Promise<CryptoKey>;
|
|
32
|
+
/**
|
|
33
|
+
* Changes the passphrase protecting the AEK without re-encrypting any content.
|
|
34
|
+
* The Secret Key (if any) stays the same — it is used on both the unwrap and re-wrap sides.
|
|
35
|
+
*/
|
|
36
|
+
declare function rekey(oldPassphrase: string, newPassphrase: string, wrappedKey: ArrayBuffer, salt: Uint8Array, secretKey?: Uint8Array): Promise<{
|
|
37
|
+
wrappedKey: ArrayBuffer;
|
|
38
|
+
salt: Uint8Array;
|
|
39
|
+
}>;
|
|
40
|
+
/**
|
|
41
|
+
* Rotates the Secret Key while keeping the passphrase the same.
|
|
42
|
+
* Use only when the Secret Key is compromised — this is a rare, heavyweight operation.
|
|
43
|
+
* Unwraps with passphrase + oldSecretKey, re-wraps with passphrase + newSecretKey.
|
|
44
|
+
*
|
|
45
|
+
* @param passphrase - the passphrase (unchanged)
|
|
46
|
+
* @param oldSecretKey - the current Secret Key (used to unwrap)
|
|
47
|
+
* @param newSecretKey - the replacement Secret Key (used to re-wrap)
|
|
48
|
+
* @param wrappedKey - the currently wrapped AEK
|
|
49
|
+
* @param salt - the salt used when the AEK was wrapped
|
|
50
|
+
*/
|
|
51
|
+
declare function rekeySecretKey(passphrase: string, oldSecretKey: Uint8Array, newSecretKey: Uint8Array, wrappedKey: ArrayBuffer, salt: Uint8Array): Promise<{
|
|
52
|
+
wrappedKey: ArrayBuffer;
|
|
53
|
+
salt: Uint8Array;
|
|
54
|
+
}>;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 128-bit Secret Key — second factor for AEK wrapping.
|
|
58
|
+
*
|
|
59
|
+
* generateSecretKey() generates a random 128-bit secret key. Call once
|
|
60
|
+
* at account setup and store device-side (localStorage)
|
|
61
|
+
* plus an offline copy for recovery.
|
|
62
|
+
*
|
|
63
|
+
* combinePassphraseAndSecretKey() HMAC-SHA-256(key=secretKey, msg=passphrase),
|
|
64
|
+
* hex-encoded. The secretKey is used as the HMAC key
|
|
65
|
+
* and the passphrase as the message, which is the
|
|
66
|
+
* standard construction for combining two inputs.
|
|
67
|
+
* Neither factor is recoverable from the result.
|
|
68
|
+
* The output is fed into PBKDF2 as the password.
|
|
69
|
+
*/
|
|
70
|
+
/** Generates a random 128-bit (16-byte) secret key. */
|
|
71
|
+
declare function generateSecretKey(): Uint8Array;
|
|
72
|
+
/**
|
|
73
|
+
* Combines a passphrase and secret key into a single opaque string for PBKDF2.
|
|
74
|
+
* HMAC-SHA-256(key=secretKey, msg=passphrase), hex-encoded.
|
|
75
|
+
*
|
|
76
|
+
* Using HMAC with the secretKey as the key is the standard construction for
|
|
77
|
+
* combining two inputs — it avoids length-extension concerns and provides
|
|
78
|
+
* cryptographic binding of both factors.
|
|
79
|
+
*/
|
|
80
|
+
declare function combinePassphraseAndSecretKey(passphrase: string, secretKey: Uint8Array): Promise<string>;
|
|
81
|
+
|
|
82
|
+
/** Generates a long-term ECDH P-256 key pair for an account identity. */
|
|
83
|
+
declare function generateECDHKeyPair(): Promise<CryptoKeyPair>;
|
|
84
|
+
/** Encrypts plaintext for a recipient. Only the recipient's private key can decrypt. */
|
|
85
|
+
declare function sealMessage(recipientPublicKey: CryptoKey, plaintext: ArrayBuffer): Promise<{
|
|
86
|
+
ciphertext: ArrayBuffer;
|
|
87
|
+
iv: Uint8Array;
|
|
88
|
+
ephemeralPublicKey: JsonWebKey;
|
|
89
|
+
}>;
|
|
90
|
+
/** Decrypts a message sealed with the recipient's public key. */
|
|
91
|
+
declare function unsealMessage(recipientPrivateKey: CryptoKey, ciphertext: ArrayBuffer, iv: Uint8Array, ephemeralPublicKey: JsonWebKey): Promise<ArrayBuffer>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* ECDSA P-256 signing for sender verification.
|
|
95
|
+
*
|
|
96
|
+
* Provides cryptographic proof that a file or message came from the claimed sender.
|
|
97
|
+
* The sender signs with their private key; any recipient who has the sender's public
|
|
98
|
+
* key can verify the signature.
|
|
99
|
+
*
|
|
100
|
+
* sign() produces a raw ECDSA signature over arbitrary data.
|
|
101
|
+
* verify() checks a signature against data using the signer's public key.
|
|
102
|
+
* Returns true if valid, false if not — never throws on invalid signatures.
|
|
103
|
+
*
|
|
104
|
+
* Hash: SHA-256.
|
|
105
|
+
*/
|
|
106
|
+
/** Generates a long-term ECDSA P-256 key pair for an account identity. */
|
|
107
|
+
declare function generateECDSAKeyPair(): Promise<CryptoKeyPair>;
|
|
108
|
+
/** Signs data with the sender's ECDSA private key. */
|
|
109
|
+
declare function sign(privateKey: CryptoKey, data: ArrayBuffer): Promise<ArrayBuffer>;
|
|
110
|
+
/** Verifies a signature against data using the sender's ECDSA public key. */
|
|
111
|
+
declare function verify(publicKey: CryptoKey, signature: ArrayBuffer, data: ArrayBuffer): Promise<boolean>;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* JWK serialisation for public keys.
|
|
115
|
+
*
|
|
116
|
+
* exportPublicKeyAsJwk() serialises a CryptoKey to a JSON Web Key — used when
|
|
117
|
+
* registering a public key with the Conseal identity registry.
|
|
118
|
+
*
|
|
119
|
+
* importPublicKeyFromJwk() deserialises a JWK back to a CryptoKey — used when
|
|
120
|
+
* loading a recipient's public key before encrypting a message.
|
|
121
|
+
*
|
|
122
|
+
* Only public keys are exported. Private keys are never extracted.
|
|
123
|
+
*/
|
|
124
|
+
/** Serialises a public CryptoKey to a JSON Web Key object. */
|
|
125
|
+
declare function exportPublicKeyAsJwk(key: CryptoKey): Promise<JsonWebKey>;
|
|
126
|
+
/** Deserialises a JSON Web Key to a CryptoKey for the given algorithm. */
|
|
127
|
+
declare function importPublicKeyFromJwk(jwk: JsonWebKey, algorithm: 'ECDH' | 'ECDSA'): Promise<CryptoKey>;
|
|
128
|
+
|
|
129
|
+
/** The IndexedDB key id under which the Account Encryption Key is stored after init(). */
|
|
130
|
+
declare const AEK_KEY_ID = "aek";
|
|
131
|
+
/** Persists a CryptoKey to IndexedDB under the given name. Overwrites if name exists. */
|
|
132
|
+
declare function saveCryptoKey(name: string, key: CryptoKey): Promise<void>;
|
|
133
|
+
/** Loads a CryptoKey from IndexedDB. Returns null if the name is not found. */
|
|
134
|
+
declare function loadCryptoKey(name: string): Promise<CryptoKey | null>;
|
|
135
|
+
/** Removes a CryptoKey from IndexedDB. No-op if the name does not exist. */
|
|
136
|
+
declare function deleteCryptoKey(name: string): Promise<void>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Unwraps the AEK with the given passphrase (and optional Secret Key) and
|
|
140
|
+
* stores it in IndexedDB. After this completes, the AEK is available via
|
|
141
|
+
* load(AEK_KEY_ID).
|
|
142
|
+
*/
|
|
143
|
+
declare function init(wrappedKey: ArrayBuffer, salt: Uint8Array, passphrase: string, secretKey?: Uint8Array): Promise<void>;
|
|
144
|
+
|
|
145
|
+
/** Generates a fresh 24-word BIP-39 mnemonic (256 bits of entropy). */
|
|
146
|
+
declare function generateMnemonic(): string;
|
|
147
|
+
/**
|
|
148
|
+
* Derives a deterministic AES-256-GCM CryptoKey from a BIP-39 mnemonic.
|
|
149
|
+
* Throws if the mnemonic is invalid or not in the BIP-39 word list.
|
|
150
|
+
*
|
|
151
|
+
* The mnemonic's 32-byte entropy is passed through HKDF-SHA-256 with a
|
|
152
|
+
* domain-separation label before being imported as key material. This adds
|
|
153
|
+
* defense-in-depth: if the mnemonic came from a tool with weaker entropy, HKDF
|
|
154
|
+
* still produces a well-distributed key; it also ensures key-domain separation
|
|
155
|
+
* if the same entropy is ever used for another purpose.
|
|
156
|
+
*
|
|
157
|
+
* Pass extractable: true when the key must be wrapped before storage —
|
|
158
|
+
* e.g. when passing it to initCircle().
|
|
159
|
+
*/
|
|
160
|
+
declare function recoverWithMnemonic(mnemonic: string, extractable?: boolean): Promise<CryptoKey>;
|
|
161
|
+
|
|
162
|
+
/** Encrypts plaintext and wraps the key with a passcode, returning a SealedEnvelope. */
|
|
163
|
+
declare function sealEnvelope(plaintext: ArrayBuffer, passcode: string): Promise<SealedEnvelope>;
|
|
164
|
+
/** Decrypts a SealedEnvelope using the passcode. Throws if the passcode is wrong. */
|
|
165
|
+
declare function unsealEnvelope(envelope: SealedEnvelope, passcode: string): Promise<ArrayBuffer>;
|
|
166
|
+
/**
|
|
167
|
+
* The in-memory representation produced by sealEnvelope().
|
|
168
|
+
*
|
|
169
|
+
* **Not directly JSON-serialisable.** ArrayBuffer and Uint8Array fields are
|
|
170
|
+
* silently lost or corrupted by JSON.stringify. Use encodeEnvelope() to produce
|
|
171
|
+
* a JSON string and decodeEnvelope() to reconstruct this type from one.
|
|
172
|
+
*/
|
|
173
|
+
interface SealedEnvelope {
|
|
174
|
+
/** Format version — always 1 for envelopes produced by this library. */
|
|
175
|
+
version: 1;
|
|
176
|
+
ciphertext: ArrayBuffer;
|
|
177
|
+
iv: Uint8Array;
|
|
178
|
+
wrappedKey: ArrayBuffer;
|
|
179
|
+
salt: Uint8Array;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Serialises a SealedEnvelope to a JSON string.
|
|
183
|
+
* Each binary field is base64-encoded. Safe to store server-side or pass over text channels.
|
|
184
|
+
*/
|
|
185
|
+
declare function encodeEnvelope(envelope: SealedEnvelope): string;
|
|
186
|
+
/**
|
|
187
|
+
* Deserialises a JSON string produced by encodeEnvelope() back to a SealedEnvelope.
|
|
188
|
+
* Throws SyntaxError if the string is not valid JSON.
|
|
189
|
+
* Throws TypeError if required fields are missing, the wrong type, or the version is unsupported.
|
|
190
|
+
*/
|
|
191
|
+
declare function decodeEnvelope(json: string): SealedEnvelope;
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Base64 encoding and decoding utilities.
|
|
195
|
+
*
|
|
196
|
+
* Helper functions for converting between binary data (ArrayBuffer / Uint8Array)
|
|
197
|
+
* and standard base64 strings — used when serialising encrypted payloads for
|
|
198
|
+
* storage or transmission over text-based channels.
|
|
199
|
+
*
|
|
200
|
+
* Two variants are provided:
|
|
201
|
+
* toBase64 / fromBase64 — standard base64 (uses +, /, = padding)
|
|
202
|
+
* toBase64Url / fromBase64Url — base64url (uses -, _, no padding; safe in URLs and JWKs)
|
|
203
|
+
*/
|
|
204
|
+
/**
|
|
205
|
+
* Encodes an ArrayBuffer or Uint8Array to a standard base64 string.
|
|
206
|
+
* Uses a loop instead of spread to avoid stack overflow on large buffers.
|
|
207
|
+
*/
|
|
208
|
+
declare function toBase64(buf: ArrayBuffer | Uint8Array): string;
|
|
209
|
+
/** Decodes a standard base64 string to a Uint8Array. */
|
|
210
|
+
declare function fromBase64(b64: string): Uint8Array;
|
|
211
|
+
/**
|
|
212
|
+
* Encodes an ArrayBuffer or Uint8Array to a base64url string.
|
|
213
|
+
* Replaces + with -, / with _, and strips = padding.
|
|
214
|
+
* Used for JWK coordinates and URL-safe contexts.
|
|
215
|
+
*/
|
|
216
|
+
declare function toBase64Url(buf: ArrayBuffer | Uint8Array): string;
|
|
217
|
+
/** Decodes a base64url string to a Uint8Array. */
|
|
218
|
+
declare function fromBase64Url(b64url: string): Uint8Array;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* SHA-256 digest.
|
|
222
|
+
*
|
|
223
|
+
* Thin wrapper around SubtleCrypto.digest for the hash function used throughout
|
|
224
|
+
* this library — key fingerprinting, content hashing, and integrity checks.
|
|
225
|
+
*/
|
|
226
|
+
/** Returns the SHA-256 hash of the input data as an ArrayBuffer. */
|
|
227
|
+
declare function digest(data: ArrayBuffer | Uint8Array): Promise<ArrayBuffer>;
|
|
228
|
+
|
|
229
|
+
/** Wrapped AEK — opaque blob stored server-side per device. */
|
|
230
|
+
interface WrappedAEK {
|
|
231
|
+
wrappedKey: ArrayBuffer;
|
|
232
|
+
salt: Uint8Array;
|
|
233
|
+
}
|
|
234
|
+
/** Serializable join request payload sent by a new device to the backend. */
|
|
235
|
+
interface JoinRequest {
|
|
236
|
+
deviceId: string;
|
|
237
|
+
ephemeralPublicKey: JsonWebKey;
|
|
238
|
+
/**
|
|
239
|
+
* ISO 8601 timestamp set by the joining device. `authorizeJoin` uses this to
|
|
240
|
+
* reject stale requests, but the check is only as trustworthy as the device
|
|
241
|
+
* that set the value. Callers should independently validate request freshness
|
|
242
|
+
* server-side (e.g. record when the backend first received the request and
|
|
243
|
+
* reject if that server-stamped age exceeds the TTL).
|
|
244
|
+
*/
|
|
245
|
+
createdAt: string;
|
|
246
|
+
deviceMeta?: {
|
|
247
|
+
name?: string;
|
|
248
|
+
platform?: string;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/** AEK sealed for a specific device's ephemeral public key via ECDH. */
|
|
252
|
+
interface SealedAEK {
|
|
253
|
+
ciphertext: ArrayBuffer;
|
|
254
|
+
iv: Uint8Array;
|
|
255
|
+
ephemeralPublicKey: JsonWebKey;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Derives a human-readable verification code from an ECDH public key JWK.
|
|
259
|
+
*
|
|
260
|
+
* Exports the key as its uncompressed point bytes (65 bytes for P-256),
|
|
261
|
+
* SHA-256 hashes them, and formats the first 4 bytes as uppercase hex pairs
|
|
262
|
+
* separated by dashes: e.g. "A3-4F-9C-12" (32 bits / ~4 billion possible codes).
|
|
263
|
+
*
|
|
264
|
+
* Both the new device (createJoinRequest) and the authorizing device
|
|
265
|
+
* (authorizeJoin) call this with the same JWK and must get the same code.
|
|
266
|
+
* The user confirms the codes match out-of-band before approval proceeds.
|
|
267
|
+
*
|
|
268
|
+
* 32-bit space makes brute-force injection of a matching fake key pair
|
|
269
|
+
* infeasible within the 5-minute TTL window.
|
|
270
|
+
*/
|
|
271
|
+
declare function deriveVerificationCode(ephemeralPublicKey: JsonWebKey): Promise<string>;
|
|
272
|
+
/**
|
|
273
|
+
* Called once by the founding device when creating a new account.
|
|
274
|
+
*
|
|
275
|
+
* Derives the shared AEK from the mnemonic (so it can always be recovered),
|
|
276
|
+
* publishes its SHA-256 commitment (so joining devices can verify the AEK was
|
|
277
|
+
* not substituted), and wraps the AEK under the founding device's own
|
|
278
|
+
* passphrase + secretKey.
|
|
279
|
+
*
|
|
280
|
+
* The mnemonic is the root of trust — it must be shown to the user at account
|
|
281
|
+
* creation and never stored. The app stores wrappedAEK and aekCommitment on
|
|
282
|
+
* the server.
|
|
283
|
+
*/
|
|
284
|
+
declare function initCircle(mnemonic: string, passphrase: string, secretKey: Uint8Array): Promise<{
|
|
285
|
+
wrappedAEK: WrappedAEK;
|
|
286
|
+
aekCommitment: ArrayBuffer;
|
|
287
|
+
deviceId: string;
|
|
288
|
+
}>;
|
|
289
|
+
/**
|
|
290
|
+
* Called by a new device that wants to join the circle.
|
|
291
|
+
*
|
|
292
|
+
* Generates an ephemeral ECDH P-256 key pair for the one-time ceremony.
|
|
293
|
+
* The ephemeral private key is returned to the caller and must be held in
|
|
294
|
+
* memory only — never persisted — until finalizeJoin completes.
|
|
295
|
+
*
|
|
296
|
+
* The verification code is derived from the ephemeral public key and must
|
|
297
|
+
* be displayed prominently so the user can confirm it matches the code shown
|
|
298
|
+
* on the authorizing device.
|
|
299
|
+
*/
|
|
300
|
+
declare function createJoinRequest(deviceMeta?: {
|
|
301
|
+
name?: string;
|
|
302
|
+
platform?: string;
|
|
303
|
+
}): Promise<{
|
|
304
|
+
request: JoinRequest;
|
|
305
|
+
ephemeralPrivateKey: CryptoKey;
|
|
306
|
+
verificationCode: string;
|
|
307
|
+
}>;
|
|
308
|
+
/**
|
|
309
|
+
* Called by a trusted device to approve a new device joining the circle.
|
|
310
|
+
*
|
|
311
|
+
* Rejects stale join requests based on `serverReceivedAt` — the Unix-ms
|
|
312
|
+
* timestamp recorded by the server when the join request first arrived.
|
|
313
|
+
* Callers MUST pass the server-stamped time, not `joinRequest.createdAt`,
|
|
314
|
+
* which is set by the joining device and can be forged.
|
|
315
|
+
*
|
|
316
|
+
* The caller must present the verification code from
|
|
317
|
+
* joinRequest.ephemeralPublicKey and require explicit user confirmation that
|
|
318
|
+
* it matches the code on the new device before calling this function —
|
|
319
|
+
* calling without confirmation bypasses the primary MITM defence.
|
|
320
|
+
*
|
|
321
|
+
* Unwraps the AEK and seals its raw bytes for the new device's ephemeral
|
|
322
|
+
* public key via ECDH. Only the ephemeral private key held by the new device
|
|
323
|
+
* can open it.
|
|
324
|
+
*
|
|
325
|
+
* @param serverReceivedAt Unix milliseconds — when the server first received
|
|
326
|
+
* this join request. Use `Date.now()` at receipt time on the server, NOT
|
|
327
|
+
* `new Date(joinRequest.createdAt).getTime()`. Passing the client-supplied
|
|
328
|
+
* timestamp defeats TTL enforcement.
|
|
329
|
+
*/
|
|
330
|
+
declare function authorizeJoin(joinRequest: JoinRequest, wrappedAEK: WrappedAEK, passphrase: string, secretKey: Uint8Array, serverReceivedAt: number): Promise<SealedAEK>;
|
|
331
|
+
/**
|
|
332
|
+
* Called by the new device after authorization.
|
|
333
|
+
*
|
|
334
|
+
* Unseals the AEK using the ephemeral private key generated in
|
|
335
|
+
* createJoinRequest, verifies it against aekCommitment (SHA-256 of the raw
|
|
336
|
+
* AEK published by initCircle), then re-wraps it under the new device's own
|
|
337
|
+
* passphrase + secretKey. The ephemeral private key is not needed after this
|
|
338
|
+
* call and should be discarded.
|
|
339
|
+
*
|
|
340
|
+
* Throws if the commitment check fails — the AEK was substituted or tampered
|
|
341
|
+
* with in transit. Do not use the returned wrappedAEK if this throws.
|
|
342
|
+
*/
|
|
343
|
+
declare function finalizeJoin(sealedAEK: SealedAEK, ephemeralPrivateKey: CryptoKey, passphrase: string, secretKey: Uint8Array, aekCommitment: ArrayBuffer): Promise<{
|
|
344
|
+
wrappedAEK: WrappedAEK;
|
|
345
|
+
}>;
|
|
346
|
+
|
|
347
|
+
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 };
|