yamlock 0.3.0 → 1.0.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.
@@ -1,5 +1,11 @@
1
1
  import { createDecipheriv } from 'node:crypto';
2
2
 
3
+ import {
4
+ YAMLOCK_ERROR_CODES,
5
+ YamlockDecryptionError,
6
+ YamlockPayloadError,
7
+ YamlockValidationError
8
+ } from '../errors.js';
3
9
  import {
4
10
  decodeFieldPathSalt,
5
11
  deriveKey,
@@ -7,6 +13,61 @@ import {
7
13
  parsePayload,
8
14
  resolveAlgorithmOptions
9
15
  } from './utils.js';
16
+ import {
17
+ decryptValueV2,
18
+ detectPayloadVersion,
19
+ V2_ALGORITHM,
20
+ V2_FORMAT_VERSION
21
+ } from './payload-v2.js';
22
+
23
+ function validateV2Options(input) {
24
+ if (input === undefined) {
25
+ return;
26
+ }
27
+
28
+ if (typeof input === 'string') {
29
+ if (input !== V2_ALGORITHM) {
30
+ throw new YamlockValidationError(
31
+ `yamlock v2 only supports ${V2_ALGORITHM}.`,
32
+ { code: YAMLOCK_ERROR_CODES.UNSUPPORTED_ALGORITHM }
33
+ );
34
+ }
35
+ return;
36
+ }
37
+
38
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
39
+ throw new YamlockValidationError(
40
+ 'yamlock v2 options must be an object or algorithm string.',
41
+ { code: YAMLOCK_ERROR_CODES.INVALID_OPTIONS }
42
+ );
43
+ }
44
+
45
+ if (input.algorithm !== undefined && input.algorithm !== V2_ALGORITHM) {
46
+ throw new YamlockValidationError(
47
+ `yamlock v2 only supports ${V2_ALGORITHM}.`,
48
+ { code: YAMLOCK_ERROR_CODES.UNSUPPORTED_ALGORITHM }
49
+ );
50
+ }
51
+
52
+ if (
53
+ input.formatVersion !== undefined &&
54
+ input.formatVersion !== V2_FORMAT_VERSION
55
+ ) {
56
+ throw new YamlockValidationError(
57
+ 'Payload format version does not match yamlock v2.',
58
+ { code: YAMLOCK_ERROR_CODES.UNSUPPORTED_PAYLOAD_VERSION }
59
+ );
60
+ }
61
+
62
+ const unsupportedOverrides = ['keyLength', 'ivLength', 'authTagLength'];
63
+ const override = unsupportedOverrides.find((name) => input[name] !== undefined);
64
+ if (override) {
65
+ throw new YamlockValidationError(
66
+ `yamlock v2 does not support the ${override} override.`,
67
+ { code: YAMLOCK_ERROR_CODES.INVALID_OPTIONS }
68
+ );
69
+ }
70
+ }
10
71
 
11
72
  function resolveDecryptOptions(payloadAlgorithm, overrides) {
12
73
  if (typeof overrides === 'string' || overrides === undefined) {
@@ -29,7 +90,23 @@ function resolveDecryptOptions(payloadAlgorithm, overrides) {
29
90
  */
30
91
  export function decryptValue(encryptedValue, key, fieldPath, algorithmOptions) {
31
92
  if (!isYamlockPayload(encryptedValue)) {
32
- throw new Error('decryptValue expects a yamlock-formatted payload.');
93
+ throw new YamlockPayloadError(
94
+ 'decryptValue expects a yamlock-formatted payload.',
95
+ { code: YAMLOCK_ERROR_CODES.INVALID_PAYLOAD }
96
+ );
97
+ }
98
+
99
+ const payloadVersion = detectPayloadVersion(encryptedValue);
100
+ if (payloadVersion === V2_FORMAT_VERSION) {
101
+ validateV2Options(algorithmOptions);
102
+ return decryptValueV2(encryptedValue, key, fieldPath);
103
+ }
104
+
105
+ if (payloadVersion !== 1) {
106
+ throw new YamlockPayloadError(
107
+ `Unsupported yamlock payload version: ${payloadVersion}`,
108
+ { code: YAMLOCK_ERROR_CODES.UNSUPPORTED_PAYLOAD_VERSION }
109
+ );
33
110
  }
34
111
 
35
112
  const payload = parsePayload(encryptedValue);
@@ -37,11 +114,17 @@ export function decryptValue(encryptedValue, key, fieldPath, algorithmOptions) {
37
114
  const saltFieldPath = decodeFieldPathSalt(payload.salt);
38
115
 
39
116
  if (!fieldPath) {
40
- throw new Error('Field path is required to decrypt a value.');
117
+ throw new YamlockValidationError(
118
+ 'Field path is required to decrypt a value.',
119
+ { code: YAMLOCK_ERROR_CODES.INVALID_FIELD_PATH }
120
+ );
41
121
  }
42
122
 
43
123
  if (saltFieldPath !== fieldPath) {
44
- throw new Error('Field path does not match the encrypted payload.');
124
+ throw new YamlockDecryptionError(
125
+ 'Field path does not match the encrypted payload.',
126
+ { code: YAMLOCK_ERROR_CODES.FIELD_PATH_MISMATCH }
127
+ );
45
128
  }
46
129
 
47
130
  const derivedKey = deriveKey(key, resolvedOptions);
@@ -49,22 +132,38 @@ export function decryptValue(encryptedValue, key, fieldPath, algorithmOptions) {
49
132
  let authTag;
50
133
  if (resolvedOptions.authTagLength) {
51
134
  if (ciphertext.length < resolvedOptions.authTagLength) {
52
- throw new Error('Encrypted payload is missing an authentication tag.');
135
+ throw new YamlockPayloadError(
136
+ 'Encrypted payload is missing an authentication tag.',
137
+ { code: YAMLOCK_ERROR_CODES.INVALID_PAYLOAD }
138
+ );
53
139
  }
54
140
  authTag = ciphertext.subarray(ciphertext.length - resolvedOptions.authTagLength);
55
141
  ciphertext = ciphertext.subarray(0, ciphertext.length - resolvedOptions.authTagLength);
56
142
  }
57
143
 
58
- const decipherOptions = resolvedOptions.authTagLength ? { authTagLength: resolvedOptions.authTagLength } : undefined;
59
- const decipher = createDecipheriv(resolvedOptions.algorithm, derivedKey, payload.iv, decipherOptions);
60
- if (authTag) {
61
- if (typeof decipher.setAuthTag !== 'function') {
62
- throw new Error(`Algorithm ${resolvedOptions.algorithm} requires auth tags but setAuthTag is unavailable.`);
144
+ try {
145
+ const decipherOptions = resolvedOptions.authTagLength ? { authTagLength: resolvedOptions.authTagLength } : undefined;
146
+ const decipher = createDecipheriv(resolvedOptions.algorithm, derivedKey, payload.iv, decipherOptions);
147
+ if (authTag) {
148
+ if (typeof decipher.setAuthTag !== 'function') {
149
+ throw new YamlockValidationError(
150
+ `Algorithm ${resolvedOptions.algorithm} requires auth tags but setAuthTag is unavailable.`,
151
+ { code: YAMLOCK_ERROR_CODES.UNSUPPORTED_ALGORITHM }
152
+ );
153
+ }
154
+ decipher.setAuthTag(authTag);
63
155
  }
64
- decipher.setAuthTag(authTag);
65
- }
66
156
 
67
- const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
157
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
158
+ return decrypted.toString('utf8');
159
+ } catch (cause) {
160
+ if (cause instanceof YamlockValidationError) {
161
+ throw cause;
162
+ }
68
163
 
69
- return decrypted.toString('utf8');
164
+ throw new YamlockDecryptionError('Legacy payload decryption failed.', {
165
+ code: YAMLOCK_ERROR_CODES.DECRYPTION_FAILED,
166
+ cause
167
+ });
168
+ }
70
169
  }
@@ -1,5 +1,9 @@
1
1
  import { createCipheriv } from 'node:crypto';
2
2
 
3
+ import {
4
+ YAMLOCK_ERROR_CODES,
5
+ YamlockValidationError
6
+ } from '../errors.js';
3
7
  import {
4
8
  DEFAULT_ALGORITHM,
5
9
  deriveKey,
@@ -8,6 +12,57 @@ import {
8
12
  generateIv,
9
13
  resolveAlgorithmOptions
10
14
  } from './utils.js';
15
+ import {
16
+ encryptValueV2,
17
+ V2_ALGORITHM,
18
+ V2_FORMAT_VERSION
19
+ } from './payload-v2.js';
20
+
21
+ function isV2Request(input) {
22
+ if (input === undefined) {
23
+ return true;
24
+ }
25
+
26
+ if (typeof input !== 'object' || input === null) {
27
+ return false;
28
+ }
29
+
30
+ if (input.formatVersion !== undefined) {
31
+ return input.formatVersion === V2_FORMAT_VERSION;
32
+ }
33
+
34
+ return !['algorithm', 'keyLength', 'ivLength', 'authTagLength']
35
+ .some((name) => input[name] !== undefined);
36
+ }
37
+
38
+ function validateV2Options(input) {
39
+ if (input === undefined) {
40
+ return;
41
+ }
42
+
43
+ if (typeof input !== 'object' || input === null || Array.isArray(input)) {
44
+ throw new YamlockValidationError(
45
+ 'yamlock v2 options must be an object.',
46
+ { code: YAMLOCK_ERROR_CODES.INVALID_OPTIONS }
47
+ );
48
+ }
49
+
50
+ if (input.algorithm !== undefined && input.algorithm !== V2_ALGORITHM) {
51
+ throw new YamlockValidationError(
52
+ `yamlock v2 only supports ${V2_ALGORITHM}.`,
53
+ { code: YAMLOCK_ERROR_CODES.UNSUPPORTED_ALGORITHM }
54
+ );
55
+ }
56
+
57
+ const unsupportedOverrides = ['keyLength', 'ivLength', 'authTagLength'];
58
+ const override = unsupportedOverrides.find((name) => input[name] !== undefined);
59
+ if (override) {
60
+ throw new YamlockValidationError(
61
+ `yamlock v2 does not support the ${override} override.`,
62
+ { code: YAMLOCK_ERROR_CODES.INVALID_OPTIONS }
63
+ );
64
+ }
65
+ }
11
66
 
12
67
  function resolveOptions(input) {
13
68
  if (typeof input === 'string' || input === undefined) {
@@ -21,12 +76,32 @@ function resolveOptions(input) {
21
76
  * @param {string} value
22
77
  * @param {string|Buffer} key
23
78
  * @param {string} fieldPath
24
- * @param {string|object} [algorithmOptions=DEFAULT_ALGORITHM]
79
+ * @param {string|object} [algorithmOptions]
25
80
  * @returns {string}
26
81
  */
27
- export function encryptValue(value, key, fieldPath, algorithmOptions = DEFAULT_ALGORITHM) {
82
+ export function encryptValue(value, key, fieldPath, algorithmOptions) {
28
83
  if (typeof value !== 'string') {
29
- throw new Error('encryptValue expects the value to be a string.');
84
+ throw new YamlockValidationError(
85
+ 'encryptValue expects the value to be a string.',
86
+ { code: YAMLOCK_ERROR_CODES.INVALID_VALUE }
87
+ );
88
+ }
89
+
90
+ if (isV2Request(algorithmOptions)) {
91
+ validateV2Options(algorithmOptions);
92
+ return encryptValueV2(value, key, fieldPath);
93
+ }
94
+
95
+ if (
96
+ typeof algorithmOptions === 'object' &&
97
+ algorithmOptions !== null &&
98
+ algorithmOptions.formatVersion !== undefined &&
99
+ algorithmOptions.formatVersion !== 1
100
+ ) {
101
+ throw new YamlockValidationError(
102
+ `Unsupported yamlock payload version: ${algorithmOptions.formatVersion}`,
103
+ { code: YAMLOCK_ERROR_CODES.UNSUPPORTED_PAYLOAD_VERSION }
104
+ );
30
105
  }
31
106
 
32
107
  const resolvedOptions = resolveOptions(algorithmOptions);
@@ -34,21 +109,35 @@ export function encryptValue(value, key, fieldPath, algorithmOptions = DEFAULT_A
34
109
  const iv = generateIv(resolvedOptions);
35
110
  const salt = encodeFieldPathSalt(fieldPath);
36
111
 
37
- const cipherOptions = resolvedOptions.authTagLength ? { authTagLength: resolvedOptions.authTagLength } : undefined;
38
- const cipher = createCipheriv(resolvedOptions.algorithm, derivedKey, iv, cipherOptions);
39
- let encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
40
- if (resolvedOptions.authTagLength) {
41
- if (typeof cipher.getAuthTag !== 'function') {
42
- throw new Error(`Algorithm ${resolvedOptions.algorithm} requires auth tags but getAuthTag is unavailable.`);
112
+ try {
113
+ const cipherOptions = resolvedOptions.authTagLength ? { authTagLength: resolvedOptions.authTagLength } : undefined;
114
+ const cipher = createCipheriv(resolvedOptions.algorithm, derivedKey, iv, cipherOptions);
115
+ let encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
116
+ if (resolvedOptions.authTagLength) {
117
+ if (typeof cipher.getAuthTag !== 'function') {
118
+ throw new YamlockValidationError(
119
+ `Algorithm ${resolvedOptions.algorithm} requires auth tags but getAuthTag is unavailable.`,
120
+ { code: YAMLOCK_ERROR_CODES.UNSUPPORTED_ALGORITHM }
121
+ );
122
+ }
123
+ const authTag = cipher.getAuthTag();
124
+ encrypted = Buffer.concat([encrypted, authTag]);
43
125
  }
44
- const authTag = cipher.getAuthTag();
45
- encrypted = Buffer.concat([encrypted, authTag]);
46
- }
47
126
 
48
- return formatPayload({
49
- algorithm: resolvedOptions.algorithm,
50
- salt,
51
- iv,
52
- data: encrypted
53
- });
127
+ return formatPayload({
128
+ algorithm: resolvedOptions.algorithm,
129
+ salt,
130
+ iv,
131
+ data: encrypted
132
+ });
133
+ } catch (cause) {
134
+ if (cause instanceof YamlockValidationError) {
135
+ throw cause;
136
+ }
137
+
138
+ throw new YamlockValidationError('Legacy encryption options are invalid.', {
139
+ code: YAMLOCK_ERROR_CODES.INVALID_OPTIONS,
140
+ cause
141
+ });
142
+ }
54
143
  }
@@ -0,0 +1,371 @@
1
+ import {
2
+ createCipheriv,
3
+ createDecipheriv,
4
+ randomBytes,
5
+ scryptSync
6
+ } from 'node:crypto';
7
+ import { TextDecoder } from 'node:util';
8
+
9
+ import {
10
+ YAMLOCK_ERROR_CODES,
11
+ YamlockAuthenticationError,
12
+ YamlockPayloadError,
13
+ YamlockValidationError
14
+ } from '../errors.js';
15
+
16
+ export const V2_FORMAT_VERSION = 2;
17
+ export const V2_ALGORITHM = 'aes-256-gcm';
18
+ export const V2_KDF = 'scrypt';
19
+ export const V2_KDF_PARAMS = Object.freeze({ N: 32768, r: 8, p: 1 });
20
+ export const V2_KEY_LENGTH = 32;
21
+ export const V2_KDF_SALT_LENGTH = 16;
22
+ export const V2_NONCE_LENGTH = 12;
23
+ export const V2_AUTH_TAG_LENGTH = 16;
24
+ export const V2_SCRYPT_MAXMEM = 128 * 1024 * 1024;
25
+ export const V2_MAX_SERIALIZED_BYTES = 16 * 1024 * 1024;
26
+ export const V2_MAX_CIPHERTEXT_BYTES = 8 * 1024 * 1024;
27
+ export const V2_MAX_FIELD_PATH_BYTES = 4 * 1024;
28
+
29
+ const V2_FIELD_COUNT = 12;
30
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
31
+ const CANONICAL_VERSION_PATTERN = /^(0|[1-9][0-9]*)$/;
32
+ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
33
+
34
+ function createAuthenticationError() {
35
+ return new YamlockAuthenticationError();
36
+ }
37
+
38
+ function createPayloadError(message, code = YAMLOCK_ERROR_CODES.INVALID_PAYLOAD) {
39
+ return new YamlockPayloadError(message, { code });
40
+ }
41
+
42
+ function createValidationError(message, code) {
43
+ return new YamlockValidationError(message, { code });
44
+ }
45
+
46
+ function normalizeSecret(secret) {
47
+ if (Buffer.isBuffer(secret)) {
48
+ if (secret.length === 0) {
49
+ throw createValidationError(
50
+ 'Encryption key must not be empty.',
51
+ YAMLOCK_ERROR_CODES.INVALID_KEY
52
+ );
53
+ }
54
+ return Buffer.from(secret);
55
+ }
56
+
57
+ if (typeof secret === 'string') {
58
+ if (secret.length === 0) {
59
+ throw createValidationError(
60
+ 'Encryption key must not be empty.',
61
+ YAMLOCK_ERROR_CODES.INVALID_KEY
62
+ );
63
+ }
64
+ return Buffer.from(secret, 'utf8');
65
+ }
66
+
67
+ throw createValidationError(
68
+ 'Encryption key must be a string or Buffer.',
69
+ YAMLOCK_ERROR_CODES.INVALID_KEY
70
+ );
71
+ }
72
+
73
+ function encodeFieldPath(fieldPath) {
74
+ if (typeof fieldPath !== 'string' || fieldPath.length === 0) {
75
+ throw createValidationError(
76
+ 'Field path must be a non-empty string.',
77
+ YAMLOCK_ERROR_CODES.INVALID_FIELD_PATH
78
+ );
79
+ }
80
+
81
+ const bytes = Buffer.from(fieldPath, 'utf8');
82
+ if (bytes.length > V2_MAX_FIELD_PATH_BYTES) {
83
+ throw createValidationError(
84
+ `Field path exceeds ${V2_MAX_FIELD_PATH_BYTES} bytes.`,
85
+ YAMLOCK_ERROR_CODES.INVALID_FIELD_PATH
86
+ );
87
+ }
88
+
89
+ if (UTF8_DECODER.decode(bytes) !== fieldPath) {
90
+ throw createValidationError(
91
+ 'Field path must contain valid UTF-8 text.',
92
+ YAMLOCK_ERROR_CODES.INVALID_FIELD_PATH
93
+ );
94
+ }
95
+
96
+ return bytes.toString('base64url');
97
+ }
98
+
99
+ function decodeBase64Url(segment, name, { allowEmpty = false, exactLength, maxLength } = {}) {
100
+ if (typeof segment !== 'string' || (!allowEmpty && segment.length === 0)) {
101
+ throw createPayloadError(`${name} must not be empty.`);
102
+ }
103
+
104
+ if (!BASE64URL_PATTERN.test(segment)) {
105
+ throw createPayloadError(`${name} must use unpadded base64url encoding.`);
106
+ }
107
+
108
+ const decoded = Buffer.from(segment, 'base64url');
109
+ if (decoded.toString('base64url') !== segment) {
110
+ throw createPayloadError(`${name} must use canonical base64url encoding.`);
111
+ }
112
+
113
+ if (exactLength !== undefined && decoded.length !== exactLength) {
114
+ throw createPayloadError(`${name} must decode to exactly ${exactLength} bytes.`);
115
+ }
116
+
117
+ if (maxLength !== undefined && decoded.length > maxLength) {
118
+ throw createPayloadError(
119
+ `${name} exceeds ${maxLength} bytes.`,
120
+ YAMLOCK_ERROR_CODES.PAYLOAD_TOO_LARGE
121
+ );
122
+ }
123
+
124
+ return decoded;
125
+ }
126
+
127
+ function decodeFieldPath(segment) {
128
+ const bytes = decodeBase64Url(segment, 'Field path', {
129
+ maxLength: V2_MAX_FIELD_PATH_BYTES
130
+ });
131
+
132
+ let fieldPath;
133
+ try {
134
+ fieldPath = UTF8_DECODER.decode(bytes);
135
+ } catch {
136
+ throw createPayloadError('Field path must contain valid UTF-8 text.');
137
+ }
138
+
139
+ if (fieldPath.length === 0) {
140
+ throw createPayloadError('Field path must not be empty.');
141
+ }
142
+
143
+ return fieldPath;
144
+ }
145
+
146
+ function validateMaterial(value, name, expectedLength) {
147
+ if (!Buffer.isBuffer(value) || value.length !== expectedLength) {
148
+ throw createValidationError(
149
+ `${name} must be a ${expectedLength}-byte Buffer.`,
150
+ YAMLOCK_ERROR_CODES.INVALID_OPTIONS
151
+ );
152
+ }
153
+ return Buffer.from(value);
154
+ }
155
+
156
+ function deriveV2Key(secret, kdfSalt) {
157
+ const secretBytes = normalizeSecret(secret);
158
+ try {
159
+ return scryptSync(secretBytes, kdfSalt, V2_KEY_LENGTH, {
160
+ ...V2_KDF_PARAMS,
161
+ maxmem: V2_SCRYPT_MAXMEM
162
+ });
163
+ } finally {
164
+ secretBytes.fill(0);
165
+ }
166
+ }
167
+
168
+ function buildHeader({ kdfSalt, nonce, encodedFieldPath }) {
169
+ return [
170
+ 'yl',
171
+ String(V2_FORMAT_VERSION),
172
+ V2_ALGORITHM,
173
+ V2_KDF,
174
+ String(V2_KDF_PARAMS.N),
175
+ String(V2_KDF_PARAMS.r),
176
+ String(V2_KDF_PARAMS.p),
177
+ kdfSalt.toString('base64url'),
178
+ nonce.toString('base64url'),
179
+ encodedFieldPath
180
+ ];
181
+ }
182
+
183
+ export function detectPayloadVersion(value) {
184
+ if (typeof value !== 'string' || !value.startsWith('yl|')) {
185
+ return null;
186
+ }
187
+
188
+ const nextDelimiter = value.indexOf('|', 3);
189
+ const secondSegment = nextDelimiter === -1
190
+ ? value.slice(3)
191
+ : value.slice(3, nextDelimiter);
192
+
193
+ if (CANONICAL_VERSION_PATTERN.test(secondSegment)) {
194
+ return Number(secondSegment);
195
+ }
196
+
197
+ return 1;
198
+ }
199
+
200
+ export function isV2Payload(value) {
201
+ return detectPayloadVersion(value) === V2_FORMAT_VERSION;
202
+ }
203
+
204
+ export function parseV2Payload(value) {
205
+ if (typeof value !== 'string' || !value.startsWith('yl|2|')) {
206
+ throw createPayloadError('Value is not a yamlock v2 payload.');
207
+ }
208
+
209
+ if (Buffer.byteLength(value, 'utf8') > V2_MAX_SERIALIZED_BYTES) {
210
+ throw createPayloadError(
211
+ `Payload exceeds ${V2_MAX_SERIALIZED_BYTES} bytes.`,
212
+ YAMLOCK_ERROR_CODES.PAYLOAD_TOO_LARGE
213
+ );
214
+ }
215
+
216
+ const parts = value.split('|');
217
+ if (parts.length !== V2_FIELD_COUNT) {
218
+ throw createPayloadError(
219
+ `Malformed yamlock v2 payload: expected ${V2_FIELD_COUNT} fields.`
220
+ );
221
+ }
222
+
223
+ const [
224
+ marker,
225
+ version,
226
+ algorithm,
227
+ kdf,
228
+ cost,
229
+ blockSize,
230
+ parallelization,
231
+ saltSegment,
232
+ nonceSegment,
233
+ pathSegment,
234
+ ciphertextSegment,
235
+ tagSegment
236
+ ] = parts;
237
+
238
+ if (marker !== 'yl' || version !== String(V2_FORMAT_VERSION)) {
239
+ throw createPayloadError(
240
+ 'Unsupported yamlock payload version.',
241
+ YAMLOCK_ERROR_CODES.UNSUPPORTED_PAYLOAD_VERSION
242
+ );
243
+ }
244
+
245
+ if (algorithm !== V2_ALGORITHM) {
246
+ throw createPayloadError(
247
+ `Unsupported yamlock v2 algorithm: ${algorithm}`,
248
+ YAMLOCK_ERROR_CODES.UNSUPPORTED_PAYLOAD
249
+ );
250
+ }
251
+
252
+ if (kdf !== V2_KDF) {
253
+ throw createPayloadError(
254
+ `Unsupported yamlock v2 KDF: ${kdf}`,
255
+ YAMLOCK_ERROR_CODES.UNSUPPORTED_PAYLOAD
256
+ );
257
+ }
258
+
259
+ if (
260
+ cost !== String(V2_KDF_PARAMS.N) ||
261
+ blockSize !== String(V2_KDF_PARAMS.r) ||
262
+ parallelization !== String(V2_KDF_PARAMS.p)
263
+ ) {
264
+ throw createPayloadError(
265
+ 'Unsupported yamlock v2 KDF parameters.',
266
+ YAMLOCK_ERROR_CODES.UNSUPPORTED_PAYLOAD
267
+ );
268
+ }
269
+
270
+ const kdfSalt = decodeBase64Url(saltSegment, 'KDF salt', {
271
+ exactLength: V2_KDF_SALT_LENGTH
272
+ });
273
+ const nonce = decodeBase64Url(nonceSegment, 'Nonce', {
274
+ exactLength: V2_NONCE_LENGTH
275
+ });
276
+ const storedFieldPath = decodeFieldPath(pathSegment);
277
+ const ciphertext = decodeBase64Url(ciphertextSegment, 'Ciphertext', {
278
+ allowEmpty: true,
279
+ maxLength: V2_MAX_CIPHERTEXT_BYTES
280
+ });
281
+ const authTag = decodeBase64Url(tagSegment, 'Authentication tag', {
282
+ exactLength: V2_AUTH_TAG_LENGTH
283
+ });
284
+
285
+ return {
286
+ version: V2_FORMAT_VERSION,
287
+ algorithm,
288
+ kdf,
289
+ kdfParams: { ...V2_KDF_PARAMS },
290
+ kdfSalt,
291
+ nonce,
292
+ storedFieldPath,
293
+ encodedFieldPath: pathSegment,
294
+ ciphertext,
295
+ authTag,
296
+ headerSegments: parts.slice(0, 10)
297
+ };
298
+ }
299
+
300
+ export function encryptValueV2(value, secret, fieldPath, testMaterial = {}) {
301
+ if (typeof value !== 'string') {
302
+ throw createValidationError(
303
+ 'encryptValue expects the value to be a string.',
304
+ YAMLOCK_ERROR_CODES.INVALID_VALUE
305
+ );
306
+ }
307
+
308
+ const plaintext = Buffer.from(value, 'utf8');
309
+ if (plaintext.length > V2_MAX_CIPHERTEXT_BYTES) {
310
+ throw createValidationError(
311
+ `Plaintext exceeds ${V2_MAX_CIPHERTEXT_BYTES} bytes.`,
312
+ YAMLOCK_ERROR_CODES.VALUE_TOO_LARGE
313
+ );
314
+ }
315
+
316
+ const encodedFieldPath = encodeFieldPath(fieldPath);
317
+ const kdfSalt = testMaterial.kdfSalt === undefined
318
+ ? randomBytes(V2_KDF_SALT_LENGTH)
319
+ : validateMaterial(testMaterial.kdfSalt, 'KDF salt', V2_KDF_SALT_LENGTH);
320
+ const nonce = testMaterial.nonce === undefined
321
+ ? randomBytes(V2_NONCE_LENGTH)
322
+ : validateMaterial(testMaterial.nonce, 'Nonce', V2_NONCE_LENGTH);
323
+ const key = deriveV2Key(secret, kdfSalt);
324
+ const headerSegments = buildHeader({ kdfSalt, nonce, encodedFieldPath });
325
+ const aad = Buffer.from(headerSegments.join('|'), 'utf8');
326
+
327
+ try {
328
+ const cipher = createCipheriv(V2_ALGORITHM, key, nonce, {
329
+ authTagLength: V2_AUTH_TAG_LENGTH
330
+ });
331
+ cipher.setAAD(aad);
332
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
333
+ const authTag = cipher.getAuthTag();
334
+
335
+ return [
336
+ ...headerSegments,
337
+ ciphertext.toString('base64url'),
338
+ authTag.toString('base64url')
339
+ ].join('|');
340
+ } finally {
341
+ key.fill(0);
342
+ }
343
+ }
344
+
345
+ export function decryptValueV2(encryptedValue, secret, fieldPath) {
346
+ const payload = parseV2Payload(encryptedValue);
347
+ const callerPathSegment = encodeFieldPath(fieldPath);
348
+ const key = deriveV2Key(secret, payload.kdfSalt);
349
+
350
+ try {
351
+ const decipher = createDecipheriv(V2_ALGORITHM, key, payload.nonce, {
352
+ authTagLength: V2_AUTH_TAG_LENGTH
353
+ });
354
+ decipher.setAAD(Buffer.from(payload.headerSegments.join('|'), 'utf8'));
355
+ decipher.setAuthTag(payload.authTag);
356
+ const plaintext = Buffer.concat([
357
+ decipher.update(payload.ciphertext),
358
+ decipher.final()
359
+ ]);
360
+
361
+ if (payload.encodedFieldPath !== callerPathSegment) {
362
+ throw createAuthenticationError();
363
+ }
364
+
365
+ return plaintext.toString('utf8');
366
+ } catch {
367
+ throw createAuthenticationError();
368
+ } finally {
369
+ key.fill(0);
370
+ }
371
+ }