@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.
- package/CHANGELOG.md +170 -0
- package/README.md +204 -31
- package/dist/cjs/compact-url.d.ts +150 -0
- package/dist/cjs/compact-url.d.ts.map +1 -0
- package/dist/cjs/crypto/decrypt.d.ts +110 -0
- package/dist/cjs/crypto/decrypt.d.ts.map +1 -0
- package/dist/cjs/crypto/decrypt.js +519 -99
- package/dist/cjs/crypto/decrypt.js.map +1 -1
- package/dist/cjs/crypto/encrypt.d.ts +132 -0
- package/dist/cjs/crypto/encrypt.d.ts.map +1 -0
- package/dist/cjs/crypto/encrypt.js +281 -200
- package/dist/cjs/crypto/encrypt.js.map +1 -1
- package/dist/cjs/crypto/index.d.ts +12 -0
- package/dist/cjs/crypto/index.d.ts.map +1 -0
- package/dist/cjs/crypto/index.js +15 -2
- package/dist/cjs/crypto/index.js.map +1 -1
- package/dist/cjs/crypto/shared.d.ts +116 -0
- package/dist/cjs/crypto/shared.d.ts.map +1 -0
- package/dist/cjs/crypto/shared.js +238 -0
- package/dist/cjs/crypto/shared.js.map +1 -0
- package/dist/cjs/errors.d.ts +133 -0
- package/dist/cjs/errors.d.ts.map +1 -0
- package/dist/cjs/errors.js +39 -1
- package/dist/cjs/errors.js.map +1 -1
- package/dist/cjs/index.d.ts +29 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +19 -4
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/parser.d.ts +30 -0
- package/dist/cjs/parser.d.ts.map +1 -0
- package/dist/cjs/qr/generate.d.ts +62 -0
- package/dist/cjs/qr/generate.d.ts.map +1 -0
- package/dist/cjs/qr/index.d.ts +10 -0
- package/dist/cjs/qr/index.d.ts.map +1 -0
- package/dist/cjs/specimen.d.ts +96 -0
- package/dist/cjs/specimen.d.ts.map +1 -0
- package/dist/cjs/specimen.js +84 -11
- package/dist/cjs/specimen.js.map +1 -1
- package/dist/cjs/types.d.ts +735 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +65 -5
- package/dist/cjs/types.js.map +1 -1
- package/dist/cjs/utils/cuid.d.ts +46 -0
- package/dist/cjs/utils/cuid.d.ts.map +1 -0
- package/dist/cjs/utils/cuid.js +6 -4
- package/dist/cjs/utils/cuid.js.map +1 -1
- package/dist/cjs/utils/environment.d.ts +137 -0
- package/dist/cjs/utils/environment.d.ts.map +1 -0
- package/dist/cjs/utils/iso8601.d.ts +79 -0
- package/dist/cjs/utils/iso8601.d.ts.map +1 -0
- package/dist/cjs/utils/migration.d.ts +162 -0
- package/dist/cjs/utils/migration.d.ts.map +1 -0
- package/dist/cjs/validator.d.ts +25 -0
- package/dist/cjs/validator.d.ts.map +1 -0
- package/dist/cjs/validator.js +33 -22
- package/dist/cjs/validator.js.map +1 -1
- package/dist/esm/crypto/decrypt.js +515 -99
- package/dist/esm/crypto/decrypt.js.map +1 -1
- package/dist/esm/crypto/encrypt.js +277 -200
- package/dist/esm/crypto/encrypt.js.map +1 -1
- package/dist/esm/crypto/index.js +5 -3
- package/dist/esm/crypto/index.js.map +1 -1
- package/dist/esm/crypto/shared.js +222 -0
- package/dist/esm/crypto/shared.js.map +1 -0
- package/dist/esm/errors.js +37 -0
- package/dist/esm/errors.js.map +1 -1
- package/dist/esm/index.js +6 -6
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/specimen.js +84 -12
- package/dist/esm/specimen.js.map +1 -1
- package/dist/esm/types.js +63 -4
- package/dist/esm/types.js.map +1 -1
- package/dist/esm/utils/cuid.js +6 -4
- package/dist/esm/utils/cuid.js.map +1 -1
- package/dist/esm/validator.js +34 -23
- package/dist/esm/validator.js.map +1 -1
- package/dist/types/crypto/decrypt.d.ts +93 -22
- package/dist/types/crypto/decrypt.d.ts.map +1 -1
- package/dist/types/crypto/encrypt.d.ts +106 -61
- package/dist/types/crypto/encrypt.d.ts.map +1 -1
- package/dist/types/crypto/index.d.ts +5 -3
- package/dist/types/crypto/index.d.ts.map +1 -1
- package/dist/types/crypto/shared.d.ts +116 -0
- package/dist/types/crypto/shared.d.ts.map +1 -0
- package/dist/types/errors.d.ts +31 -1
- package/dist/types/errors.d.ts.map +1 -1
- package/dist/types/index.d.ts +7 -7
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/specimen.d.ts +29 -4
- package/dist/types/specimen.d.ts.map +1 -1
- package/dist/types/types.d.ts +265 -40
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils/cuid.d.ts +5 -3
- package/dist/types/utils/cuid.d.ts.map +1 -1
- package/dist/types/validator.d.ts +1 -1
- package/dist/types/validator.d.ts.map +1 -1
- 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
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
+
* Failures never throw; they return `{ success: false, error }` where `error`
|
|
10
|
+
* is a {@link WolsDecryptionError} with a stable, typed `reason`.
|
|
9
11
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
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
|
-
//
|
|
33
|
+
// URL PARSING
|
|
22
34
|
// =============================================================================
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
//
|
|
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
|
-
*
|
|
180
|
+
* Validate every parameter and the key type, and decode all binary values,
|
|
181
|
+
* without doing any key derivation.
|
|
32
182
|
*/
|
|
33
|
-
function
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
*
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
//
|
|
319
|
+
// PUBLIC API
|
|
71
320
|
// =============================================================================
|
|
72
321
|
/**
|
|
73
|
-
* Decrypt an encrypted
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
-
*
|
|
397
|
+
* const r = await decryptField(partial.strain, { key }, { id: partial.id, field: 'strain' });
|
|
398
|
+
* ```
|
|
82
399
|
*
|
|
83
|
-
*
|
|
84
|
-
|
|
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
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|