@ursalock/crypto 0.2.1

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.
@@ -0,0 +1,285 @@
1
+ import { CipherJWK } from '@z-base/cryptosuite';
2
+ export { CipherJWK } from '@z-base/cryptosuite';
3
+
4
+ /**
5
+ * Interfaces for crypto operations (Dependency Inversion Principle)
6
+ * Allows testing and alternative implementations
7
+ */
8
+ /** Encrypted data structure */
9
+ interface IEncryptedPayload {
10
+ /** Initialization vector */
11
+ iv: Uint8Array;
12
+ /** Ciphertext with auth tag */
13
+ ciphertext: Uint8Array;
14
+ /** Combined iv + ciphertext for storage */
15
+ combined: Uint8Array;
16
+ }
17
+ /** Crypto provider interface for encryption/decryption */
18
+ interface ICryptoProvider {
19
+ /**
20
+ * Encrypt data with AES-GCM
21
+ * @param plaintext Data to encrypt
22
+ * @param key 256-bit encryption key
23
+ * @param iv Optional IV (generated if not provided)
24
+ */
25
+ encrypt(plaintext: Uint8Array, key: Uint8Array, iv?: Uint8Array): Promise<IEncryptedPayload>;
26
+ /**
27
+ * Decrypt data with AES-GCM
28
+ * @param encrypted Encrypted payload or combined bytes
29
+ * @param key 256-bit encryption key
30
+ */
31
+ decrypt(encrypted: Uint8Array | IEncryptedPayload, key: Uint8Array): Promise<Uint8Array>;
32
+ }
33
+ /** Key derivation provider interface */
34
+ interface IKeyDerivationProvider {
35
+ /**
36
+ * Derive encryption key from password
37
+ * @param password Password bytes
38
+ * @param salt Salt (generated if not provided)
39
+ * @param options Derivation options
40
+ */
41
+ deriveKey(password: Uint8Array, salt?: Uint8Array, options?: {
42
+ iterations?: number;
43
+ memorySize?: number;
44
+ parallelism?: number;
45
+ }): Promise<{
46
+ key: Uint8Array;
47
+ salt: Uint8Array;
48
+ }>;
49
+ }
50
+ /** Random number generator interface */
51
+ interface IRandomProvider {
52
+ /**
53
+ * Generate cryptographically secure random bytes
54
+ * @param length Number of bytes to generate
55
+ */
56
+ randomBytes(length: number): Uint8Array;
57
+ }
58
+
59
+ /**
60
+ * Web Crypto API implementation (Concrete provider)
61
+ * Implements ICryptoProvider using browser's native crypto
62
+ */
63
+
64
+ /**
65
+ * Web Crypto API provider for AES-256-GCM
66
+ */
67
+ declare class WebCryptoProvider implements ICryptoProvider {
68
+ encrypt(plaintext: Uint8Array, key: Uint8Array, iv?: Uint8Array): Promise<IEncryptedPayload>;
69
+ decrypt(encrypted: Uint8Array | IEncryptedPayload, key: Uint8Array): Promise<Uint8Array>;
70
+ }
71
+
72
+ /**
73
+ * Key derivation using Argon2id
74
+ *
75
+ * Parameters based on OWASP 2026 recommendations:
76
+ * - Memory: 64 MiB
77
+ * - Iterations: 3
78
+ * - Parallelism: 4
79
+ */
80
+ interface DeriveKeyOptions {
81
+ /** Password or recovery key bytes */
82
+ password: Uint8Array;
83
+ /** Salt (32 bytes recommended, auto-generated if not provided) */
84
+ salt?: Uint8Array;
85
+ /** Memory cost in KiB (default: 65536 = 64 MiB) */
86
+ memoryCost?: number;
87
+ /** Time cost / iterations (default: 3) */
88
+ timeCost?: number;
89
+ /** Parallelism (default: 4) */
90
+ parallelism?: number;
91
+ /** Output key length in bytes (default: 32 for AES-256) */
92
+ keyLength?: number;
93
+ }
94
+ interface DerivedKey {
95
+ /** The derived key bytes */
96
+ key: Uint8Array;
97
+ /** The salt used (save this for re-derivation) */
98
+ salt: Uint8Array;
99
+ }
100
+ /**
101
+ * Derive an encryption key from a password/recovery key using Argon2id
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * const { key, salt } = await deriveKey({
106
+ * password: recoveryKeyBytes,
107
+ * })
108
+ * // Save salt alongside encrypted data
109
+ * // Use key for AES-256-GCM encryption
110
+ * ```
111
+ */
112
+ declare function deriveKey(options: DeriveKeyOptions): Promise<DerivedKey>;
113
+
114
+ /**
115
+ * AES-256-GCM encryption/decryption using Web Crypto API
116
+ *
117
+ * - 256-bit key
118
+ * - 96-bit (12 byte) IV (NIST recommended for GCM)
119
+ * - 128-bit auth tag (included in ciphertext by Web Crypto)
120
+ *
121
+ * Refactored to follow Dependency Inversion Principle:
122
+ * - Uses ICryptoProvider interface
123
+ * - Default implementation uses WebCryptoProvider
124
+ * - Can inject alternative implementations for testing
125
+ */
126
+
127
+ /** Encrypted payload structure (backward compatibility) */
128
+ interface EncryptedPayload extends IEncryptedPayload {
129
+ }
130
+ /**
131
+ * Set custom crypto provider (for testing or alternative implementations)
132
+ * @param provider Custom crypto provider
133
+ */
134
+ declare function setCryptoProvider(provider: ICryptoProvider): void;
135
+ /**
136
+ * Get current crypto provider
137
+ */
138
+ declare function getCryptoProvider(): ICryptoProvider;
139
+ /**
140
+ * Encrypt data using AES-256-GCM
141
+ *
142
+ * @param plaintext - Data to encrypt
143
+ * @param key - 256-bit encryption key
144
+ * @param provider - Optional crypto provider (uses default if not provided)
145
+ * @returns Encrypted payload with IV
146
+ *
147
+ * @example
148
+ * ```ts
149
+ * const data = new TextEncoder().encode(JSON.stringify(state))
150
+ * const encrypted = await encrypt(data, key)
151
+ * // Store encrypted.combined (iv + ciphertext)
152
+ * ```
153
+ */
154
+ declare function encrypt(plaintext: Uint8Array, key: Uint8Array, provider?: ICryptoProvider): Promise<EncryptedPayload>;
155
+ /**
156
+ * Decrypt data using AES-256-GCM
157
+ *
158
+ * @param encrypted - Combined IV + ciphertext, or separate components
159
+ * @param key - 256-bit encryption key
160
+ * @param provider - Optional crypto provider (uses default if not provided)
161
+ * @returns Decrypted plaintext
162
+ *
163
+ * @example
164
+ * ```ts
165
+ * const plaintext = await decrypt(encrypted.combined, key)
166
+ * const state = JSON.parse(new TextDecoder().decode(plaintext))
167
+ * ```
168
+ */
169
+ declare function decrypt(encrypted: Uint8Array | EncryptedPayload, key: Uint8Array, provider?: ICryptoProvider): Promise<Uint8Array>;
170
+ /**
171
+ * Encrypt a string (convenience wrapper)
172
+ */
173
+ declare function encryptString(plaintext: string, key: Uint8Array, provider?: ICryptoProvider): Promise<EncryptedPayload>;
174
+ /**
175
+ * Decrypt to a string (convenience wrapper)
176
+ */
177
+ declare function decryptString(encrypted: Uint8Array | EncryptedPayload, key: Uint8Array, provider?: ICryptoProvider): Promise<string>;
178
+
179
+ /**
180
+ * Recovery key generation and validation
181
+ *
182
+ * Recovery key format:
183
+ * - 256 bits of entropy (32 bytes)
184
+ * - Encoded as base32 (RFC 4648, no padding)
185
+ * - Split into groups of 4 chars with dashes for readability
186
+ * - Total: 52 characters + 12 dashes = 64 characters
187
+ *
188
+ * Example: ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ23-4567-ABCD-EFGH-IJKL-MNOP-Q
189
+ */
190
+ /** Recovery key with metadata */
191
+ interface RecoveryKey {
192
+ /** Human-readable recovery key with dashes */
193
+ formatted: string;
194
+ /** Raw recovery key without dashes */
195
+ raw: string;
196
+ /** Original bytes (32 bytes = 256 bits) */
197
+ bytes: Uint8Array;
198
+ }
199
+ /**
200
+ * Generate a new recovery key
201
+ *
202
+ * @returns Recovery key in multiple formats
203
+ *
204
+ * @example
205
+ * ```ts
206
+ * const recovery = generateRecoveryKey()
207
+ * console.log(recovery.formatted)
208
+ * // "ABCD-EFGH-IJKL-MNOP-QRST-UVWX-YZ23-4567-ABCD-EFGH-IJKL-MNOP-Q"
209
+ * ```
210
+ */
211
+ declare function generateRecoveryKey(): RecoveryKey;
212
+ /**
213
+ * Validate a recovery key format
214
+ *
215
+ * @param key - Recovery key (with or without dashes)
216
+ * @returns True if valid format
217
+ */
218
+ declare function validateRecoveryKey(key: string): boolean;
219
+ /**
220
+ * Convert recovery key string to bytes
221
+ *
222
+ * @param key - Recovery key (with or without dashes)
223
+ * @returns 32 bytes
224
+ */
225
+ declare function recoveryKeyToBytes(key: string): Uint8Array;
226
+ /**
227
+ * Convert bytes to recovery key string
228
+ *
229
+ * @param bytes - 32 bytes
230
+ * @returns Raw base32 string (no dashes)
231
+ */
232
+ declare function bytesToRecoveryKey(bytes: Uint8Array): string;
233
+
234
+ /**
235
+ * Crypto utilities
236
+ */
237
+ /**
238
+ * Generate cryptographically secure random bytes
239
+ */
240
+ declare function randomBytes(length: number): Uint8Array;
241
+ /**
242
+ * Constant-time comparison to prevent timing attacks
243
+ */
244
+ declare function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean;
245
+
246
+ /**
247
+ * JWK-based encryption using @z-base/cryptosuite
248
+ * For use with ZKCredentials PRF-derived keys
249
+ */
250
+
251
+ /** Encrypted payload with IV and ciphertext */
252
+ interface JwkEncryptedPayload {
253
+ /** Initialization vector (12 bytes for AES-GCM) */
254
+ iv: Uint8Array;
255
+ /** Encrypted data */
256
+ ciphertext: Uint8Array;
257
+ /** Combined iv + ciphertext for easy storage */
258
+ combined: Uint8Array;
259
+ }
260
+ /**
261
+ * Encrypt data using AES-256-GCM with a CipherJWK
262
+ *
263
+ * @param plaintext - Data to encrypt
264
+ * @param cipherJwk - JWK key from ZKCredentials
265
+ * @returns Encrypted payload with IV
266
+ */
267
+ declare function encryptWithJwk(plaintext: Uint8Array, cipherJwk: CipherJWK): Promise<JwkEncryptedPayload>;
268
+ /**
269
+ * Decrypt data using AES-256-GCM with a CipherJWK
270
+ *
271
+ * @param encrypted - Combined IV + ciphertext, or separate components
272
+ * @param cipherJwk - JWK key from ZKCredentials
273
+ * @returns Decrypted plaintext
274
+ */
275
+ declare function decryptWithJwk(encrypted: Uint8Array | JwkEncryptedPayload, cipherJwk: CipherJWK): Promise<Uint8Array>;
276
+ /**
277
+ * Encrypt a string using CipherJWK
278
+ */
279
+ declare function encryptStringWithJwk(plaintext: string, cipherJwk: CipherJWK): Promise<JwkEncryptedPayload>;
280
+ /**
281
+ * Decrypt to a string using CipherJWK
282
+ */
283
+ declare function decryptStringWithJwk(encrypted: Uint8Array | JwkEncryptedPayload, cipherJwk: CipherJWK): Promise<string>;
284
+
285
+ export { type DeriveKeyOptions, type EncryptedPayload, type ICryptoProvider, type IEncryptedPayload, type IKeyDerivationProvider, type IRandomProvider, type JwkEncryptedPayload, type RecoveryKey, WebCryptoProvider, bytesToRecoveryKey, constantTimeEqual, decrypt, decryptString, decryptStringWithJwk, decryptWithJwk, deriveKey, encrypt, encryptString, encryptStringWithJwk, encryptWithJwk, generateRecoveryKey, getCryptoProvider, randomBytes, recoveryKeyToBytes, setCryptoProvider, validateRecoveryKey };
package/dist/index.js ADDED
@@ -0,0 +1,271 @@
1
+ // src/utils.ts
2
+ function randomBytes(length) {
3
+ const bytes = new Uint8Array(length);
4
+ crypto.getRandomValues(bytes);
5
+ return bytes;
6
+ }
7
+ function constantTimeEqual(a, b) {
8
+ if (a.length !== b.length) return false;
9
+ let result = 0;
10
+ for (let i = 0; i < a.length; i++) {
11
+ result |= a[i] ^ b[i];
12
+ }
13
+ return result === 0;
14
+ }
15
+ function concatBytes(...arrays) {
16
+ const totalLength = arrays.reduce((sum, arr) => sum + arr.length, 0);
17
+ const result = new Uint8Array(totalLength);
18
+ let offset = 0;
19
+ for (const arr of arrays) {
20
+ result.set(arr, offset);
21
+ offset += arr.length;
22
+ }
23
+ return result;
24
+ }
25
+
26
+ // src/providers/web-crypto.ts
27
+ var IV_LENGTH = 12;
28
+ var WebCryptoProvider = class {
29
+ async encrypt(plaintext, key, iv) {
30
+ if (key.length !== 32) {
31
+ throw new Error(`Invalid key length: expected 32 bytes, got ${key.length}`);
32
+ }
33
+ const actualIv = iv ?? randomBytes(IV_LENGTH);
34
+ const cryptoKey = await crypto.subtle.importKey(
35
+ "raw",
36
+ key.buffer,
37
+ { name: "AES-GCM" },
38
+ false,
39
+ ["encrypt"]
40
+ );
41
+ const ciphertext = new Uint8Array(
42
+ await crypto.subtle.encrypt(
43
+ { name: "AES-GCM", iv: actualIv },
44
+ cryptoKey,
45
+ plaintext.buffer
46
+ )
47
+ );
48
+ const combined = concatBytes(actualIv, ciphertext);
49
+ return { iv: actualIv, ciphertext, combined };
50
+ }
51
+ async decrypt(encrypted, key) {
52
+ if (key.length !== 32) {
53
+ throw new Error(`Invalid key length: expected 32 bytes, got ${key.length}`);
54
+ }
55
+ let iv;
56
+ let ciphertext;
57
+ if (encrypted instanceof Uint8Array) {
58
+ if (encrypted.length < IV_LENGTH + 16) {
59
+ throw new Error("Invalid encrypted data: too short");
60
+ }
61
+ iv = encrypted.slice(0, IV_LENGTH);
62
+ ciphertext = encrypted.slice(IV_LENGTH);
63
+ } else {
64
+ iv = encrypted.iv;
65
+ ciphertext = encrypted.ciphertext;
66
+ }
67
+ const cryptoKey = await crypto.subtle.importKey(
68
+ "raw",
69
+ key.buffer,
70
+ { name: "AES-GCM" },
71
+ false,
72
+ ["decrypt"]
73
+ );
74
+ try {
75
+ const plaintext = await crypto.subtle.decrypt(
76
+ { name: "AES-GCM", iv },
77
+ cryptoKey,
78
+ ciphertext.buffer
79
+ );
80
+ return new Uint8Array(plaintext);
81
+ } catch (error) {
82
+ throw new Error("Decryption failed: invalid key or corrupted data");
83
+ }
84
+ }
85
+ };
86
+
87
+ // src/derive.ts
88
+ import { argon2id } from "hash-wasm";
89
+ async function deriveKey(options) {
90
+ const {
91
+ password,
92
+ salt = randomBytes(32),
93
+ memoryCost = 65536,
94
+ // 64 MiB
95
+ timeCost = 3,
96
+ parallelism = 4,
97
+ keyLength = 32
98
+ } = options;
99
+ const hash = await argon2id({
100
+ password,
101
+ salt,
102
+ memorySize: memoryCost,
103
+ iterations: timeCost,
104
+ parallelism,
105
+ hashLength: keyLength,
106
+ outputType: "binary"
107
+ });
108
+ return {
109
+ key: new Uint8Array(hash),
110
+ salt
111
+ };
112
+ }
113
+
114
+ // src/aes.ts
115
+ var defaultProvider = new WebCryptoProvider();
116
+ function setCryptoProvider(provider) {
117
+ defaultProvider = provider;
118
+ }
119
+ function getCryptoProvider() {
120
+ return defaultProvider;
121
+ }
122
+ async function encrypt(plaintext, key, provider = defaultProvider) {
123
+ return provider.encrypt(plaintext, key);
124
+ }
125
+ async function decrypt(encrypted, key, provider = defaultProvider) {
126
+ return provider.decrypt(encrypted, key);
127
+ }
128
+ async function encryptString(plaintext, key, provider) {
129
+ const data = new TextEncoder().encode(plaintext);
130
+ return encrypt(data, key, provider);
131
+ }
132
+ async function decryptString(encrypted, key, provider) {
133
+ const plaintext = await decrypt(encrypted, key, provider);
134
+ return new TextDecoder().decode(plaintext);
135
+ }
136
+
137
+ // src/recovery.ts
138
+ var BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
139
+ function generateRecoveryKey() {
140
+ const bytes = randomBytes(32);
141
+ const raw = bytesToRecoveryKey(bytes);
142
+ const formatted = formatRecoveryKey(raw);
143
+ return { formatted, raw, bytes };
144
+ }
145
+ function validateRecoveryKey(key) {
146
+ const clean = key.replace(/[-\s]/g, "").toUpperCase();
147
+ if (clean.length !== 52) return false;
148
+ for (const char of clean) {
149
+ if (!BASE32_ALPHABET.includes(char)) return false;
150
+ }
151
+ return true;
152
+ }
153
+ function recoveryKeyToBytes(key) {
154
+ const clean = key.replace(/[-\s]/g, "").toUpperCase();
155
+ if (!validateRecoveryKey(clean)) {
156
+ throw new Error("Invalid recovery key format");
157
+ }
158
+ return base32Decode(clean);
159
+ }
160
+ function bytesToRecoveryKey(bytes) {
161
+ if (bytes.length !== 32) {
162
+ throw new Error(`Invalid bytes length: expected 32, got ${bytes.length}`);
163
+ }
164
+ return base32Encode(bytes);
165
+ }
166
+ function formatRecoveryKey(raw) {
167
+ const chunks = [];
168
+ for (let i = 0; i < raw.length; i += 4) {
169
+ chunks.push(raw.slice(i, i + 4));
170
+ }
171
+ return chunks.join("-");
172
+ }
173
+ function base32Encode(bytes) {
174
+ let result = "";
175
+ let bits = 0;
176
+ let value = 0;
177
+ for (const byte of bytes) {
178
+ value = value << 8 | byte;
179
+ bits += 8;
180
+ while (bits >= 5) {
181
+ bits -= 5;
182
+ result += BASE32_ALPHABET[value >> bits & 31];
183
+ }
184
+ }
185
+ if (bits > 0) {
186
+ result += BASE32_ALPHABET[value << 5 - bits & 31];
187
+ }
188
+ return result;
189
+ }
190
+ function base32Decode(str) {
191
+ const output = [];
192
+ let bits = 0;
193
+ let value = 0;
194
+ for (const char of str) {
195
+ const index = BASE32_ALPHABET.indexOf(char);
196
+ if (index === -1) {
197
+ throw new Error(`Invalid base32 character: ${char}`);
198
+ }
199
+ value = value << 5 | index;
200
+ bits += 5;
201
+ while (bits >= 8) {
202
+ bits -= 8;
203
+ output.push(value >> bits & 255);
204
+ }
205
+ }
206
+ return new Uint8Array(output);
207
+ }
208
+
209
+ // src/jwk.ts
210
+ import { CipherCluster } from "@z-base/cryptosuite";
211
+ async function encryptWithJwk(plaintext, cipherJwk) {
212
+ const result = await CipherCluster.encrypt(cipherJwk, plaintext);
213
+ const iv = result.iv;
214
+ const ciphertext = new Uint8Array(result.ciphertext);
215
+ const combined = new Uint8Array(iv.length + ciphertext.length);
216
+ combined.set(iv, 0);
217
+ combined.set(ciphertext, iv.length);
218
+ return { iv, ciphertext, combined };
219
+ }
220
+ async function decryptWithJwk(encrypted, cipherJwk) {
221
+ let iv;
222
+ let ciphertext;
223
+ if (encrypted instanceof Uint8Array) {
224
+ if (encrypted.length < 12 + 16) {
225
+ throw new Error("Invalid encrypted data: too short");
226
+ }
227
+ iv = encrypted.slice(0, 12);
228
+ ciphertext = encrypted.slice(12);
229
+ } else {
230
+ iv = encrypted.iv;
231
+ ciphertext = encrypted.ciphertext;
232
+ }
233
+ try {
234
+ const ciphertextBuffer = new ArrayBuffer(ciphertext.length);
235
+ new Uint8Array(ciphertextBuffer).set(ciphertext);
236
+ return await CipherCluster.decrypt(cipherJwk, {
237
+ iv,
238
+ ciphertext: ciphertextBuffer
239
+ });
240
+ } catch {
241
+ throw new Error("Decryption failed: invalid key or corrupted data");
242
+ }
243
+ }
244
+ async function encryptStringWithJwk(plaintext, cipherJwk) {
245
+ const data = new TextEncoder().encode(plaintext);
246
+ return encryptWithJwk(data, cipherJwk);
247
+ }
248
+ async function decryptStringWithJwk(encrypted, cipherJwk) {
249
+ const plaintext = await decryptWithJwk(encrypted, cipherJwk);
250
+ return new TextDecoder().decode(plaintext);
251
+ }
252
+ export {
253
+ WebCryptoProvider,
254
+ bytesToRecoveryKey,
255
+ constantTimeEqual,
256
+ decrypt,
257
+ decryptString,
258
+ decryptStringWithJwk,
259
+ decryptWithJwk,
260
+ deriveKey,
261
+ encrypt,
262
+ encryptString,
263
+ encryptStringWithJwk,
264
+ encryptWithJwk,
265
+ generateRecoveryKey,
266
+ getCryptoProvider,
267
+ randomBytes,
268
+ recoveryKeyToBytes,
269
+ setCryptoProvider,
270
+ validateRecoveryKey
271
+ };
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@ursalock/crypto",
3
+ "version": "0.2.1",
4
+ "description": "E2EE crypto primitives for ursalock",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsup src/index.ts --format esm --dts",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "@z-base/cryptosuite": "^1.0.1",
26
+ "hash-wasm": "^4.11.0"
27
+ },
28
+ "devDependencies": {
29
+ "tsup": "^8.0.0",
30
+ "typescript": "^5.4.0",
31
+ "vitest": "^1.6.0"
32
+ },
33
+ "keywords": [
34
+ "encryption",
35
+ "e2ee",
36
+ "aes-256-gcm",
37
+ "argon2id",
38
+ "crypto",
39
+ "zero-knowledge",
40
+ "jwk"
41
+ ],
42
+ "license": "MIT",
43
+ "author": "Nicolas de Luz <ndlz@pm.me>",
44
+ "homepage": "https://github.com/nicodlz/ursalock#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/nicodlz/ursalock/issues"
47
+ },
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "https://github.com/nicodlz/ursalock.git",
51
+ "directory": "packages/crypto"
52
+ }
53
+ }