@wemush/wols 0.1.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 (121) 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/compact-url.js +64 -0
  6. package/dist/cjs/compact-url.js.map +1 -1
  7. package/dist/cjs/crypto/decrypt.d.ts +110 -0
  8. package/dist/cjs/crypto/decrypt.d.ts.map +1 -0
  9. package/dist/cjs/crypto/decrypt.js +521 -90
  10. package/dist/cjs/crypto/decrypt.js.map +1 -1
  11. package/dist/cjs/crypto/encrypt.d.ts +132 -0
  12. package/dist/cjs/crypto/encrypt.d.ts.map +1 -0
  13. package/dist/cjs/crypto/encrypt.js +288 -105
  14. package/dist/cjs/crypto/encrypt.js.map +1 -1
  15. package/dist/cjs/crypto/index.d.ts +12 -0
  16. package/dist/cjs/crypto/index.d.ts.map +1 -0
  17. package/dist/cjs/crypto/index.js +15 -2
  18. package/dist/cjs/crypto/index.js.map +1 -1
  19. package/dist/cjs/crypto/shared.d.ts +116 -0
  20. package/dist/cjs/crypto/shared.d.ts.map +1 -0
  21. package/dist/cjs/crypto/shared.js +238 -0
  22. package/dist/cjs/crypto/shared.js.map +1 -0
  23. package/dist/cjs/errors.d.ts +133 -0
  24. package/dist/cjs/errors.d.ts.map +1 -0
  25. package/dist/cjs/errors.js +39 -1
  26. package/dist/cjs/errors.js.map +1 -1
  27. package/dist/cjs/index.d.ts +29 -0
  28. package/dist/cjs/index.d.ts.map +1 -0
  29. package/dist/cjs/index.js +70 -3
  30. package/dist/cjs/index.js.map +1 -1
  31. package/dist/cjs/package.json +3 -0
  32. package/dist/cjs/parser.d.ts +30 -0
  33. package/dist/cjs/parser.d.ts.map +1 -0
  34. package/dist/cjs/parser.js +3 -0
  35. package/dist/cjs/parser.js.map +1 -1
  36. package/dist/cjs/qr/generate.d.ts +62 -0
  37. package/dist/cjs/qr/generate.d.ts.map +1 -0
  38. package/dist/cjs/qr/index.d.ts +10 -0
  39. package/dist/cjs/qr/index.d.ts.map +1 -0
  40. package/dist/cjs/specimen.d.ts +96 -0
  41. package/dist/cjs/specimen.d.ts.map +1 -0
  42. package/dist/cjs/specimen.js +95 -9
  43. package/dist/cjs/specimen.js.map +1 -1
  44. package/dist/cjs/types.d.ts +735 -0
  45. package/dist/cjs/types.d.ts.map +1 -0
  46. package/dist/cjs/types.js +327 -3
  47. package/dist/cjs/types.js.map +1 -1
  48. package/dist/cjs/utils/cuid.d.ts +46 -0
  49. package/dist/cjs/utils/cuid.d.ts.map +1 -0
  50. package/dist/cjs/utils/cuid.js +6 -4
  51. package/dist/cjs/utils/cuid.js.map +1 -1
  52. package/dist/cjs/utils/environment.d.ts +137 -0
  53. package/dist/cjs/utils/environment.d.ts.map +1 -0
  54. package/dist/cjs/utils/environment.js +206 -0
  55. package/dist/cjs/utils/environment.js.map +1 -0
  56. package/dist/cjs/utils/iso8601.d.ts +79 -0
  57. package/dist/cjs/utils/iso8601.d.ts.map +1 -0
  58. package/dist/cjs/utils/migration.d.ts +162 -0
  59. package/dist/cjs/utils/migration.d.ts.map +1 -0
  60. package/dist/cjs/utils/migration.js +248 -0
  61. package/dist/cjs/utils/migration.js.map +1 -0
  62. package/dist/cjs/validator.d.ts +25 -0
  63. package/dist/cjs/validator.d.ts.map +1 -0
  64. package/dist/cjs/validator.js +85 -21
  65. package/dist/cjs/validator.js.map +1 -1
  66. package/dist/esm/compact-url.js +62 -0
  67. package/dist/esm/compact-url.js.map +1 -1
  68. package/dist/esm/crypto/decrypt.js +517 -90
  69. package/dist/esm/crypto/decrypt.js.map +1 -1
  70. package/dist/esm/crypto/encrypt.js +284 -105
  71. package/dist/esm/crypto/encrypt.js.map +1 -1
  72. package/dist/esm/crypto/index.js +5 -3
  73. package/dist/esm/crypto/index.js.map +1 -1
  74. package/dist/esm/crypto/shared.js +222 -0
  75. package/dist/esm/crypto/shared.js.map +1 -0
  76. package/dist/esm/errors.js +37 -0
  77. package/dist/esm/errors.js.map +1 -1
  78. package/dist/esm/index.js +26 -6
  79. package/dist/esm/index.js.map +1 -1
  80. package/dist/esm/parser.js +3 -0
  81. package/dist/esm/parser.js.map +1 -1
  82. package/dist/esm/specimen.js +95 -10
  83. package/dist/esm/specimen.js.map +1 -1
  84. package/dist/esm/types.js +317 -2
  85. package/dist/esm/types.js.map +1 -1
  86. package/dist/esm/utils/cuid.js +6 -4
  87. package/dist/esm/utils/cuid.js.map +1 -1
  88. package/dist/esm/utils/environment.js +193 -0
  89. package/dist/esm/utils/environment.js.map +1 -0
  90. package/dist/esm/utils/migration.js +237 -0
  91. package/dist/esm/utils/migration.js.map +1 -0
  92. package/dist/esm/validator.js +86 -22
  93. package/dist/esm/validator.js.map +1 -1
  94. package/dist/types/compact-url.d.ts +46 -0
  95. package/dist/types/compact-url.d.ts.map +1 -1
  96. package/dist/types/crypto/decrypt.d.ts +95 -18
  97. package/dist/types/crypto/decrypt.d.ts.map +1 -1
  98. package/dist/types/crypto/encrypt.d.ts +114 -14
  99. package/dist/types/crypto/encrypt.d.ts.map +1 -1
  100. package/dist/types/crypto/index.d.ts +5 -3
  101. package/dist/types/crypto/index.d.ts.map +1 -1
  102. package/dist/types/crypto/shared.d.ts +116 -0
  103. package/dist/types/crypto/shared.d.ts.map +1 -0
  104. package/dist/types/errors.d.ts +31 -1
  105. package/dist/types/errors.d.ts.map +1 -1
  106. package/dist/types/index.d.ts +15 -7
  107. package/dist/types/index.d.ts.map +1 -1
  108. package/dist/types/parser.d.ts.map +1 -1
  109. package/dist/types/specimen.d.ts +29 -4
  110. package/dist/types/specimen.d.ts.map +1 -1
  111. package/dist/types/types.d.ts +483 -17
  112. package/dist/types/types.d.ts.map +1 -1
  113. package/dist/types/utils/cuid.d.ts +5 -3
  114. package/dist/types/utils/cuid.d.ts.map +1 -1
  115. package/dist/types/utils/environment.d.ts +137 -0
  116. package/dist/types/utils/environment.d.ts.map +1 -0
  117. package/dist/types/utils/migration.d.ts +162 -0
  118. package/dist/types/utils/migration.d.ts.map +1 -0
  119. package/dist/types/validator.d.ts +1 -1
  120. package/dist/types/validator.d.ts.map +1 -1
  121. package/package.json +32 -14
@@ -1,125 +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.
8
+ *
9
+ * Failures never throw; they return `{ success: false, error }` where `error`
10
+ * is a {@link WolsDecryptionError} with a stable, typed `reason`.
11
+ *
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.
7
19
  *
8
20
  * @module crypto/decrypt
9
21
  */
10
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.parseEncryptedUrl = parseEncryptedUrl;
24
+ exports.decryptPlaintext = decryptPlaintext;
11
25
  exports.decryptSpecimen = decryptSpecimen;
12
- const errors_js_1 = require("../errors.js");
26
+ exports.decryptField = decryptField;
27
+ exports.decryptFields = decryptFields;
13
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;
14
32
  // =============================================================================
15
- // CONSTANTS
33
+ // URL PARSING
16
34
  // =============================================================================
17
- /** Salt for PBKDF2 key derivation (must match encrypt.ts) */
18
- const PBKDF2_SALT = new TextEncoder().encode('@wemush/wols/crypto/v1');
19
- /** Number of PBKDF2 iterations (must match encrypt.ts) */
20
- const 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
+ }
21
142
  // =============================================================================
22
- // UTILITY FUNCTIONS
143
+ // VALIDATION (everything before key derivation)
23
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
+ }
24
179
  /**
25
- * Convert base64url string to ArrayBuffer
180
+ * Validate every parameter and the key type, and decode all binary values,
181
+ * without doing any key derivation.
26
182
  */
27
- function fromBase64Url(base64url) {
28
- // Convert base64url to base64
29
- const base64 = base64url
30
- .replace(/-/g, '+')
31
- .replace(/_/g, '/')
32
- .padEnd(base64url.length + ((4 - (base64url.length % 4)) % 4), '=');
33
- // Decode base64 to binary string
34
- const binaryString = atob(base64);
35
- // Convert to ArrayBuffer
36
- const bytes = new Uint8Array(binaryString.length);
37
- for (let i = 0; i < binaryString.length; i++) {
38
- bytes[i] = binaryString.charCodeAt(i);
39
- }
40
- 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);
41
290
  }
42
291
  /**
43
- * Derive a CryptoKey from a password using PBKDF2
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.
44
294
  */
45
- async function deriveKey(password) {
46
- const encoder = new TextEncoder();
47
- const passwordBytes = encoder.encode(password);
48
- // Import password as key material
49
- const keyMaterial = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, [
50
- 'deriveKey',
51
- ]);
52
- // Derive AES-256-GCM key
53
- return crypto.subtle.deriveKey({
54
- name: 'PBKDF2',
55
- salt: PBKDF2_SALT,
56
- iterations: PBKDF2_ITERATIONS,
57
- hash: 'SHA-256',
58
- }, 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 };
59
317
  }
60
318
  // =============================================================================
61
- // DECRYPTION FUNCTIONS
319
+ // PUBLIC API
62
320
  // =============================================================================
63
321
  /**
64
- * 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.
65
389
  *
66
- * @param encrypted - The encrypted specimen wrapper
67
- * @param options - Decryption options (key required)
68
- * @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.
69
394
  *
70
395
  * @example
71
396
  * ```typescript
72
- * import { decryptSpecimen } from '@wemush/wols/crypto';
397
+ * const r = await decryptField(partial.strain, { key }, { id: partial.id, field: 'strain' });
398
+ * ```
73
399
  *
74
- * const result = await decryptSpecimen(encrypted, {
75
- * key: 'my-secret-password',
76
- * });
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.
77
453
  *
78
- * if (result.success) {
79
- * console.log(result.data);
80
- * } else {
81
- * console.error(result.error);
82
- * }
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;
83
471
  * ```
472
+ *
473
+ * @since 1.3.0
84
474
  */
85
- async function decryptSpecimen(encrypted, options) {
86
- const { key } = options;
87
- // Validate encrypted specimen structure
88
- if (!encrypted.payload) {
89
- return {
90
- success: false,
91
- error: new errors_js_1.WolsEncryptionError(errors_js_1.WolsErrorCode.DECRYPTION_ERROR, 'Missing encrypted payload', {
92
- path: ['payload'],
93
- }),
94
- };
95
- }
96
- if (!encrypted.iv) {
97
- return {
98
- success: false,
99
- error: new errors_js_1.WolsEncryptionError(errors_js_1.WolsErrorCode.DECRYPTION_ERROR, 'Missing initialization vector (IV)', { path: ['iv'] }),
100
- };
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'));
101
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;
102
548
  try {
103
- // Get or derive the decryption key
104
- const cryptoKey = typeof key === 'string' ? await deriveKey(key) : key;
105
- // Decode payload and IV from base64url
106
- const ciphertext = fromBase64Url(encrypted.payload);
107
- const ivBuffer = fromBase64Url(encrypted.iv);
108
- const iv = new Uint8Array(ivBuffer);
109
- // Decrypt
110
- const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, cryptoKey, ciphertext);
111
- // Decode plaintext to string
112
- const json = new TextDecoder().decode(plaintext);
113
- // Parse the decrypted JSON back into a specimen
114
- return (0, parser_js_1.parseSpecimen)(json);
115
- }
116
- catch (error) {
117
- // Handle decryption errors (wrong key, tampered data, etc.)
118
- const errorMessage = error instanceof Error ? error.message : 'Unknown decryption error';
119
- return {
120
- success: false,
121
- error: new errors_js_1.WolsEncryptionError(errors_js_1.WolsErrorCode.DECRYPTION_ERROR, `Decryption failed: ${errorMessage}`),
122
- };
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)}`));
123
553
  }
554
+ return parseDecrypted(json, 'Restored specimen');
124
555
  }
125
556
  //# sourceMappingURL=decrypt.js.map