@wemush/wols 1.2.0 → 1.3.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.
Files changed (98) hide show
  1. package/CHANGELOG.md +170 -0
  2. package/README.md +204 -31
  3. package/dist/cjs/compact-url.d.ts +150 -0
  4. package/dist/cjs/compact-url.d.ts.map +1 -0
  5. package/dist/cjs/crypto/decrypt.d.ts +110 -0
  6. package/dist/cjs/crypto/decrypt.d.ts.map +1 -0
  7. package/dist/cjs/crypto/decrypt.js +519 -99
  8. package/dist/cjs/crypto/decrypt.js.map +1 -1
  9. package/dist/cjs/crypto/encrypt.d.ts +132 -0
  10. package/dist/cjs/crypto/encrypt.d.ts.map +1 -0
  11. package/dist/cjs/crypto/encrypt.js +281 -200
  12. package/dist/cjs/crypto/encrypt.js.map +1 -1
  13. package/dist/cjs/crypto/index.d.ts +12 -0
  14. package/dist/cjs/crypto/index.d.ts.map +1 -0
  15. package/dist/cjs/crypto/index.js +15 -2
  16. package/dist/cjs/crypto/index.js.map +1 -1
  17. package/dist/cjs/crypto/shared.d.ts +116 -0
  18. package/dist/cjs/crypto/shared.d.ts.map +1 -0
  19. package/dist/cjs/crypto/shared.js +238 -0
  20. package/dist/cjs/crypto/shared.js.map +1 -0
  21. package/dist/cjs/errors.d.ts +133 -0
  22. package/dist/cjs/errors.d.ts.map +1 -0
  23. package/dist/cjs/errors.js +39 -1
  24. package/dist/cjs/errors.js.map +1 -1
  25. package/dist/cjs/index.d.ts +29 -0
  26. package/dist/cjs/index.d.ts.map +1 -0
  27. package/dist/cjs/index.js +19 -4
  28. package/dist/cjs/index.js.map +1 -1
  29. package/dist/cjs/package.json +3 -0
  30. package/dist/cjs/parser.d.ts +30 -0
  31. package/dist/cjs/parser.d.ts.map +1 -0
  32. package/dist/cjs/qr/generate.d.ts +62 -0
  33. package/dist/cjs/qr/generate.d.ts.map +1 -0
  34. package/dist/cjs/qr/index.d.ts +10 -0
  35. package/dist/cjs/qr/index.d.ts.map +1 -0
  36. package/dist/cjs/specimen.d.ts +96 -0
  37. package/dist/cjs/specimen.d.ts.map +1 -0
  38. package/dist/cjs/specimen.js +84 -11
  39. package/dist/cjs/specimen.js.map +1 -1
  40. package/dist/cjs/types.d.ts +735 -0
  41. package/dist/cjs/types.d.ts.map +1 -0
  42. package/dist/cjs/types.js +65 -5
  43. package/dist/cjs/types.js.map +1 -1
  44. package/dist/cjs/utils/cuid.d.ts +46 -0
  45. package/dist/cjs/utils/cuid.d.ts.map +1 -0
  46. package/dist/cjs/utils/cuid.js +6 -4
  47. package/dist/cjs/utils/cuid.js.map +1 -1
  48. package/dist/cjs/utils/environment.d.ts +137 -0
  49. package/dist/cjs/utils/environment.d.ts.map +1 -0
  50. package/dist/cjs/utils/iso8601.d.ts +79 -0
  51. package/dist/cjs/utils/iso8601.d.ts.map +1 -0
  52. package/dist/cjs/utils/migration.d.ts +162 -0
  53. package/dist/cjs/utils/migration.d.ts.map +1 -0
  54. package/dist/cjs/validator.d.ts +25 -0
  55. package/dist/cjs/validator.d.ts.map +1 -0
  56. package/dist/cjs/validator.js +33 -22
  57. package/dist/cjs/validator.js.map +1 -1
  58. package/dist/esm/crypto/decrypt.js +515 -99
  59. package/dist/esm/crypto/decrypt.js.map +1 -1
  60. package/dist/esm/crypto/encrypt.js +277 -200
  61. package/dist/esm/crypto/encrypt.js.map +1 -1
  62. package/dist/esm/crypto/index.js +5 -3
  63. package/dist/esm/crypto/index.js.map +1 -1
  64. package/dist/esm/crypto/shared.js +222 -0
  65. package/dist/esm/crypto/shared.js.map +1 -0
  66. package/dist/esm/errors.js +37 -0
  67. package/dist/esm/errors.js.map +1 -1
  68. package/dist/esm/index.js +6 -6
  69. package/dist/esm/index.js.map +1 -1
  70. package/dist/esm/specimen.js +84 -12
  71. package/dist/esm/specimen.js.map +1 -1
  72. package/dist/esm/types.js +63 -4
  73. package/dist/esm/types.js.map +1 -1
  74. package/dist/esm/utils/cuid.js +6 -4
  75. package/dist/esm/utils/cuid.js.map +1 -1
  76. package/dist/esm/validator.js +34 -23
  77. package/dist/esm/validator.js.map +1 -1
  78. package/dist/types/crypto/decrypt.d.ts +93 -22
  79. package/dist/types/crypto/decrypt.d.ts.map +1 -1
  80. package/dist/types/crypto/encrypt.d.ts +106 -61
  81. package/dist/types/crypto/encrypt.d.ts.map +1 -1
  82. package/dist/types/crypto/index.d.ts +5 -3
  83. package/dist/types/crypto/index.d.ts.map +1 -1
  84. package/dist/types/crypto/shared.d.ts +116 -0
  85. package/dist/types/crypto/shared.d.ts.map +1 -0
  86. package/dist/types/errors.d.ts +31 -1
  87. package/dist/types/errors.d.ts.map +1 -1
  88. package/dist/types/index.d.ts +7 -7
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/types/specimen.d.ts +29 -4
  91. package/dist/types/specimen.d.ts.map +1 -1
  92. package/dist/types/types.d.ts +265 -40
  93. package/dist/types/types.d.ts.map +1 -1
  94. package/dist/types/utils/cuid.d.ts +5 -3
  95. package/dist/types/utils/cuid.d.ts.map +1 -1
  96. package/dist/types/validator.d.ts +1 -1
  97. package/dist/types/validator.d.ts.map +1 -1
  98. package/package.json +32 -14
@@ -1,133 +1,549 @@
1
1
  /**
2
- * WOLS Decryption Module
2
+ * WOLS Decryption Module (WOLS v1.3.0 encrypted format)
3
3
  *
4
- * Provides AES-256-GCM decryption for encrypted specimens using Web Crypto API.
5
- * Supports both password-based (PBKDF2) and raw CryptoKey decryption.
4
+ * Decrypts `wemush://v1/encrypted/...` URLs, the equivalent
5
+ * {@link EncryptedSpecimen} objects, and field-level {@link EncryptedField}
6
+ * values, using WebCrypto only.
6
7
  *
7
- * ## Self-Describing Payloads
8
+ * Failures never throw; they return `{ success: false, error }` where `error`
9
+ * is a {@link WolsDecryptionError} with a stable, typed `reason`.
8
10
  *
9
- * Encrypted specimens from v1.2.0+ include `iterations` in the payload, enabling
10
- * automatic key derivation without caller needing to know encryption parameters.
11
- * For payloads without `iterations` (pre-1.2.0), the default of 100,000 is used.
11
+ * Every cheap check (parameter presence, encodings, IV length, iteration
12
+ * bounds, key type) runs before PBKDF2, so a malformed or hostile label costs
13
+ * no key derivation. The iteration count comes from the label and is bounded
14
+ * by `maxIterations` (default 10,000,000).
15
+ *
16
+ * Legacy payloads (no `kdf`, pre-1.3.0 fixed salt) are rejected with
17
+ * `legacy-format` unless `allowLegacy: true` is passed.
12
18
  *
13
19
  * @module crypto/decrypt
14
20
  */
15
- import { WolsEncryptionError, WolsErrorCode } from '../errors.js';
16
21
  import { parseSpecimen } from '../parser.js';
22
+ import { DEFAULT_MAX_DECODE_ITERATIONS, IDENTIFYING_FIELDS, LEGACY_PBKDF2_ITERATIONS, ENCRYPTED_URL_PREFIX, GCM_TAG_LENGTH, IV_LENGTH, LEGACY_PBKDF2_SALT, MAX_ITERATIONS, MIN_DECODE_ITERATIONS, NO_WEBCRYPTO_MESSAGE, base64UrlDecode, decryptionError, derivePassphraseKey, fieldAad, hasWebCrypto, importAesKey, isPassphrase, isRawKey, resolveRawKey, } from './shared.js';
23
+ const fail = (error) => ({ success: false, error });
24
+ const isFailure = (value) => typeof value === 'object' && value !== null && value.success === false;
17
25
  // =============================================================================
18
- // CONSTANTS
26
+ // URL PARSING
19
27
  // =============================================================================
20
- /** Salt for PBKDF2 key derivation (must match encrypt.ts) */
21
- const PBKDF2_SALT = new TextEncoder().encode('@wemush/wols/crypto/v1');
22
- /** Default PBKDF2 iterations for backward compatibility with pre-1.2.0 payloads */
23
- const DEFAULT_PBKDF2_ITERATIONS = 100_000;
28
+ const SINGLE_VALUED = ['e', 'iv', 'kdf', 'salt', 'i', 'sig'];
29
+ /**
30
+ * Parse an encrypted-format URL into an {@link EncryptedSpecimen} without decrypting.
31
+ *
32
+ * Only checks URL structure. Parameter rules (kdf, salt, iterations) are
33
+ * enforced on decryption. The path segment is percent-decoded to recover the ID.
34
+ *
35
+ * @since 1.3.0
36
+ */
37
+ export function parseEncryptedUrl(url) {
38
+ if (typeof url !== 'string' || !url.startsWith(ENCRYPTED_URL_PREFIX)) {
39
+ return fail(decryptionError('invalid-url', `URL must start with '${ENCRYPTED_URL_PREFIX}'`));
40
+ }
41
+ const rest = url.slice(ENCRYPTED_URL_PREFIX.length).split('#')[0] ?? '';
42
+ const q = rest.indexOf('?');
43
+ const rawId = q === -1 ? rest : rest.slice(0, q);
44
+ const query = q === -1 ? '' : rest.slice(q + 1);
45
+ if (rawId.length === 0 || rawId.includes('/')) {
46
+ return fail(decryptionError('invalid-url', 'URL path must be a single non-empty specimen ID suffix'));
47
+ }
48
+ let suffix;
49
+ try {
50
+ suffix = decodeURIComponent(rawId);
51
+ }
52
+ catch {
53
+ // decodeURIComponent rejects malformed escapes and invalid UTF-8; '+' stays literal.
54
+ return fail(decryptionError('invalid-encoding', 'URL path has a malformed percent-encoding or is not valid UTF-8'));
55
+ }
56
+ if (suffix.length === 0) {
57
+ return fail(decryptionError('invalid-url', 'URL path must be a single non-empty specimen ID suffix'));
58
+ }
59
+ const params = new URLSearchParams(query);
60
+ for (const name of SINGLE_VALUED) {
61
+ if (params.getAll(name).length > 1) {
62
+ return fail(decryptionError('invalid-parameter', `Parameter '${name}' appears more than once`));
63
+ }
64
+ }
65
+ const e = params.get('e');
66
+ const iv = params.get('iv');
67
+ if (!e || !iv) {
68
+ return fail(decryptionError('missing-parameter', `Encrypted URL requires 'e' and 'iv' parameters`, {
69
+ path: [e ? 'iv' : 'e'],
70
+ }));
71
+ }
72
+ const result = {
73
+ encrypted: true,
74
+ algorithm: 'AES-256-GCM',
75
+ payload: e,
76
+ iv,
77
+ id: `wemush:${suffix}`,
78
+ url,
79
+ };
80
+ const kdf = params.get('kdf');
81
+ if (kdf !== null)
82
+ result.kdf = kdf;
83
+ const salt = params.get('salt');
84
+ if (salt !== null)
85
+ result.salt = salt;
86
+ const i = params.get('i');
87
+ if (i !== null) {
88
+ // Keep non-numeric values visible to the decryptor so it can reject them.
89
+ result.iterations = /^\d+$/.test(i) ? Number(i) : Number.NaN;
90
+ }
91
+ const sig = params.get('sig');
92
+ if (sig !== null)
93
+ result.signature = sig;
94
+ return { success: true, data: result };
95
+ }
96
+ function toCipherParams(input) {
97
+ let enc;
98
+ if (typeof input === 'string') {
99
+ const parsed = parseEncryptedUrl(input);
100
+ if (!parsed.success)
101
+ return parsed;
102
+ enc = parsed.data;
103
+ }
104
+ else if (typeof input === 'object' && input !== null) {
105
+ enc = input;
106
+ }
107
+ else {
108
+ return fail(decryptionError('invalid-url', 'Expected an encrypted URL or encrypted specimen object'));
109
+ }
110
+ if (!enc.payload) {
111
+ return fail(decryptionError('missing-parameter', 'Missing encrypted payload', { path: ['payload'] }));
112
+ }
113
+ if (!enc.iv) {
114
+ return fail(decryptionError('missing-parameter', 'Missing initialization vector (IV)', { path: ['iv'] }));
115
+ }
116
+ let idSuffix;
117
+ if (enc.id !== undefined) {
118
+ if (typeof enc.id !== 'string' || !enc.id.startsWith('wemush:') || enc.id.length === 7) {
119
+ return fail(decryptionError('invalid-parameter', `Invalid specimen id '${String(enc.id)}'`));
120
+ }
121
+ idSuffix = enc.id.slice('wemush:'.length);
122
+ }
123
+ return {
124
+ success: true,
125
+ data: {
126
+ idSuffix,
127
+ payload: enc.payload,
128
+ iv: enc.iv,
129
+ kdf: enc.kdf,
130
+ salt: enc.salt,
131
+ iterations: enc.iterations,
132
+ },
133
+ };
134
+ }
24
135
  // =============================================================================
25
- // UTILITY FUNCTIONS
136
+ // VALIDATION (everything before key derivation)
26
137
  // =============================================================================
138
+ function decodeParam(name, value) {
139
+ try {
140
+ return base64UrlDecode(value);
141
+ }
142
+ catch {
143
+ return fail(decryptionError('invalid-parameter', `Parameter '${name}' is not valid base64url`));
144
+ }
145
+ }
146
+ function resolveMaxIterations(options) {
147
+ const max = options.maxIterations ?? DEFAULT_MAX_DECODE_ITERATIONS;
148
+ // WOLS 1.3.0: a configured maximum may be lower than 10,000,000, never higher.
149
+ if (!Number.isInteger(max) || max < MIN_DECODE_ITERATIONS || max > MAX_ITERATIONS) {
150
+ return fail(decryptionError('invalid-parameter', `maxIterations must be an integer from ${MIN_DECODE_ITERATIONS} to ${MAX_ITERATIONS}, got ${max}`));
151
+ }
152
+ return max;
153
+ }
154
+ function parseIterations(value, max) {
155
+ // Strings (object input) must be plain decimal digits, exactly as in the URL form.
156
+ const n = typeof value === 'number'
157
+ ? value
158
+ : typeof value === 'string' && /^\d+$/.test(value)
159
+ ? Number(value)
160
+ : Number.NaN;
161
+ if (!Number.isInteger(n)) {
162
+ return fail(decryptionError('invalid-parameter', `Iteration count must be a decimal integer`));
163
+ }
164
+ if (n < MIN_DECODE_ITERATIONS) {
165
+ return fail(decryptionError('insufficient-iterations', `PBKDF2 iteration count ${n} is below the minimum of ${MIN_DECODE_ITERATIONS}`));
166
+ }
167
+ if (n > max) {
168
+ return fail(decryptionError('excessive-iterations', `PBKDF2 iteration count ${n} exceeds maxIterations (${max})`, { iterations: n, maxIterations: max }));
169
+ }
170
+ return n;
171
+ }
27
172
  /**
28
- * Convert base64url string to ArrayBuffer
173
+ * Validate every parameter and the key type, and decode all binary values,
174
+ * without doing any key derivation.
29
175
  */
30
- function fromBase64Url(base64url) {
31
- // Convert base64url to base64
32
- const base64 = base64url
33
- .replace(/-/g, '+')
34
- .replace(/_/g, '/')
35
- .padEnd(base64url.length + ((4 - (base64url.length % 4)) % 4), '=');
36
- // Decode base64 to binary string
37
- const binaryString = atob(base64);
38
- // Convert to ArrayBuffer
39
- const bytes = new Uint8Array(binaryString.length);
40
- for (let i = 0; i < binaryString.length; i++) {
41
- bytes[i] = binaryString.charCodeAt(i);
42
- }
43
- return bytes.buffer;
176
+ function prepare(p, options) {
177
+ if (!hasWebCrypto()) {
178
+ return fail(decryptionError('unsupported-environment', NO_WEBCRYPTO_MESSAGE));
179
+ }
180
+ if (typeof options !== 'object' || options === null) {
181
+ return fail(decryptionError('invalid-key', 'Decryption options with a key are required'));
182
+ }
183
+ const { key } = options;
184
+ const max = resolveMaxIterations(options);
185
+ if (isFailure(max))
186
+ return max;
187
+ // 1. kdf and which parameters must / must not be present
188
+ if (p.kdf === undefined) {
189
+ if (!options.allowLegacy) {
190
+ return fail(decryptionError('legacy-format', `Missing 'kdf' parameter: this is a legacy (pre-1.3.0) payload. ` +
191
+ `Pass { allowLegacy: true } to decrypt it during migration.`, { legacy: true }));
192
+ }
193
+ if (p.salt !== undefined) {
194
+ return fail(decryptionError('unexpected-parameter', `Legacy payloads have no 'salt'; refusing to guess`));
195
+ }
196
+ }
197
+ else if (p.kdf === 'pbkdf2') {
198
+ if (!p.salt || p.iterations === undefined) {
199
+ return fail(decryptionError('missing-parameter', `kdf=pbkdf2 requires 'salt' and 'i' parameters`, {
200
+ path: [p.salt ? 'i' : 'salt'],
201
+ }));
202
+ }
203
+ }
204
+ else if (p.kdf === 'raw') {
205
+ if (p.salt !== undefined || p.iterations !== undefined) {
206
+ return fail(decryptionError('unexpected-parameter', `'salt' and 'i' must be omitted when kdf=raw`));
207
+ }
208
+ }
209
+ else {
210
+ return fail(decryptionError('unsupported-kdf', `Unsupported kdf '${p.kdf}'`));
211
+ }
212
+ // 2. ciphertext and IV
213
+ const ciphertext = decodeParam('e', p.payload);
214
+ if (isFailure(ciphertext))
215
+ return ciphertext;
216
+ if (ciphertext.length < GCM_TAG_LENGTH) {
217
+ return fail(decryptionError('invalid-parameter', `Ciphertext is ${ciphertext.length} bytes; it must include the ${GCM_TAG_LENGTH}-byte GCM tag`));
218
+ }
219
+ const iv = decodeParam('iv', p.iv);
220
+ if (isFailure(iv))
221
+ return iv;
222
+ if (iv.length !== IV_LENGTH) {
223
+ return fail(decryptionError('invalid-parameter', `IV must be ${IV_LENGTH} bytes, got ${iv.length}`));
224
+ }
225
+ // 3. key derivation inputs and key type
226
+ if (p.kdf === 'raw' || (p.kdf === undefined && !isPassphrase(key))) {
227
+ if (!isRawKey(key)) {
228
+ return fail(decryptionError('invalid-key', 'kdf=raw requires 32 raw key bytes or an AES-GCM CryptoKey'));
229
+ }
230
+ return { ciphertext, iv, mode: 'raw', rawKey: key };
231
+ }
232
+ const iterations = parseIterations(p.kdf === undefined ? (p.iterations ?? LEGACY_PBKDF2_ITERATIONS) : p.iterations, max);
233
+ if (isFailure(iterations))
234
+ return iterations;
235
+ let salt;
236
+ if (p.kdf === undefined) {
237
+ salt = new Uint8Array(LEGACY_PBKDF2_SALT);
238
+ }
239
+ else {
240
+ const decoded = decodeParam('salt', p.salt ?? '');
241
+ if (isFailure(decoded))
242
+ return decoded;
243
+ salt = decoded;
244
+ }
245
+ if (!isPassphrase(key)) {
246
+ return fail(decryptionError('invalid-key', 'kdf=pbkdf2 requires a passphrase string key'));
247
+ }
248
+ return { ciphertext, iv, mode: 'pbkdf2', passphrase: key, salt, iterations };
249
+ }
250
+ // =============================================================================
251
+ // DECRYPTION
252
+ // =============================================================================
253
+ /** Cache key for reusing one derived key across fields of the same label. */
254
+ function derivationId(prepared) {
255
+ return prepared.mode === 'pbkdf2'
256
+ ? `${prepared.iterations}:${Array.from(prepared.salt).join(',')}`
257
+ : undefined;
258
+ }
259
+ async function deriveKey(prepared) {
260
+ try {
261
+ if (prepared.mode === 'raw') {
262
+ const resolved = await resolveRawKey(prepared.rawKey, 'decrypt');
263
+ return typeof resolved === 'string'
264
+ ? fail(decryptionError('invalid-key', resolved))
265
+ : resolved;
266
+ }
267
+ const bytes = await derivePassphraseKey(prepared.passphrase, prepared.salt, prepared.iterations);
268
+ return await importAesKey(bytes, 'decrypt');
269
+ }
270
+ catch (err) {
271
+ return fail(decryptionError('invalid-key', `Key derivation failed: ${err instanceof Error ? err.message : String(err)}`));
272
+ }
273
+ }
274
+ /** Decrypt normalized params to UTF-8 plaintext (no ID check). */
275
+ async function openParams(p, options) {
276
+ const prepared = prepare(p, options);
277
+ if (isFailure(prepared))
278
+ return prepared;
279
+ const cryptoKey = await deriveKey(prepared);
280
+ if (isFailure(cryptoKey))
281
+ return cryptoKey;
282
+ return openPrepared(prepared, cryptoKey);
44
283
  }
45
284
  /**
46
- * Derive a CryptoKey from a password using PBKDF2
47
- *
48
- * @param password - The password string
49
- * @param iterations - PBKDF2 iteration count (from payload or default)
285
+ * AES-GCM decrypt already-validated input with a derived key. `aad` is the
286
+ * field-level additional data; whole-label (URL) decryption uses none.
50
287
  */
51
- async function deriveKey(password, iterations) {
52
- const encoder = new TextEncoder();
53
- const passwordBytes = encoder.encode(password);
54
- // Import password as key material
55
- const keyMaterial = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, [
56
- 'deriveKey',
57
- ]);
58
- // Derive AES-256-GCM key
59
- return crypto.subtle.deriveKey({
60
- name: 'PBKDF2',
61
- salt: PBKDF2_SALT,
62
- iterations,
63
- hash: 'SHA-256',
64
- }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
288
+ async function openPrepared(prepared, cryptoKey, aad) {
289
+ let plaintext;
290
+ try {
291
+ plaintext = await crypto.subtle.decrypt(aad === undefined
292
+ ? { name: 'AES-GCM', iv: prepared.iv }
293
+ : { name: 'AES-GCM', iv: prepared.iv, additionalData: aad }, cryptoKey, prepared.ciphertext);
294
+ }
295
+ catch {
296
+ return fail(decryptionError('authentication-failed', 'Decryption failed: wrong key or tampered ciphertext (GCM authentication failed)'));
297
+ }
298
+ let text;
299
+ try {
300
+ // ignoreBOM keeps a leading BOM visible so it can be rejected below.
301
+ text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(plaintext);
302
+ }
303
+ catch {
304
+ return fail(decryptionError('invalid-plaintext', 'Decrypted payload is not valid UTF-8'));
305
+ }
306
+ if (text.startsWith('\uFEFF')) {
307
+ return fail(decryptionError('invalid-plaintext', 'Decrypted payload starts with a byte order mark'));
308
+ }
309
+ return { success: true, data: text };
65
310
  }
66
311
  // =============================================================================
67
- // DECRYPTION FUNCTIONS
312
+ // PUBLIC API
68
313
  // =============================================================================
69
314
  /**
70
- * Decrypt an encrypted specimen using AES-256-GCM
315
+ * Decrypt an encrypted label to its exact plaintext (the embedded JSON-LD string).
316
+ *
317
+ * Enforces every v1.3.0 wire rule, including that the decrypted specimen's
318
+ * `id` equals `wemush:{id}` from the URL path. For object input the check is
319
+ * made against the object's `id`; an object without `id` is not bound to any
320
+ * ID, so prefer the URL form for untrusted input.
321
+ *
322
+ * @param input - `wemush://v1/encrypted/...` URL or an {@link EncryptedSpecimen}
323
+ * @param options - `key`, and optional `allowLegacy` and `maxIterations`
324
+ * @since 1.3.0
325
+ */
326
+ export async function decryptPlaintext(input, options) {
327
+ const params = toCipherParams(input);
328
+ if (!params.success)
329
+ return params;
330
+ const opened = await openParams(params.data, options);
331
+ if (!opened.success)
332
+ return opened;
333
+ if (params.data.idSuffix !== undefined) {
334
+ const expectedId = `wemush:${params.data.idSuffix}`;
335
+ let actualId;
336
+ try {
337
+ actualId = JSON.parse(opened.data)?.id;
338
+ }
339
+ catch {
340
+ return fail(decryptionError('invalid-plaintext', 'Decrypted payload is not valid JSON'));
341
+ }
342
+ if (actualId !== expectedId) {
343
+ // actualId comes from the plaintext: quote it, and bound its length,
344
+ // so it cannot inject into logs.
345
+ const shown = JSON.stringify(String(actualId).slice(0, 100));
346
+ return fail(decryptionError('id-mismatch', `Decrypted specimen id ${shown} does not match ${JSON.stringify(expectedId)}`, { expectedId, actualId }));
347
+ }
348
+ }
349
+ return opened;
350
+ }
351
+ /** Parse decrypted JSON as a validated Specimen, reporting failure as `invalid-plaintext`. */
352
+ function parseDecrypted(json, what) {
353
+ const parsed = parseSpecimen(json);
354
+ if (parsed.success)
355
+ return parsed;
356
+ return fail(decryptionError('invalid-plaintext', `${what} is not a valid WOLS specimen: ${parsed.error.message}`, { parseError: parsed.error }));
357
+ }
358
+ /**
359
+ * Decrypt an encrypted label and parse it into a validated Specimen.
360
+ *
361
+ * @param input - `wemush://v1/encrypted/...` URL or an {@link EncryptedSpecimen}
362
+ * @param options - `key`, and optional `allowLegacy` and `maxIterations`
363
+ * @returns DecryptResult; on failure `error.reason` gives the category
364
+ *
365
+ * @example
366
+ * ```typescript
367
+ * const result = await decryptSpecimen(url, { key: 'correct horse battery staple' });
368
+ * if (result.success) console.log(result.data.species);
369
+ * else console.error(result.error.reason, result.error.message);
370
+ * ```
371
+ *
372
+ * @since 1.3.0 Accepts encrypted URLs, raw key bytes, `allowLegacy` and `maxIterations`
373
+ */
374
+ export async function decryptSpecimen(input, options) {
375
+ const plaintext = await decryptPlaintext(input, options);
376
+ if (!plaintext.success)
377
+ return plaintext;
378
+ return parseDecrypted(plaintext.data, 'Decrypted label');
379
+ }
380
+ /**
381
+ * Decrypt a single field-level {@link EncryptedField} to its original value.
71
382
  *
72
- * @param encrypted - The encrypted specimen wrapper
73
- * @param options - Decryption options (key required)
74
- * @returns Promise resolving to ParseResult with decrypted specimen or error
383
+ * `binding` names the label and field the value belongs to. It is the AES-GCM
384
+ * additional data (`"{id}\n{field}"`, WOLS 1.3.0), so a value moved to another
385
+ * field or copied from another label fails with `authentication-failed`.
386
+ * Legacy (pre-1.3.0) values, which have no `kdf`, carry no binding.
75
387
  *
76
388
  * @example
77
389
  * ```typescript
78
- * import { decryptSpecimen } from '@wemush/wols/crypto';
390
+ * const r = await decryptField(partial.strain, { key }, { id: partial.id, field: 'strain' });
391
+ * ```
79
392
  *
80
- * const result = await decryptSpecimen(encrypted, {
81
- * key: 'my-secret-password',
82
- * });
393
+ * @since 1.3.0
394
+ */
395
+ export async function decryptField(field, options, binding) {
396
+ if (typeof field !== 'object' || field === null || field.encrypted !== true) {
397
+ return fail(decryptionError('invalid-parameter', 'Expected an encrypted field object'));
398
+ }
399
+ const aad = bindingAad(field, binding);
400
+ if (isFailure(aad))
401
+ return aad;
402
+ const prepared = prepare(fieldParams(field), options);
403
+ if (isFailure(prepared))
404
+ return prepared;
405
+ const cryptoKey = await deriveKey(prepared);
406
+ if (isFailure(cryptoKey))
407
+ return cryptoKey;
408
+ const opened = await openPrepared(prepared, cryptoKey, aad);
409
+ if (!opened.success)
410
+ return opened;
411
+ try {
412
+ return { success: true, data: JSON.parse(opened.data) };
413
+ }
414
+ catch {
415
+ return fail(decryptionError('invalid-plaintext', 'Decrypted field is not valid JSON'));
416
+ }
417
+ }
418
+ function fieldParams(field) {
419
+ return {
420
+ idSuffix: undefined,
421
+ payload: field.payload,
422
+ iv: field.iv,
423
+ kdf: field.kdf,
424
+ salt: field.salt,
425
+ iterations: field.iterations,
426
+ };
427
+ }
428
+ /** AAD for a field value, or undefined for legacy values (no kdf, no binding). */
429
+ function bindingAad(field, binding) {
430
+ if (field.kdf === undefined)
431
+ return undefined;
432
+ if (typeof binding !== 'object' ||
433
+ binding === null ||
434
+ typeof binding.id !== 'string' ||
435
+ !binding.id.startsWith('wemush:') ||
436
+ binding.id.length === 'wemush:'.length ||
437
+ typeof binding.field !== 'string' ||
438
+ binding.field.length === 0) {
439
+ return fail(decryptionError('invalid-parameter', "Field decryption needs the label's id and the field name (they are the AES-GCM additional data)"));
440
+ }
441
+ return fieldAad(binding.id, binding.field);
442
+ }
443
+ /**
444
+ * Decrypt every field-level encrypted value in a specimen produced by
445
+ * `encryptFields()`, and return the restored, validated Specimen.
83
446
  *
84
- * if (result.success) {
85
- * console.log(result.data);
86
- * } else {
87
- * console.error(result.error);
88
- * }
447
+ * Before any key is derived:
448
+ * - a label whose identifying fields (`@context`, `@type`, `id`, `version`,
449
+ * `type`, `species`) include an encrypted object is refused
450
+ * (`identifying-field`); decrypted values never replace them
451
+ * - every encrypted field is validated
452
+ * - the iterations of each distinct (salt, iterations) pair are summed, and
453
+ * the label is refused if the total exceeds `maxIterations` (10,000,000)
454
+ *
455
+ * Fields sharing a salt and iteration count share one derivation. Each value
456
+ * is authenticated against its AAD, `"{id}\n{field}"`, so swapped or copied
457
+ * values fail with `authentication-failed`.
458
+ *
459
+ * @example
460
+ * ```typescript
461
+ * const partial = await encryptFields(specimen, { key, fields: ['strain'] });
462
+ * const restored = await decryptFields(partial, { key });
463
+ * if (restored.success) restored.data.strain?.name;
89
464
  * ```
465
+ *
466
+ * @since 1.3.0
90
467
  */
91
- export async function decryptSpecimen(encrypted, options) {
92
- const { key } = options;
93
- // Validate encrypted specimen structure
94
- if (!encrypted.payload) {
95
- return {
96
- success: false,
97
- error: new WolsEncryptionError(WolsErrorCode.DECRYPTION_ERROR, 'Missing encrypted payload', {
98
- path: ['payload'],
99
- }),
100
- };
101
- }
102
- if (!encrypted.iv) {
103
- return {
104
- success: false,
105
- error: new WolsEncryptionError(WolsErrorCode.DECRYPTION_ERROR, 'Missing initialization vector (IV)', { path: ['iv'] }),
106
- };
468
+ export async function decryptFields(value, options) {
469
+ if (typeof value !== 'object' || value === null) {
470
+ return fail(decryptionError('invalid-parameter', 'Expected a field-encrypted specimen object'));
107
471
  }
472
+ const fieldError = (name, error) => fail(decryptionError(error.reason, `Field '${name}': ${error.message}`, {
473
+ ...error.details,
474
+ path: [name],
475
+ }));
476
+ const isEncryptedValue = (v) => typeof v === 'object' && v !== null && v.encrypted === true;
477
+ // 0. Identifying fields must be cleartext, and the id is the AAD.
478
+ const record = value;
479
+ const encryptedIdentifying = IDENTIFYING_FIELDS.filter(name => isEncryptedValue(record[name]));
480
+ if (encryptedIdentifying.length > 0) {
481
+ return fail(decryptionError('identifying-field', `Identifying field(s) ${encryptedIdentifying.join(', ')} must not be encrypted`, { path: encryptedIdentifying }));
482
+ }
483
+ const labelId = record['id'];
484
+ // 1. Validate every encrypted field before any key derivation.
485
+ const pending = [];
486
+ for (const [name, fieldValue] of Object.entries(value)) {
487
+ if (typeof fieldValue !== 'object' ||
488
+ fieldValue === null ||
489
+ fieldValue.encrypted !== true) {
490
+ continue;
491
+ }
492
+ const field = fieldValue;
493
+ const prepared = prepare(fieldParams(field), options);
494
+ if (isFailure(prepared))
495
+ return fieldError(name, prepared.error);
496
+ const aad = bindingAad(field, { id: labelId, field: name });
497
+ if (isFailure(aad))
498
+ return fieldError(name, aad.error);
499
+ pending.push({ name, prepared, aad });
500
+ }
501
+ // 2. Bound the total key-derivation cost of the document.
502
+ const max = resolveMaxIterations(options);
503
+ if (isFailure(max))
504
+ return max;
505
+ const derivations = new Map();
506
+ for (const { prepared } of pending) {
507
+ const id = derivationId(prepared);
508
+ if (id !== undefined && prepared.mode === 'pbkdf2')
509
+ derivations.set(id, prepared.iterations);
510
+ }
511
+ const total = [...derivations.values()].reduce((sum, n) => sum + n, 0);
512
+ if (total > max) {
513
+ return fail(decryptionError('excessive-iterations', `Document needs ${total} PBKDF2 iterations across ${derivations.size} distinct salts, ` +
514
+ `exceeding maxIterations (${max})`, { iterations: total, maxIterations: max }));
515
+ }
516
+ // 3. Derive (once per distinct salt) and decrypt.
517
+ const keys = new Map();
518
+ const result = { ...value };
519
+ for (const { name, prepared, aad } of pending) {
520
+ const id = derivationId(prepared);
521
+ let cryptoKey = id === undefined ? undefined : keys.get(id);
522
+ if (cryptoKey === undefined) {
523
+ const derived = await deriveKey(prepared);
524
+ if (isFailure(derived))
525
+ return fieldError(name, derived.error);
526
+ cryptoKey = derived;
527
+ if (id !== undefined)
528
+ keys.set(id, derived);
529
+ }
530
+ const opened = await openPrepared(prepared, cryptoKey, aad);
531
+ if (!opened.success)
532
+ return fieldError(name, opened.error);
533
+ try {
534
+ result[name] = JSON.parse(opened.data);
535
+ }
536
+ catch {
537
+ return fieldError(name, decryptionError('invalid-plaintext', 'Decrypted field is not valid JSON'));
538
+ }
539
+ }
540
+ let json;
108
541
  try {
109
- // Get iteration count from payload (self-describing) or use default for backward compat
110
- const iterations = encrypted.iterations ?? DEFAULT_PBKDF2_ITERATIONS;
111
- // Get or derive the decryption key
112
- const cryptoKey = typeof key === 'string' ? await deriveKey(key, iterations) : key;
113
- // Decode payload and IV from base64url
114
- const ciphertext = fromBase64Url(encrypted.payload);
115
- const ivBuffer = fromBase64Url(encrypted.iv);
116
- const iv = new Uint8Array(ivBuffer);
117
- // Decrypt
118
- const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, ciphertext);
119
- // Decode plaintext to string
120
- const json = new TextDecoder().decode(plaintext);
121
- // Parse the decrypted JSON back into a specimen
122
- return parseSpecimen(json);
123
- }
124
- catch (error) {
125
- // Handle decryption errors (wrong key, tampered data, etc.)
126
- const errorMessage = error instanceof Error ? error.message : 'Unknown decryption error';
127
- return {
128
- success: false,
129
- error: new WolsEncryptionError(WolsErrorCode.DECRYPTION_ERROR, `Decryption failed: ${errorMessage}`),
130
- };
542
+ json = JSON.stringify(result);
543
+ }
544
+ catch (err) {
545
+ return fail(decryptionError('invalid-parameter', `Restored specimen cannot be serialized: ${err instanceof Error ? err.message : String(err)}`));
131
546
  }
547
+ return parseDecrypted(json, 'Restored specimen');
132
548
  }
133
549
  //# sourceMappingURL=decrypt.js.map