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.
- package/README.md +223 -38
- package/bin/yamlock +8 -0
- package/dist/cli/cli.js +457 -74
- package/dist/crypto/decrypt.js +112 -13
- package/dist/crypto/encrypt.js +107 -18
- package/dist/crypto/payload-v2.js +371 -0
- package/dist/crypto/utils.js +65 -14
- package/dist/errors.js +59 -0
- package/dist/index.d.ts +123 -0
- package/dist/index.js +10 -0
- package/dist/utils/config.js +294 -54
- package/dist/utils/file.js +58 -0
- package/dist/utils/migrate.js +170 -0
- package/dist/utils/path.js +58 -9
- package/docs/api.md +56 -0
- package/docs/design/payload-v2.md +344 -0
- package/docs/errors.md +71 -0
- package/docs/yaml-behavior.md +51 -0
- package/examples/basic.js +31 -0
- package/examples/docs/ci-cd.md +58 -0
- package/examples/docs/key-rotation.md +65 -0
- package/package.json +19 -7
package/dist/crypto/decrypt.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
+
throw new YamlockDecryptionError('Legacy payload decryption failed.', {
|
|
165
|
+
code: YAMLOCK_ERROR_CODES.DECRYPTION_FAILED,
|
|
166
|
+
cause
|
|
167
|
+
});
|
|
168
|
+
}
|
|
70
169
|
}
|
package/dist/crypto/encrypt.js
CHANGED
|
@@ -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
|
|
79
|
+
* @param {string|object} [algorithmOptions]
|
|
25
80
|
* @returns {string}
|
|
26
81
|
*/
|
|
27
|
-
export function encryptValue(value, key, fieldPath, algorithmOptions
|
|
82
|
+
export function encryptValue(value, key, fieldPath, algorithmOptions) {
|
|
28
83
|
if (typeof value !== 'string') {
|
|
29
|
-
throw new
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
+
}
|