@push.rocks/smartsecret 1.0.2 → 1.2.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.
@@ -0,0 +1,76 @@
1
+ export type TSmartSecretKeyringErrorCode =
2
+ | 'AUTHENTICATION_FAILED'
3
+ | 'CONFIG_INVALID'
4
+ | 'CONTEXT_MISMATCH'
5
+ | 'CRYPTO_OPERATION_FAILED'
6
+ | 'CREDENTIAL_FILE_INVALID'
7
+ | 'DESTROYED'
8
+ | 'ENVELOPE_ID_MISMATCH'
9
+ | 'ENVELOPE_INVALID'
10
+ | 'KEK_FINGERPRINT_MISMATCH'
11
+ | 'KEK_UNAVAILABLE'
12
+ | 'INVALID_ARGUMENT'
13
+ | 'PLATFORM_UNSUPPORTED'
14
+ | 'SIZE_LIMIT_EXCEEDED'
15
+ | 'TARGET_VERSION_MISMATCH';
16
+
17
+ export interface ISmartSecretKeyringErrorJson {
18
+ name: 'SmartSecretKeyringError';
19
+ code: TSmartSecretKeyringErrorCode;
20
+ message: string;
21
+ }
22
+
23
+ const inspectSymbol = Symbol.for('nodejs.util.inspect.custom');
24
+ const errorCodes = new Set<TSmartSecretKeyringErrorCode>([
25
+ 'AUTHENTICATION_FAILED',
26
+ 'CONFIG_INVALID',
27
+ 'CONTEXT_MISMATCH',
28
+ 'CRYPTO_OPERATION_FAILED',
29
+ 'CREDENTIAL_FILE_INVALID',
30
+ 'DESTROYED',
31
+ 'ENVELOPE_ID_MISMATCH',
32
+ 'ENVELOPE_INVALID',
33
+ 'KEK_FINGERPRINT_MISMATCH',
34
+ 'KEK_UNAVAILABLE',
35
+ 'INVALID_ARGUMENT',
36
+ 'PLATFORM_UNSUPPORTED',
37
+ 'SIZE_LIMIT_EXCEEDED',
38
+ 'TARGET_VERSION_MISMATCH',
39
+ ]);
40
+
41
+ /** A code-only error that intentionally retains no operation values or causes. */
42
+ export class SmartSecretKeyringError extends Error {
43
+ public readonly code: TSmartSecretKeyringErrorCode;
44
+ public readonly cause: undefined;
45
+
46
+ constructor(codeArg: TSmartSecretKeyringErrorCode) {
47
+ const code = errorCodes.has(codeArg) ? codeArg : 'INVALID_ARGUMENT';
48
+ const message = `SmartSecret keyring operation failed (${code}).`;
49
+ super(message);
50
+ this.name = 'SmartSecretKeyringError';
51
+ this.code = code;
52
+ this.stack = `${this.name}: ${message}`;
53
+ Object.defineProperty(this, 'cause', {
54
+ configurable: false,
55
+ enumerable: false,
56
+ value: undefined,
57
+ writable: false,
58
+ });
59
+ }
60
+
61
+ public toJSON(): ISmartSecretKeyringErrorJson {
62
+ return {
63
+ name: 'SmartSecretKeyringError',
64
+ code: this.code,
65
+ message: this.message,
66
+ };
67
+ }
68
+
69
+ public [inspectSymbol](): ISmartSecretKeyringErrorJson {
70
+ return this.toJSON();
71
+ }
72
+ }
73
+
74
+ export const createSmartSecretKeyringError = (
75
+ codeArg: TSmartSecretKeyringErrorCode,
76
+ ): SmartSecretKeyringError => new SmartSecretKeyringError(codeArg);
@@ -0,0 +1,377 @@
1
+ import * as plugins from './smartsecret.plugins.js';
2
+ import {
3
+ SmartSecretKeyringError,
4
+ createSmartSecretKeyringError,
5
+ } from './smartsecret.keyring.error.js';
6
+
7
+ export const SMARTSECRET_KEYRING_SCHEMA_VERSION = 1 as const;
8
+ export const SMARTSECRET_KEYRING_PROFILE = 'smartsecret-aes-256-gcm-dek-kek-v1' as const;
9
+ export type TSmartSecretKeyringProfile = typeof SMARTSECRET_KEYRING_PROFILE;
10
+
11
+ export const SMARTSECRET_KEYRING_MAX_PAYLOAD_BYTES = 524_288;
12
+ export const SMARTSECRET_KEYRING_MIN_CONTEXT_BYTES = 1;
13
+ export const SMARTSECRET_KEYRING_MAX_CONTEXT_BYTES = 65_536;
14
+ export const SMARTSECRET_KEYRING_MAX_IDENTIFIER_BYTES = 200;
15
+ export const SMARTSECRET_KEYRING_MAX_KEK_VERSION = 2_147_483_647;
16
+
17
+ const identifierPattern = /^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/;
18
+ const fingerprintPattern = /^sha256:[0-9a-f]{64}$/;
19
+ const base64UrlPattern = /^[A-Za-z0-9_-]*$/;
20
+ const wrapAadMagic = Buffer.from('smartsecret-envelope-wrap-aad-v1', 'ascii');
21
+ const payloadDigestMagic = Buffer.from('smartsecret-envelope-payload-digest-v1', 'ascii');
22
+
23
+ export interface ISmartSecretEnvelopeCiphertext {
24
+ nonce: string;
25
+ ciphertext: string;
26
+ tag: string;
27
+ }
28
+
29
+ export interface ISmartSecretEnvelopeV1 {
30
+ schemaVersion: 1;
31
+ profile: TSmartSecretKeyringProfile;
32
+ envelopeId: string;
33
+ createdAt: number;
34
+ contextDigest: string;
35
+ kek: {
36
+ keyringId: string;
37
+ version: number;
38
+ fingerprint: `sha256:${string}`;
39
+ };
40
+ payload: ISmartSecretEnvelopeCiphertext;
41
+ wrappedDek: ISmartSecretEnvelopeCiphertext;
42
+ }
43
+
44
+ export interface IParsedSmartSecretEnvelopeV1 {
45
+ envelope: ISmartSecretEnvelopeV1;
46
+ contextDigest: Uint8Array;
47
+ kekFingerprint: Uint8Array;
48
+ payload: plugins.smartcrypto.IAesGcmCiphertext;
49
+ wrappedDek: plugins.smartcrypto.IAesGcmCiphertext;
50
+ }
51
+
52
+ export interface ISmartSecretWrapAadFields {
53
+ envelopeId: string;
54
+ createdAt: number;
55
+ contextDigest: Uint8Array;
56
+ payloadDigest: Uint8Array;
57
+ keyringId: string;
58
+ kekVersion: number;
59
+ kekFingerprint: Uint8Array;
60
+ }
61
+
62
+ const requirePlainObject = (
63
+ valueArg: unknown,
64
+ expectedKeysArg: readonly string[],
65
+ ): Record<string, unknown> => {
66
+ try {
67
+ if (valueArg === null || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
68
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
69
+ }
70
+ const prototype = Object.getPrototypeOf(valueArg);
71
+ if (prototype !== Object.prototype && prototype !== null) {
72
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
73
+ }
74
+ const ownKeys = Reflect.ownKeys(valueArg);
75
+ if (
76
+ ownKeys.length !== expectedKeysArg.length
77
+ || ownKeys.some((keyArg) => typeof keyArg !== 'string')
78
+ ) {
79
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
80
+ }
81
+ const actualKeys = ownKeys as string[];
82
+ if (expectedKeysArg.some((keyArg) => !actualKeys.includes(keyArg))) {
83
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
84
+ }
85
+ const result: Record<string, unknown> = Object.create(null);
86
+ for (const key of actualKeys) {
87
+ const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
88
+ if (!descriptor?.enumerable || !('value' in descriptor)) {
89
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
90
+ }
91
+ result[key] = descriptor.value;
92
+ }
93
+ return result;
94
+ } catch (error) {
95
+ if (error instanceof SmartSecretKeyringError) {
96
+ throw error;
97
+ }
98
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
99
+ }
100
+ };
101
+
102
+ export const requireSmartSecretIdentifier = (valueArg: unknown): string => {
103
+ if (
104
+ typeof valueArg !== 'string'
105
+ || !identifierPattern.test(valueArg)
106
+ || Buffer.byteLength(valueArg, 'utf8') < 1
107
+ || Buffer.byteLength(valueArg, 'utf8') > SMARTSECRET_KEYRING_MAX_IDENTIFIER_BYTES
108
+ ) {
109
+ throw createSmartSecretKeyringError('CONFIG_INVALID');
110
+ }
111
+ return valueArg;
112
+ };
113
+
114
+ export const requireEnvelopeIdentifier = (valueArg: unknown): string => {
115
+ try {
116
+ return requireSmartSecretIdentifier(valueArg);
117
+ } catch {
118
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
119
+ }
120
+ };
121
+
122
+ export const requireKekVersion = (valueArg: unknown, envelopeArg = false): number => {
123
+ if (
124
+ !Number.isSafeInteger(valueArg)
125
+ || (valueArg as number) < 1
126
+ || (valueArg as number) > SMARTSECRET_KEYRING_MAX_KEK_VERSION
127
+ ) {
128
+ throw createSmartSecretKeyringError(envelopeArg ? 'ENVELOPE_INVALID' : 'CONFIG_INVALID');
129
+ }
130
+ return valueArg as number;
131
+ };
132
+
133
+ export const requireEpochMilliseconds = (valueArg: unknown, envelopeArg = false): number => {
134
+ if (!Number.isSafeInteger(valueArg) || (valueArg as number) < 0) {
135
+ throw createSmartSecretKeyringError(envelopeArg ? 'ENVELOPE_INVALID' : 'CONFIG_INVALID');
136
+ }
137
+ return valueArg as number;
138
+ };
139
+
140
+ export const requireFingerprint = (valueArg: unknown, envelopeArg = false): `sha256:${string}` => {
141
+ if (typeof valueArg !== 'string' || !fingerprintPattern.test(valueArg)) {
142
+ throw createSmartSecretKeyringError(envelopeArg ? 'ENVELOPE_INVALID' : 'CONFIG_INVALID');
143
+ }
144
+ return valueArg as `sha256:${string}`;
145
+ };
146
+
147
+ export const fingerprintToBytes = (valueArg: `sha256:${string}`): Uint8Array =>
148
+ new Uint8Array(Buffer.from(valueArg.slice('sha256:'.length), 'hex'));
149
+
150
+ export const encodeCanonicalBase64Url = (valueArg: Uint8Array): string =>
151
+ Buffer.from(valueArg).toString('base64url');
152
+
153
+ const decodeCanonicalBase64Url = (
154
+ valueArg: unknown,
155
+ maximumBytesArg: number,
156
+ exactBytesArg?: number,
157
+ ): Uint8Array => {
158
+ if (typeof valueArg !== 'string') {
159
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
160
+ }
161
+ const maximumEncodedLength = Math.ceil(maximumBytesArg * 4 / 3);
162
+ if (valueArg.length > maximumEncodedLength) {
163
+ throw createSmartSecretKeyringError('SIZE_LIMIT_EXCEEDED');
164
+ }
165
+ if (!base64UrlPattern.test(valueArg) || valueArg.length % 4 === 1) {
166
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
167
+ }
168
+ let result: Uint8Array | undefined;
169
+ let decodedBuffer: Buffer | undefined;
170
+ let didSucceed = false;
171
+ try {
172
+ decodedBuffer = Buffer.from(valueArg, 'base64url');
173
+ result = new Uint8Array(decodedBuffer);
174
+ if (result.byteLength > maximumBytesArg) {
175
+ throw createSmartSecretKeyringError('SIZE_LIMIT_EXCEEDED');
176
+ }
177
+ if (exactBytesArg !== undefined && result.byteLength !== exactBytesArg) {
178
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
179
+ }
180
+ if (encodeCanonicalBase64Url(result) !== valueArg) {
181
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
182
+ }
183
+ didSucceed = true;
184
+ return result;
185
+ } catch (error) {
186
+ if (error instanceof SmartSecretKeyringError) {
187
+ throw error;
188
+ }
189
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
190
+ } finally {
191
+ decodedBuffer?.fill(0);
192
+ if (!didSucceed) {
193
+ result?.fill(0);
194
+ }
195
+ }
196
+ };
197
+
198
+ const parseCiphertext = (
199
+ valueArg: unknown,
200
+ maximumCiphertextBytesArg: number,
201
+ exactCiphertextBytesArg?: number,
202
+ ): { encoded: ISmartSecretEnvelopeCiphertext; raw: plugins.smartcrypto.IAesGcmCiphertext } => {
203
+ const value = requirePlainObject(valueArg, ['nonce', 'ciphertext', 'tag']);
204
+ let nonce: Uint8Array | undefined;
205
+ let ciphertext: Uint8Array | undefined;
206
+ let tag: Uint8Array | undefined;
207
+ let didSucceed = false;
208
+ try {
209
+ nonce = decodeCanonicalBase64Url(value.nonce, 12, 12);
210
+ ciphertext = decodeCanonicalBase64Url(
211
+ value.ciphertext,
212
+ maximumCiphertextBytesArg,
213
+ exactCiphertextBytesArg,
214
+ );
215
+ tag = decodeCanonicalBase64Url(value.tag, 16, 16);
216
+ const result = {
217
+ encoded: {
218
+ nonce: value.nonce as string,
219
+ ciphertext: value.ciphertext as string,
220
+ tag: value.tag as string,
221
+ },
222
+ raw: { nonce, ciphertext, tag },
223
+ };
224
+ didSucceed = true;
225
+ return result;
226
+ } finally {
227
+ if (!didSucceed) {
228
+ nonce?.fill(0);
229
+ ciphertext?.fill(0);
230
+ tag?.fill(0);
231
+ }
232
+ }
233
+ };
234
+
235
+ export const parseSmartSecretEnvelope = (valueArg: unknown): IParsedSmartSecretEnvelopeV1 => {
236
+ const value = requirePlainObject(valueArg, [
237
+ 'schemaVersion',
238
+ 'profile',
239
+ 'envelopeId',
240
+ 'createdAt',
241
+ 'contextDigest',
242
+ 'kek',
243
+ 'payload',
244
+ 'wrappedDek',
245
+ ]);
246
+ if (value.schemaVersion !== SMARTSECRET_KEYRING_SCHEMA_VERSION) {
247
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
248
+ }
249
+ if (value.profile !== SMARTSECRET_KEYRING_PROFILE) {
250
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
251
+ }
252
+ let contextDigest: Uint8Array | undefined;
253
+ let kekFingerprint: Uint8Array | undefined;
254
+ let payload: ReturnType<typeof parseCiphertext> | undefined;
255
+ let wrappedDek: ReturnType<typeof parseCiphertext> | undefined;
256
+ let didSucceed = false;
257
+ try {
258
+ const envelopeId = requireEnvelopeIdentifier(value.envelopeId);
259
+ const createdAt = requireEpochMilliseconds(value.createdAt, true);
260
+ contextDigest = decodeCanonicalBase64Url(value.contextDigest, 32, 32);
261
+ const kekValue = requirePlainObject(value.kek, ['keyringId', 'version', 'fingerprint']);
262
+ const keyringId = requireEnvelopeIdentifier(kekValue.keyringId);
263
+ const version = requireKekVersion(kekValue.version, true);
264
+ const fingerprint = requireFingerprint(kekValue.fingerprint, true);
265
+ kekFingerprint = fingerprintToBytes(fingerprint);
266
+ payload = parseCiphertext(value.payload, SMARTSECRET_KEYRING_MAX_PAYLOAD_BYTES);
267
+ wrappedDek = parseCiphertext(value.wrappedDek, 32, 32);
268
+ const result = {
269
+ envelope: {
270
+ schemaVersion: SMARTSECRET_KEYRING_SCHEMA_VERSION,
271
+ profile: SMARTSECRET_KEYRING_PROFILE,
272
+ envelopeId,
273
+ createdAt,
274
+ contextDigest: value.contextDigest as string,
275
+ kek: { keyringId, version, fingerprint },
276
+ payload: payload.encoded,
277
+ wrappedDek: wrappedDek.encoded,
278
+ },
279
+ contextDigest,
280
+ kekFingerprint,
281
+ payload: payload.raw,
282
+ wrappedDek: wrappedDek.raw,
283
+ };
284
+ didSucceed = true;
285
+ return result;
286
+ } finally {
287
+ if (!didSucceed) {
288
+ contextDigest?.fill(0);
289
+ kekFingerprint?.fill(0);
290
+ wipeCiphertext(payload?.raw);
291
+ wipeCiphertext(wrappedDek?.raw);
292
+ }
293
+ }
294
+ };
295
+
296
+ export const sha256Bytes = (...valuesArg: Uint8Array[]): Uint8Array => {
297
+ const hash = plugins.crypto.createHash('sha256');
298
+ for (const value of valuesArg) {
299
+ hash.update(value);
300
+ }
301
+ return new Uint8Array(hash.digest());
302
+ };
303
+
304
+ const uint32Bytes = (valueArg: number): Uint8Array => {
305
+ const result = Buffer.allocUnsafe(4);
306
+ result.writeUInt32BE(valueArg, 0);
307
+ return result;
308
+ };
309
+
310
+ const uint64Bytes = (valueArg: number): Uint8Array => {
311
+ const result = Buffer.allocUnsafe(8);
312
+ result.writeBigUInt64BE(BigInt(valueArg), 0);
313
+ return result;
314
+ };
315
+
316
+ const lengthPrefixed = (valueArg: Uint8Array): Uint8Array => {
317
+ if (valueArg.byteLength > 0xffff_ffff) {
318
+ throw createSmartSecretKeyringError('SIZE_LIMIT_EXCEEDED');
319
+ }
320
+ return Buffer.concat([uint32Bytes(valueArg.byteLength), valueArg]);
321
+ };
322
+
323
+ export const createSmartSecretPayloadDigest = (
324
+ payloadArg: plugins.smartcrypto.IAesGcmCiphertext,
325
+ ): Uint8Array => {
326
+ const hash = plugins.crypto.createHash('sha256');
327
+ hash.update(payloadDigestMagic);
328
+ for (const value of [payloadArg.nonce, payloadArg.ciphertext, payloadArg.tag]) {
329
+ const length = uint32Bytes(value.byteLength);
330
+ try {
331
+ hash.update(length);
332
+ hash.update(value);
333
+ } finally {
334
+ length.fill(0);
335
+ }
336
+ }
337
+ return new Uint8Array(hash.digest());
338
+ };
339
+
340
+ export const createSmartSecretWrapAad = (
341
+ fieldsArg: ISmartSecretWrapAadFields,
342
+ ): Uint8Array => {
343
+ if (
344
+ fieldsArg.contextDigest.byteLength !== 32
345
+ || fieldsArg.payloadDigest.byteLength !== 32
346
+ || fieldsArg.kekFingerprint.byteLength !== 32
347
+ ) {
348
+ throw createSmartSecretKeyringError('ENVELOPE_INVALID');
349
+ }
350
+ const profile = Buffer.from(SMARTSECRET_KEYRING_PROFILE, 'utf8');
351
+ const envelopeId = Buffer.from(requireEnvelopeIdentifier(fieldsArg.envelopeId), 'utf8');
352
+ const keyringId = Buffer.from(requireEnvelopeIdentifier(fieldsArg.keyringId), 'utf8');
353
+ const createdAt = requireEpochMilliseconds(fieldsArg.createdAt, true);
354
+ const kekVersion = requireKekVersion(fieldsArg.kekVersion, true);
355
+ return Buffer.concat([
356
+ wrapAadMagic,
357
+ uint64Bytes(SMARTSECRET_KEYRING_SCHEMA_VERSION),
358
+ lengthPrefixed(profile),
359
+ lengthPrefixed(envelopeId),
360
+ uint64Bytes(createdAt),
361
+ fieldsArg.contextDigest,
362
+ fieldsArg.payloadDigest,
363
+ lengthPrefixed(keyringId),
364
+ uint64Bytes(kekVersion),
365
+ fieldsArg.kekFingerprint,
366
+ ]);
367
+ };
368
+
369
+ export const wipeCiphertext = (valueArg: plugins.smartcrypto.IAesGcmCiphertext | undefined): void => {
370
+ valueArg?.nonce.fill(0);
371
+ valueArg?.ciphertext.fill(0);
372
+ valueArg?.tag.fill(0);
373
+ };
374
+
375
+ export const timingSafeEqual = (leftArg: Uint8Array, rightArg: Uint8Array): boolean =>
376
+ leftArg.byteLength === rightArg.byteLength
377
+ && plugins.crypto.timingSafeEqual(leftArg, rightArg);