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