@push.rocks/smartsecret 1.4.0 → 1.6.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/.smartconfig.json +1 -2
- package/dist_rust/smartsecret-kernel_linux_amd64 +0 -0
- package/dist_ts/00_commitinfo_data.js +3 -3
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/smartsecret.kernel.error.d.ts +1 -1
- package/dist_ts/smartsecret.kernel.error.js +2 -1
- package/dist_ts/smartsecret.kernel.protocol.d.ts +21 -1
- package/dist_ts/smartsecret.kernel.protocol.js +16 -1
- package/dist_ts/smartsecret.kernelstore.d.ts +3 -2
- package/dist_ts/smartsecret.kernelstore.js +23 -2
- package/dist_ts/smartsecret.sealedfile.d.ts +51 -0
- package/dist_ts/smartsecret.sealedfile.error.d.ts +17 -0
- package/dist_ts/smartsecret.sealedfile.error.js +44 -0
- package/dist_ts/smartsecret.sealedfile.js +1458 -0
- package/package.json +6 -6
- package/readme.hints.md +17 -0
- package/readme.md +113 -4
- package/ts/00_commitinfo_data.ts +2 -2
- package/ts/index.ts +6 -0
- package/ts/smartsecret.kernel.error.ts +2 -0
- package/ts/smartsecret.kernel.protocol.ts +40 -1
- package/ts/smartsecret.kernelstore.ts +38 -0
- package/ts/smartsecret.sealedfile.error.ts +72 -0
- package/ts/smartsecret.sealedfile.ts +1834 -0
|
@@ -0,0 +1,1834 @@
|
|
|
1
|
+
import * as plugins from './smartsecret.plugins.js';
|
|
2
|
+
import {
|
|
3
|
+
SmartSecretKernelStoreError,
|
|
4
|
+
} from './smartsecret.kernel.error.js';
|
|
5
|
+
import {
|
|
6
|
+
normalizeSmartSecretKernelAccount,
|
|
7
|
+
normalizeSmartSecretKernelService,
|
|
8
|
+
type ISmartSecretKernelOperationOptions,
|
|
9
|
+
type TSmartSecretKernelMoveStatus,
|
|
10
|
+
} from './smartsecret.kernel.protocol.js';
|
|
11
|
+
import {
|
|
12
|
+
SmartSecretSealedFileStoreError,
|
|
13
|
+
createSmartSecretSealedFileStoreError,
|
|
14
|
+
} from './smartsecret.sealedfile.error.js';
|
|
15
|
+
|
|
16
|
+
export const smartSecretSealedFileMaximumEntryBytes = 512 * 1_024;
|
|
17
|
+
|
|
18
|
+
const schemaVersion = 1 as const;
|
|
19
|
+
const profile = 'smartsecret-kernel-aes-256-gcm-file-v1' as const;
|
|
20
|
+
const manifestName = 'manifest.json';
|
|
21
|
+
const relocationReceiptName = '.smartsecret-relocation.json';
|
|
22
|
+
const masterKeyBytes = 32;
|
|
23
|
+
const maximumEnvelopeBytes = 768 * 1_024;
|
|
24
|
+
const operationTimeoutMs = 60_000;
|
|
25
|
+
const canonicalDigestPattern = /^[A-Za-z0-9_-]{43}$/;
|
|
26
|
+
const entryFilePattern = /^[A-Za-z0-9_-]{43}\.sealed\.json$/;
|
|
27
|
+
const temporaryFilePattern = /^\.smartsecret-[A-Za-z0-9_-]{22}\.tmp$/;
|
|
28
|
+
const maximumPendingOperations = 64;
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
|
|
31
|
+
export interface ISmartSecretSealedFileKernelStore {
|
|
32
|
+
readonly service: string;
|
|
33
|
+
getEntry(
|
|
34
|
+
accountArg: string,
|
|
35
|
+
optionsArg?: ISmartSecretKernelOperationOptions,
|
|
36
|
+
): Promise<Uint8Array | null>;
|
|
37
|
+
setEntry(
|
|
38
|
+
accountArg: string,
|
|
39
|
+
valueArg: Uint8Array,
|
|
40
|
+
optionsArg?: ISmartSecretKernelOperationOptions,
|
|
41
|
+
): Promise<void>;
|
|
42
|
+
deleteEntry(
|
|
43
|
+
accountArg: string,
|
|
44
|
+
optionsArg?: ISmartSecretKernelOperationOptions,
|
|
45
|
+
): Promise<boolean>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ISmartSecretSealedFileRelocationKernelStore
|
|
49
|
+
extends ISmartSecretSealedFileKernelStore {
|
|
50
|
+
moveEntry(
|
|
51
|
+
sourceAccountArg: string,
|
|
52
|
+
destinationAccountArg: string,
|
|
53
|
+
optionsArg?: ISmartSecretKernelOperationOptions,
|
|
54
|
+
): Promise<TSmartSecretKernelMoveStatus>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ISmartSecretSealedFileStoreOptions {
|
|
58
|
+
kernelStore: ISmartSecretSealedFileKernelStore;
|
|
59
|
+
storeId: string;
|
|
60
|
+
directoryPath: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ISmartSecretSealedFileStoreRelocationOptions {
|
|
64
|
+
kernelStore: ISmartSecretSealedFileRelocationKernelStore;
|
|
65
|
+
storeId: string;
|
|
66
|
+
sourceDirectoryPath: string;
|
|
67
|
+
destinationDirectoryPath: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface INormalizedRelocationOptions {
|
|
71
|
+
kernelStore: ISmartSecretSealedFileRelocationKernelStore;
|
|
72
|
+
source: INormalizedOptions;
|
|
73
|
+
destination: INormalizedOptions;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface INormalizedOptions {
|
|
77
|
+
kernelStore: ISmartSecretSealedFileKernelStore;
|
|
78
|
+
service: string;
|
|
79
|
+
storeId: string;
|
|
80
|
+
directoryPath: string;
|
|
81
|
+
masterKeyAccount: string;
|
|
82
|
+
mutexNamespace: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface IManifestV1 {
|
|
86
|
+
schemaVersion: 1;
|
|
87
|
+
profile: typeof profile;
|
|
88
|
+
serviceDigest: string;
|
|
89
|
+
storeId: string;
|
|
90
|
+
masterKeyFingerprint: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface IEnvelopeV1 {
|
|
94
|
+
schemaVersion: 1;
|
|
95
|
+
profile: typeof profile;
|
|
96
|
+
entryId: string;
|
|
97
|
+
masterKeyFingerprint: string;
|
|
98
|
+
nonce: string;
|
|
99
|
+
ciphertext: string;
|
|
100
|
+
tag: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface IDirectoryIdentity {
|
|
104
|
+
device: string;
|
|
105
|
+
inode: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
interface IRelocationReceiptV1 extends IDirectoryIdentity {
|
|
109
|
+
schemaVersion: 1;
|
|
110
|
+
kind: 'relocation';
|
|
111
|
+
profile: typeof profile;
|
|
112
|
+
serviceDigest: string;
|
|
113
|
+
storeId: string;
|
|
114
|
+
sourcePathDigest: string;
|
|
115
|
+
destinationPathDigest: string;
|
|
116
|
+
masterKeyFingerprint: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface IRelocationDirectoryInspection {
|
|
120
|
+
manifest: IManifestV1;
|
|
121
|
+
receiptValue?: unknown;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface IRelocationMasterKeyState {
|
|
125
|
+
key: Uint8Array;
|
|
126
|
+
sourcePresent: boolean;
|
|
127
|
+
destinationPresent: boolean;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type TLifecycleState = 'ready' | 'closing' | 'closed';
|
|
131
|
+
type TBigIntStats = plugins.fs.BigIntStats;
|
|
132
|
+
|
|
133
|
+
const sha256 = (...valuesArg: Uint8Array[]): Uint8Array => {
|
|
134
|
+
const hash = plugins.crypto.createHash('sha256');
|
|
135
|
+
for (const value of valuesArg) hash.update(value);
|
|
136
|
+
return new Uint8Array(hash.digest());
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const digest = (...valuesArg: Uint8Array[]): string =>
|
|
140
|
+
Buffer.from(sha256(...valuesArg)).toString('base64url');
|
|
141
|
+
|
|
142
|
+
const fingerprint = (keyArg: Uint8Array): string => digest(
|
|
143
|
+
encoder.encode('@push.rocks/smartsecret/sealed-file/master-key/v1\0'),
|
|
144
|
+
keyArg,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const normalizeStoreId = (valueArg: unknown): string => {
|
|
148
|
+
if (
|
|
149
|
+
typeof valueArg !== 'string'
|
|
150
|
+
|| !/^[A-Za-z0-9][A-Za-z0-9:._-]{0,199}$/.test(valueArg)
|
|
151
|
+
) throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
152
|
+
return valueArg;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const normalizeDirectoryPath = (valueArg: unknown): string => {
|
|
156
|
+
if (
|
|
157
|
+
typeof valueArg !== 'string'
|
|
158
|
+
|| !plugins.path.isAbsolute(valueArg)
|
|
159
|
+
|| plugins.path.resolve(valueArg) !== valueArg
|
|
160
|
+
|| valueArg === plugins.path.parse(valueArg).root
|
|
161
|
+
) throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
162
|
+
return valueArg;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const dataPropertyValue = (objectArg: object, keyArg: string): unknown => {
|
|
166
|
+
let current: object | null = objectArg;
|
|
167
|
+
while (current) {
|
|
168
|
+
const descriptor = Object.getOwnPropertyDescriptor(current, keyArg);
|
|
169
|
+
if (descriptor) {
|
|
170
|
+
if (!('value' in descriptor)) {
|
|
171
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
172
|
+
}
|
|
173
|
+
return descriptor.value;
|
|
174
|
+
}
|
|
175
|
+
current = Object.getPrototypeOf(current) as object | null;
|
|
176
|
+
}
|
|
177
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const normalizeOptions = (optionsArg: ISmartSecretSealedFileStoreOptions): INormalizedOptions => {
|
|
181
|
+
try {
|
|
182
|
+
if (!optionsArg || typeof optionsArg !== 'object' || Array.isArray(optionsArg)) {
|
|
183
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
184
|
+
}
|
|
185
|
+
const keys = Reflect.ownKeys(optionsArg);
|
|
186
|
+
if (
|
|
187
|
+
keys.length !== 3
|
|
188
|
+
|| keys.some((keyArg) => typeof keyArg !== 'string')
|
|
189
|
+
|| !['kernelStore', 'storeId', 'directoryPath'].every((keyArg) => keys.includes(keyArg))
|
|
190
|
+
) throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
191
|
+
const values: Record<string, unknown> = Object.create(null);
|
|
192
|
+
for (const key of keys as string[]) {
|
|
193
|
+
const descriptor = Object.getOwnPropertyDescriptor(optionsArg, key);
|
|
194
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
195
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
196
|
+
}
|
|
197
|
+
values[key] = descriptor.value;
|
|
198
|
+
}
|
|
199
|
+
const kernelStoreValue = values.kernelStore;
|
|
200
|
+
if (
|
|
201
|
+
!kernelStoreValue
|
|
202
|
+
|| typeof kernelStoreValue !== 'object'
|
|
203
|
+
) throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
204
|
+
const service = normalizeSmartSecretKernelService(
|
|
205
|
+
dataPropertyValue(kernelStoreValue, 'service'),
|
|
206
|
+
);
|
|
207
|
+
const getEntry = dataPropertyValue(kernelStoreValue, 'getEntry');
|
|
208
|
+
const setEntry = dataPropertyValue(kernelStoreValue, 'setEntry');
|
|
209
|
+
const deleteEntry = dataPropertyValue(kernelStoreValue, 'deleteEntry');
|
|
210
|
+
if (
|
|
211
|
+
typeof getEntry !== 'function'
|
|
212
|
+
|| typeof setEntry !== 'function'
|
|
213
|
+
|| typeof deleteEntry !== 'function'
|
|
214
|
+
) throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
215
|
+
const kernelStore: ISmartSecretSealedFileKernelStore = {
|
|
216
|
+
service,
|
|
217
|
+
getEntry: (accountArg, operationOptionsArg) => Reflect.apply(
|
|
218
|
+
getEntry,
|
|
219
|
+
kernelStoreValue,
|
|
220
|
+
[accountArg, operationOptionsArg],
|
|
221
|
+
) as Promise<Uint8Array | null>,
|
|
222
|
+
setEntry: (accountArg, valueArg, operationOptionsArg) => Reflect.apply(
|
|
223
|
+
setEntry,
|
|
224
|
+
kernelStoreValue,
|
|
225
|
+
[accountArg, valueArg, operationOptionsArg],
|
|
226
|
+
) as Promise<void>,
|
|
227
|
+
deleteEntry: (accountArg, operationOptionsArg) => Reflect.apply(
|
|
228
|
+
deleteEntry,
|
|
229
|
+
kernelStoreValue,
|
|
230
|
+
[accountArg, operationOptionsArg],
|
|
231
|
+
) as Promise<boolean>,
|
|
232
|
+
};
|
|
233
|
+
const storeId = normalizeStoreId(values.storeId);
|
|
234
|
+
const directoryPath = normalizeDirectoryPath(values.directoryPath);
|
|
235
|
+
const identity = encoder.encode(`${service}\0${storeId}\0${directoryPath}`);
|
|
236
|
+
return {
|
|
237
|
+
kernelStore,
|
|
238
|
+
service,
|
|
239
|
+
storeId,
|
|
240
|
+
directoryPath,
|
|
241
|
+
masterKeyAccount: `sealed-file-master:v1:${digest(identity)}`,
|
|
242
|
+
mutexNamespace: `smartsecret-sealed-file:v1:${digest(encoder.encode(directoryPath))}`,
|
|
243
|
+
};
|
|
244
|
+
} catch {
|
|
245
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const normalizeRelocationOptions = (
|
|
250
|
+
optionsArg: ISmartSecretSealedFileStoreRelocationOptions,
|
|
251
|
+
): INormalizedRelocationOptions => {
|
|
252
|
+
try {
|
|
253
|
+
if (!optionsArg || typeof optionsArg !== 'object' || Array.isArray(optionsArg)) {
|
|
254
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
255
|
+
}
|
|
256
|
+
const expectedKeys = [
|
|
257
|
+
'kernelStore',
|
|
258
|
+
'storeId',
|
|
259
|
+
'sourceDirectoryPath',
|
|
260
|
+
'destinationDirectoryPath',
|
|
261
|
+
];
|
|
262
|
+
const keys = Reflect.ownKeys(optionsArg);
|
|
263
|
+
if (
|
|
264
|
+
keys.length !== expectedKeys.length
|
|
265
|
+
|| keys.some((keyArg) => typeof keyArg !== 'string')
|
|
266
|
+
|| expectedKeys.some((keyArg) => !keys.includes(keyArg))
|
|
267
|
+
) throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
268
|
+
const values: Record<string, unknown> = Object.create(null);
|
|
269
|
+
for (const key of keys as string[]) {
|
|
270
|
+
const descriptor = Object.getOwnPropertyDescriptor(optionsArg, key);
|
|
271
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
272
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
273
|
+
}
|
|
274
|
+
values[key] = descriptor.value;
|
|
275
|
+
}
|
|
276
|
+
const kernelStoreValue = values.kernelStore;
|
|
277
|
+
if (!kernelStoreValue || typeof kernelStoreValue !== 'object') {
|
|
278
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
279
|
+
}
|
|
280
|
+
const moveEntry = dataPropertyValue(kernelStoreValue, 'moveEntry');
|
|
281
|
+
if (typeof moveEntry !== 'function') {
|
|
282
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
283
|
+
}
|
|
284
|
+
const source = normalizeOptions({
|
|
285
|
+
kernelStore: kernelStoreValue as ISmartSecretSealedFileKernelStore,
|
|
286
|
+
storeId: values.storeId as string,
|
|
287
|
+
directoryPath: values.sourceDirectoryPath as string,
|
|
288
|
+
});
|
|
289
|
+
const destination = normalizeOptions({
|
|
290
|
+
kernelStore: kernelStoreValue as ISmartSecretSealedFileKernelStore,
|
|
291
|
+
storeId: values.storeId as string,
|
|
292
|
+
directoryPath: values.destinationDirectoryPath as string,
|
|
293
|
+
});
|
|
294
|
+
if (source.directoryPath === destination.directoryPath) {
|
|
295
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
296
|
+
}
|
|
297
|
+
const kernelStore: ISmartSecretSealedFileRelocationKernelStore = {
|
|
298
|
+
...source.kernelStore,
|
|
299
|
+
moveEntry: (sourceAccountArg, destinationAccountArg, operationOptionsArg) => Reflect.apply(
|
|
300
|
+
moveEntry,
|
|
301
|
+
kernelStoreValue,
|
|
302
|
+
[sourceAccountArg, destinationAccountArg, operationOptionsArg],
|
|
303
|
+
) as Promise<TSmartSecretKernelMoveStatus>,
|
|
304
|
+
};
|
|
305
|
+
source.kernelStore = kernelStore;
|
|
306
|
+
destination.kernelStore = kernelStore;
|
|
307
|
+
return { kernelStore, source, destination };
|
|
308
|
+
} catch (errorArg) {
|
|
309
|
+
if (errorArg instanceof SmartSecretSealedFileStoreError) throw errorArg;
|
|
310
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const exactObject = (
|
|
315
|
+
valueArg: unknown,
|
|
316
|
+
keysArg: readonly string[],
|
|
317
|
+
errorCodeArg: 'ENVELOPE_INVALID' | 'FILESYSTEM_FAILED',
|
|
318
|
+
): Record<string, unknown> => {
|
|
319
|
+
try {
|
|
320
|
+
if (!valueArg || typeof valueArg !== 'object' || Array.isArray(valueArg)) {
|
|
321
|
+
throw createSmartSecretSealedFileStoreError(errorCodeArg);
|
|
322
|
+
}
|
|
323
|
+
const prototype = Object.getPrototypeOf(valueArg);
|
|
324
|
+
const keys = Reflect.ownKeys(valueArg);
|
|
325
|
+
if (
|
|
326
|
+
(prototype !== Object.prototype && prototype !== null)
|
|
327
|
+
|| keys.length !== keysArg.length
|
|
328
|
+
|| keys.some((keyArg) => typeof keyArg !== 'string')
|
|
329
|
+
|| keysArg.some((keyArg) => !keys.includes(keyArg))
|
|
330
|
+
) throw createSmartSecretSealedFileStoreError(errorCodeArg);
|
|
331
|
+
const result: Record<string, unknown> = Object.create(null);
|
|
332
|
+
for (const key of keys as string[]) {
|
|
333
|
+
const descriptor = Object.getOwnPropertyDescriptor(valueArg, key);
|
|
334
|
+
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
|
335
|
+
throw createSmartSecretSealedFileStoreError(errorCodeArg);
|
|
336
|
+
}
|
|
337
|
+
result[key] = descriptor.value;
|
|
338
|
+
}
|
|
339
|
+
return result;
|
|
340
|
+
} catch (errorArg) {
|
|
341
|
+
if (errorArg instanceof SmartSecretSealedFileStoreError) throw errorArg;
|
|
342
|
+
throw createSmartSecretSealedFileStoreError(errorCodeArg);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const decodeBase64Url = (
|
|
347
|
+
valueArg: unknown,
|
|
348
|
+
maximumBytesArg: number,
|
|
349
|
+
exactBytesArg?: number,
|
|
350
|
+
): Uint8Array => {
|
|
351
|
+
if (
|
|
352
|
+
typeof valueArg !== 'string'
|
|
353
|
+
|| !/^[A-Za-z0-9_-]*$/.test(valueArg)
|
|
354
|
+
|| valueArg.length % 4 === 1
|
|
355
|
+
|| valueArg.length > Math.ceil(maximumBytesArg * 4 / 3)
|
|
356
|
+
) throw createSmartSecretSealedFileStoreError('ENVELOPE_INVALID');
|
|
357
|
+
const decoded = Buffer.from(valueArg, 'base64url');
|
|
358
|
+
if (
|
|
359
|
+
decoded.byteLength > maximumBytesArg
|
|
360
|
+
|| (exactBytesArg !== undefined && decoded.byteLength !== exactBytesArg)
|
|
361
|
+
|| decoded.toString('base64url') !== valueArg
|
|
362
|
+
) {
|
|
363
|
+
decoded.fill(0);
|
|
364
|
+
throw createSmartSecretSealedFileStoreError('ENVELOPE_INVALID');
|
|
365
|
+
}
|
|
366
|
+
const result = new Uint8Array(decoded);
|
|
367
|
+
decoded.fill(0);
|
|
368
|
+
return result;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const parseEnvelope = (valueArg: unknown): {
|
|
372
|
+
envelope: IEnvelopeV1;
|
|
373
|
+
encryptedData: plugins.smartcrypto.IAesGcmCiphertext;
|
|
374
|
+
} => {
|
|
375
|
+
const value = exactObject(valueArg, [
|
|
376
|
+
'schemaVersion',
|
|
377
|
+
'profile',
|
|
378
|
+
'entryId',
|
|
379
|
+
'masterKeyFingerprint',
|
|
380
|
+
'nonce',
|
|
381
|
+
'ciphertext',
|
|
382
|
+
'tag',
|
|
383
|
+
], 'ENVELOPE_INVALID');
|
|
384
|
+
if (
|
|
385
|
+
value.schemaVersion !== schemaVersion
|
|
386
|
+
|| value.profile !== profile
|
|
387
|
+
|| typeof value.entryId !== 'string'
|
|
388
|
+
|| !canonicalDigestPattern.test(value.entryId)
|
|
389
|
+
|| typeof value.masterKeyFingerprint !== 'string'
|
|
390
|
+
|| !canonicalDigestPattern.test(value.masterKeyFingerprint)
|
|
391
|
+
) throw createSmartSecretSealedFileStoreError('ENVELOPE_INVALID');
|
|
392
|
+
let nonce: Uint8Array | undefined;
|
|
393
|
+
let ciphertext: Uint8Array | undefined;
|
|
394
|
+
let tag: Uint8Array | undefined;
|
|
395
|
+
try {
|
|
396
|
+
nonce = decodeBase64Url(value.nonce, 12, 12);
|
|
397
|
+
ciphertext = decodeBase64Url(value.ciphertext, smartSecretSealedFileMaximumEntryBytes);
|
|
398
|
+
tag = decodeBase64Url(value.tag, 16, 16);
|
|
399
|
+
return {
|
|
400
|
+
envelope: {
|
|
401
|
+
schemaVersion,
|
|
402
|
+
profile,
|
|
403
|
+
entryId: value.entryId,
|
|
404
|
+
masterKeyFingerprint: value.masterKeyFingerprint,
|
|
405
|
+
nonce: value.nonce as string,
|
|
406
|
+
ciphertext: value.ciphertext as string,
|
|
407
|
+
tag: value.tag as string,
|
|
408
|
+
},
|
|
409
|
+
encryptedData: { nonce, ciphertext, tag },
|
|
410
|
+
};
|
|
411
|
+
} catch (errorArg) {
|
|
412
|
+
nonce?.fill(0);
|
|
413
|
+
ciphertext?.fill(0);
|
|
414
|
+
tag?.fill(0);
|
|
415
|
+
throw errorArg;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const encodeBase64Url = (valueArg: Uint8Array): string => {
|
|
420
|
+
const copy = Buffer.from(valueArg);
|
|
421
|
+
try {
|
|
422
|
+
return copy.toString('base64url');
|
|
423
|
+
} finally {
|
|
424
|
+
copy.fill(0);
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const encodeEncryptedData = (
|
|
429
|
+
valueArg: plugins.smartcrypto.IAesGcmCiphertext,
|
|
430
|
+
): Pick<IEnvelopeV1, 'nonce' | 'ciphertext' | 'tag'> => ({
|
|
431
|
+
nonce: encodeBase64Url(valueArg.nonce),
|
|
432
|
+
ciphertext: encodeBase64Url(valueArg.ciphertext),
|
|
433
|
+
tag: encodeBase64Url(valueArg.tag),
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
const entryId = (optionsArg: INormalizedOptions, accountArg: string): string => digest(
|
|
437
|
+
encoder.encode('@push.rocks/smartsecret/sealed-file/entry/v1\0'),
|
|
438
|
+
encoder.encode(optionsArg.service),
|
|
439
|
+
new Uint8Array([0]),
|
|
440
|
+
encoder.encode(optionsArg.storeId),
|
|
441
|
+
new Uint8Array([0]),
|
|
442
|
+
encoder.encode(accountArg),
|
|
443
|
+
);
|
|
444
|
+
|
|
445
|
+
const entryAad = (
|
|
446
|
+
optionsArg: INormalizedOptions,
|
|
447
|
+
accountArg: string,
|
|
448
|
+
entryIdArg: string,
|
|
449
|
+
fingerprintArg: string,
|
|
450
|
+
): Uint8Array => encoder.encode(JSON.stringify({
|
|
451
|
+
schemaVersion,
|
|
452
|
+
profile,
|
|
453
|
+
service: optionsArg.service,
|
|
454
|
+
storeId: optionsArg.storeId,
|
|
455
|
+
account: accountArg,
|
|
456
|
+
entryId: entryIdArg,
|
|
457
|
+
masterKeyFingerprint: fingerprintArg,
|
|
458
|
+
}));
|
|
459
|
+
|
|
460
|
+
const manifestFor = (optionsArg: INormalizedOptions, fingerprintArg: string): IManifestV1 => ({
|
|
461
|
+
schemaVersion,
|
|
462
|
+
profile,
|
|
463
|
+
serviceDigest: digest(encoder.encode(optionsArg.service)),
|
|
464
|
+
storeId: optionsArg.storeId,
|
|
465
|
+
masterKeyFingerprint: fingerprintArg,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
const directoryIdentity = (statsArg: TBigIntStats): IDirectoryIdentity => ({
|
|
469
|
+
device: statsArg.dev.toString(10),
|
|
470
|
+
inode: statsArg.ino.toString(10),
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const directoryIdentitiesEqual = (
|
|
474
|
+
leftArg: IDirectoryIdentity,
|
|
475
|
+
rightArg: IDirectoryIdentity,
|
|
476
|
+
): boolean => leftArg.device === rightArg.device && leftArg.inode === rightArg.inode;
|
|
477
|
+
|
|
478
|
+
const relocationReceiptFor = (
|
|
479
|
+
optionsArg: INormalizedRelocationOptions,
|
|
480
|
+
fingerprintArg: string,
|
|
481
|
+
identityArg: IDirectoryIdentity,
|
|
482
|
+
): IRelocationReceiptV1 => ({
|
|
483
|
+
schemaVersion,
|
|
484
|
+
kind: 'relocation',
|
|
485
|
+
profile,
|
|
486
|
+
serviceDigest: digest(encoder.encode(optionsArg.source.service)),
|
|
487
|
+
storeId: optionsArg.source.storeId,
|
|
488
|
+
sourcePathDigest: digest(encoder.encode(optionsArg.source.directoryPath)),
|
|
489
|
+
destinationPathDigest: digest(encoder.encode(optionsArg.destination.directoryPath)),
|
|
490
|
+
masterKeyFingerprint: fingerprintArg,
|
|
491
|
+
...identityArg,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const parseRelocationReceipt = (
|
|
495
|
+
valueArg: unknown,
|
|
496
|
+
optionsArg: INormalizedRelocationOptions,
|
|
497
|
+
fingerprintArg: string,
|
|
498
|
+
identityArg: IDirectoryIdentity,
|
|
499
|
+
): IRelocationReceiptV1 => {
|
|
500
|
+
const value = exactObject(valueArg, [
|
|
501
|
+
'schemaVersion',
|
|
502
|
+
'kind',
|
|
503
|
+
'profile',
|
|
504
|
+
'serviceDigest',
|
|
505
|
+
'storeId',
|
|
506
|
+
'sourcePathDigest',
|
|
507
|
+
'destinationPathDigest',
|
|
508
|
+
'masterKeyFingerprint',
|
|
509
|
+
'device',
|
|
510
|
+
'inode',
|
|
511
|
+
], 'FILESYSTEM_FAILED');
|
|
512
|
+
const expected = relocationReceiptFor(optionsArg, fingerprintArg, identityArg);
|
|
513
|
+
if (
|
|
514
|
+
value.schemaVersion !== expected.schemaVersion
|
|
515
|
+
|| value.kind !== expected.kind
|
|
516
|
+
|| value.profile !== expected.profile
|
|
517
|
+
|| value.serviceDigest !== expected.serviceDigest
|
|
518
|
+
|| value.storeId !== expected.storeId
|
|
519
|
+
|| value.sourcePathDigest !== expected.sourcePathDigest
|
|
520
|
+
|| value.destinationPathDigest !== expected.destinationPathDigest
|
|
521
|
+
|| value.masterKeyFingerprint !== expected.masterKeyFingerprint
|
|
522
|
+
|| value.device !== expected.device
|
|
523
|
+
|| value.inode !== expected.inode
|
|
524
|
+
) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
525
|
+
return value as unknown as IRelocationReceiptV1;
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
const parseManifest = (valueArg: unknown, optionsArg: INormalizedOptions): IManifestV1 => {
|
|
529
|
+
const value = exactObject(valueArg, [
|
|
530
|
+
'schemaVersion',
|
|
531
|
+
'profile',
|
|
532
|
+
'serviceDigest',
|
|
533
|
+
'storeId',
|
|
534
|
+
'masterKeyFingerprint',
|
|
535
|
+
], 'FILESYSTEM_FAILED');
|
|
536
|
+
if (
|
|
537
|
+
value.schemaVersion !== schemaVersion
|
|
538
|
+
|| value.profile !== profile
|
|
539
|
+
|| value.serviceDigest !== digest(encoder.encode(optionsArg.service))
|
|
540
|
+
|| value.storeId !== optionsArg.storeId
|
|
541
|
+
|| typeof value.masterKeyFingerprint !== 'string'
|
|
542
|
+
|| !canonicalDigestPattern.test(value.masterKeyFingerprint)
|
|
543
|
+
) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
544
|
+
return value as unknown as IManifestV1;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
const mapKernelError = (errorArg: unknown): SmartSecretSealedFileStoreError => {
|
|
548
|
+
if (!(errorArg instanceof SmartSecretKernelStoreError)) {
|
|
549
|
+
return createSmartSecretSealedFileStoreError('KERNEL_UNAVAILABLE');
|
|
550
|
+
}
|
|
551
|
+
if (errorArg.code === 'MUTATION_OUTCOME_UNKNOWN') {
|
|
552
|
+
return createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
553
|
+
}
|
|
554
|
+
if (errorArg.code === 'INVALID_ARGUMENT') {
|
|
555
|
+
return createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
556
|
+
}
|
|
557
|
+
return createSmartSecretSealedFileStoreError('KERNEL_UNAVAILABLE');
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const mapMutexError = (errorArg: unknown): SmartSecretSealedFileStoreError => {
|
|
561
|
+
if (errorArg instanceof plugins.smartipc.NamedMutexError) {
|
|
562
|
+
if (errorArg.code === 'ABORTED' || errorArg.code === 'TIMEOUT') {
|
|
563
|
+
return createSmartSecretSealedFileStoreError('MUTEX_FAILED');
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return createSmartSecretSealedFileStoreError('MUTEX_FAILED');
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const directoryHandlePath = (
|
|
570
|
+
directoryHandleArg: plugins.fs.promises.FileHandle,
|
|
571
|
+
nameArg?: string,
|
|
572
|
+
): string => nameArg
|
|
573
|
+
? plugins.path.join(`/proc/self/fd/${directoryHandleArg.fd}`, nameArg)
|
|
574
|
+
: `/proc/self/fd/${directoryHandleArg.fd}`;
|
|
575
|
+
|
|
576
|
+
const mutationOutcomeUnknown = (errorArg: unknown): boolean =>
|
|
577
|
+
errorArg instanceof SmartSecretSealedFileStoreError
|
|
578
|
+
&& errorArg.code === 'MUTATION_OUTCOME_UNKNOWN';
|
|
579
|
+
|
|
580
|
+
const preferCleanupError = (operationErrorArg: unknown, cleanupErrorArg: unknown): unknown =>
|
|
581
|
+
mutationOutcomeUnknown(operationErrorArg) ? operationErrorArg : cleanupErrorArg;
|
|
582
|
+
|
|
583
|
+
const closeFileHandle = async (handleArg: plugins.fs.promises.FileHandle): Promise<void> => {
|
|
584
|
+
try {
|
|
585
|
+
await handleArg.close();
|
|
586
|
+
} catch {
|
|
587
|
+
try {
|
|
588
|
+
await handleArg.close();
|
|
589
|
+
} catch {}
|
|
590
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const closeFileHandleWithError = async (
|
|
595
|
+
handleArg: plugins.fs.promises.FileHandle,
|
|
596
|
+
operationErrorArg: unknown,
|
|
597
|
+
): Promise<unknown> => {
|
|
598
|
+
try {
|
|
599
|
+
await closeFileHandle(handleArg);
|
|
600
|
+
return operationErrorArg;
|
|
601
|
+
} catch (errorArg) {
|
|
602
|
+
return preferCleanupError(operationErrorArg, errorArg);
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const wipeDiscardedResult = (valueArg: unknown): void => {
|
|
607
|
+
if (valueArg instanceof Uint8Array) valueArg.fill(0);
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
const validateDirectory = (
|
|
611
|
+
statsArg: TBigIntStats,
|
|
612
|
+
effectiveUidArg: bigint,
|
|
613
|
+
finalArg: boolean,
|
|
614
|
+
): void => {
|
|
615
|
+
if (!statsArg.isDirectory()) {
|
|
616
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
617
|
+
}
|
|
618
|
+
const mode = statsArg.mode & 0o7777n;
|
|
619
|
+
if (finalArg) {
|
|
620
|
+
if (statsArg.uid !== effectiveUidArg || mode !== 0o700n) {
|
|
621
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
622
|
+
}
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (statsArg.uid !== 0n && statsArg.uid !== effectiveUidArg) {
|
|
626
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
627
|
+
}
|
|
628
|
+
if (
|
|
629
|
+
(mode & 0o022n) !== 0n
|
|
630
|
+
&& !(statsArg.uid === 0n && (mode & 0o1000n) !== 0n)
|
|
631
|
+
) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const openDirectory = async (
|
|
635
|
+
directoryPathArg: string,
|
|
636
|
+
createArg: boolean,
|
|
637
|
+
): Promise<plugins.fs.promises.FileHandle> => {
|
|
638
|
+
let currentHandle: plugins.fs.promises.FileHandle | undefined;
|
|
639
|
+
try {
|
|
640
|
+
const effectiveUid = process.geteuid?.();
|
|
641
|
+
if (!Number.isSafeInteger(effectiveUid) || effectiveUid! < 0) {
|
|
642
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
643
|
+
}
|
|
644
|
+
const root = plugins.path.parse(directoryPathArg).root;
|
|
645
|
+
const components = plugins.path.relative(root, directoryPathArg)
|
|
646
|
+
.split(plugins.path.sep)
|
|
647
|
+
.filter(Boolean);
|
|
648
|
+
currentHandle = await plugins.fs.promises.open(
|
|
649
|
+
root,
|
|
650
|
+
plugins.fs.constants.O_RDONLY
|
|
651
|
+
| plugins.fs.constants.O_DIRECTORY
|
|
652
|
+
| plugins.fs.constants.O_NOFOLLOW,
|
|
653
|
+
);
|
|
654
|
+
validateDirectory(await currentHandle.stat({ bigint: true }), BigInt(effectiveUid!), false);
|
|
655
|
+
for (let index = 0; index < components.length; index++) {
|
|
656
|
+
const componentPath = directoryHandlePath(currentHandle, components[index]);
|
|
657
|
+
if (createArg) {
|
|
658
|
+
try {
|
|
659
|
+
await plugins.fs.promises.mkdir(componentPath, { mode: 0o700 });
|
|
660
|
+
} catch (errorArg) {
|
|
661
|
+
if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') throw errorArg;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
let nextHandle: plugins.fs.promises.FileHandle | undefined;
|
|
665
|
+
try {
|
|
666
|
+
nextHandle = await plugins.fs.promises.open(
|
|
667
|
+
componentPath,
|
|
668
|
+
plugins.fs.constants.O_RDONLY
|
|
669
|
+
| plugins.fs.constants.O_DIRECTORY
|
|
670
|
+
| plugins.fs.constants.O_NOFOLLOW,
|
|
671
|
+
);
|
|
672
|
+
validateDirectory(
|
|
673
|
+
await nextHandle.stat({ bigint: true }),
|
|
674
|
+
BigInt(effectiveUid!),
|
|
675
|
+
index === components.length - 1,
|
|
676
|
+
);
|
|
677
|
+
await closeFileHandle(currentHandle);
|
|
678
|
+
currentHandle = nextHandle;
|
|
679
|
+
nextHandle = undefined;
|
|
680
|
+
} finally {
|
|
681
|
+
if (nextHandle) await closeFileHandle(nextHandle);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const result = currentHandle;
|
|
685
|
+
currentHandle = undefined;
|
|
686
|
+
return result;
|
|
687
|
+
} catch (errorArg) {
|
|
688
|
+
let operationError: unknown = errorArg instanceof SmartSecretSealedFileStoreError
|
|
689
|
+
? errorArg
|
|
690
|
+
: createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
691
|
+
if (currentHandle) {
|
|
692
|
+
operationError = await closeFileHandleWithError(currentHandle, operationError);
|
|
693
|
+
}
|
|
694
|
+
throw operationError;
|
|
695
|
+
}
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const ensureDirectory = (directoryPathArg: string): Promise<plugins.fs.promises.FileHandle> =>
|
|
699
|
+
openDirectory(directoryPathArg, true);
|
|
700
|
+
|
|
701
|
+
const openExistingDirectory = (
|
|
702
|
+
directoryPathArg: string,
|
|
703
|
+
): Promise<plugins.fs.promises.FileHandle> => openDirectory(directoryPathArg, false);
|
|
704
|
+
|
|
705
|
+
const openChildDirectoryIfPresent = async (
|
|
706
|
+
parentHandleArg: plugins.fs.promises.FileHandle,
|
|
707
|
+
nameArg: string,
|
|
708
|
+
): Promise<plugins.fs.promises.FileHandle | undefined> => {
|
|
709
|
+
let handle: plugins.fs.promises.FileHandle | undefined;
|
|
710
|
+
try {
|
|
711
|
+
try {
|
|
712
|
+
handle = await plugins.fs.promises.open(
|
|
713
|
+
directoryHandlePath(parentHandleArg, nameArg),
|
|
714
|
+
plugins.fs.constants.O_RDONLY
|
|
715
|
+
| plugins.fs.constants.O_DIRECTORY
|
|
716
|
+
| plugins.fs.constants.O_NOFOLLOW,
|
|
717
|
+
);
|
|
718
|
+
} catch (errorArg) {
|
|
719
|
+
if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
720
|
+
throw errorArg;
|
|
721
|
+
}
|
|
722
|
+
const effectiveUid = process.geteuid?.();
|
|
723
|
+
if (!Number.isSafeInteger(effectiveUid) || effectiveUid! < 0) {
|
|
724
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
725
|
+
}
|
|
726
|
+
validateDirectory(await handle.stat({ bigint: true }), BigInt(effectiveUid!), true);
|
|
727
|
+
const result = handle;
|
|
728
|
+
handle = undefined;
|
|
729
|
+
return result;
|
|
730
|
+
} catch (errorArg) {
|
|
731
|
+
let operationError: unknown = errorArg instanceof SmartSecretSealedFileStoreError
|
|
732
|
+
? errorArg
|
|
733
|
+
: createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
734
|
+
if (handle) operationError = await closeFileHandleWithError(handle, operationError);
|
|
735
|
+
throw operationError;
|
|
736
|
+
}
|
|
737
|
+
};
|
|
738
|
+
|
|
739
|
+
const readStrictJsonFile = async (
|
|
740
|
+
directoryHandleArg: plugins.fs.promises.FileHandle,
|
|
741
|
+
fileNameArg: string,
|
|
742
|
+
maximumBytesArg: number,
|
|
743
|
+
): Promise<unknown | undefined> => {
|
|
744
|
+
let handle: plugins.fs.promises.FileHandle | undefined;
|
|
745
|
+
let bytes: Buffer | undefined;
|
|
746
|
+
let result: unknown | undefined;
|
|
747
|
+
let operationError: unknown;
|
|
748
|
+
try {
|
|
749
|
+
try {
|
|
750
|
+
handle = await plugins.fs.promises.open(
|
|
751
|
+
directoryHandlePath(directoryHandleArg, fileNameArg),
|
|
752
|
+
plugins.fs.constants.O_RDONLY | plugins.fs.constants.O_NOFOLLOW,
|
|
753
|
+
);
|
|
754
|
+
} catch (errorArg) {
|
|
755
|
+
if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
756
|
+
throw errorArg;
|
|
757
|
+
}
|
|
758
|
+
const stats = await handle.stat();
|
|
759
|
+
const effectiveUid = process.geteuid?.();
|
|
760
|
+
if (
|
|
761
|
+
!stats.isFile()
|
|
762
|
+
|| stats.nlink !== 1
|
|
763
|
+
|| stats.size < 1
|
|
764
|
+
|| stats.size > maximumBytesArg
|
|
765
|
+
|| (effectiveUid !== undefined && stats.uid !== effectiveUid)
|
|
766
|
+
|| (stats.mode & 0o7777) !== 0o600
|
|
767
|
+
) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
768
|
+
bytes = await handle.readFile();
|
|
769
|
+
if (bytes.byteLength !== stats.size) {
|
|
770
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
771
|
+
}
|
|
772
|
+
result = JSON.parse(bytes.toString('utf8')) as unknown;
|
|
773
|
+
} catch (errorArg) {
|
|
774
|
+
operationError = errorArg instanceof SmartSecretSealedFileStoreError
|
|
775
|
+
? errorArg
|
|
776
|
+
: createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
777
|
+
}
|
|
778
|
+
bytes?.fill(0);
|
|
779
|
+
if (handle) {
|
|
780
|
+
try {
|
|
781
|
+
await closeFileHandle(handle);
|
|
782
|
+
} catch (errorArg) {
|
|
783
|
+
operationError = preferCleanupError(operationError, errorArg);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
if (operationError) throw operationError;
|
|
787
|
+
return result;
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
const atomicWriteJson = async (
|
|
791
|
+
directoryHandleArg: plugins.fs.promises.FileHandle,
|
|
792
|
+
targetNameArg: string,
|
|
793
|
+
valueArg: unknown,
|
|
794
|
+
): Promise<void> => {
|
|
795
|
+
const temporaryName = `.smartsecret-${plugins.crypto.randomBytes(16).toString('base64url')}.tmp`;
|
|
796
|
+
const temporaryPath = directoryHandlePath(directoryHandleArg, temporaryName);
|
|
797
|
+
const targetPath = directoryHandlePath(directoryHandleArg, targetNameArg);
|
|
798
|
+
let handle: plugins.fs.promises.FileHandle | undefined;
|
|
799
|
+
let bytes: Buffer | undefined;
|
|
800
|
+
let renamed = false;
|
|
801
|
+
let mutationApplied = false;
|
|
802
|
+
let operationError: unknown;
|
|
803
|
+
try {
|
|
804
|
+
bytes = Buffer.from(JSON.stringify(valueArg), 'utf8');
|
|
805
|
+
handle = await plugins.fs.promises.open(
|
|
806
|
+
temporaryPath,
|
|
807
|
+
plugins.fs.constants.O_WRONLY
|
|
808
|
+
| plugins.fs.constants.O_CREAT
|
|
809
|
+
| plugins.fs.constants.O_EXCL
|
|
810
|
+
| plugins.fs.constants.O_NOFOLLOW,
|
|
811
|
+
0o600,
|
|
812
|
+
);
|
|
813
|
+
const stats = await handle.stat();
|
|
814
|
+
const effectiveUid = process.geteuid?.();
|
|
815
|
+
if (
|
|
816
|
+
!stats.isFile()
|
|
817
|
+
|| stats.nlink !== 1
|
|
818
|
+
|| (effectiveUid !== undefined && stats.uid !== effectiveUid)
|
|
819
|
+
|| (stats.mode & 0o7777) !== 0o600
|
|
820
|
+
) {
|
|
821
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
822
|
+
}
|
|
823
|
+
await handle.writeFile(bytes);
|
|
824
|
+
await handle.sync();
|
|
825
|
+
await closeFileHandle(handle);
|
|
826
|
+
handle = undefined;
|
|
827
|
+
await plugins.fs.promises.rename(temporaryPath, targetPath);
|
|
828
|
+
renamed = true;
|
|
829
|
+
mutationApplied = true;
|
|
830
|
+
await directoryHandleArg.sync();
|
|
831
|
+
} catch (errorArg) {
|
|
832
|
+
operationError = errorArg instanceof SmartSecretSealedFileStoreError
|
|
833
|
+
? errorArg
|
|
834
|
+
: createSmartSecretSealedFileStoreError(
|
|
835
|
+
mutationApplied ? 'MUTATION_OUTCOME_UNKNOWN' : 'FILESYSTEM_FAILED',
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
bytes?.fill(0);
|
|
839
|
+
if (handle) {
|
|
840
|
+
try {
|
|
841
|
+
await closeFileHandle(handle);
|
|
842
|
+
} catch (errorArg) {
|
|
843
|
+
operationError = preferCleanupError(operationError, errorArg);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if (!renamed) {
|
|
847
|
+
try {
|
|
848
|
+
await plugins.fs.promises.unlink(temporaryPath);
|
|
849
|
+
await directoryHandleArg.sync();
|
|
850
|
+
} catch (errorArg) {
|
|
851
|
+
if ((errorArg as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
852
|
+
operationError = createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (operationError) throw operationError;
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const removeAndSync = async (
|
|
860
|
+
directoryHandleArg: plugins.fs.promises.FileHandle,
|
|
861
|
+
fileNameArg: string,
|
|
862
|
+
): Promise<boolean> => {
|
|
863
|
+
try {
|
|
864
|
+
await plugins.fs.promises.unlink(directoryHandlePath(directoryHandleArg, fileNameArg));
|
|
865
|
+
try {
|
|
866
|
+
await directoryHandleArg.sync();
|
|
867
|
+
} catch {
|
|
868
|
+
throw createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
869
|
+
}
|
|
870
|
+
return true;
|
|
871
|
+
} catch (errorArg) {
|
|
872
|
+
if (errorArg instanceof SmartSecretSealedFileStoreError) throw errorArg;
|
|
873
|
+
if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return false;
|
|
874
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
const listOwnedFiles = async (
|
|
879
|
+
directoryHandleArg: plugins.fs.promises.FileHandle,
|
|
880
|
+
allowRelocationReceiptArg = false,
|
|
881
|
+
): Promise<string[]> => {
|
|
882
|
+
try {
|
|
883
|
+
const names = await plugins.fs.promises.readdir(directoryHandlePath(directoryHandleArg));
|
|
884
|
+
if (names.some((nameArg) => (
|
|
885
|
+
nameArg !== manifestName
|
|
886
|
+
&& !(allowRelocationReceiptArg && nameArg === relocationReceiptName)
|
|
887
|
+
&& !entryFilePattern.test(nameArg)
|
|
888
|
+
&& !temporaryFilePattern.test(nameArg)
|
|
889
|
+
))) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
890
|
+
return names;
|
|
891
|
+
} catch (errorArg) {
|
|
892
|
+
if (errorArg instanceof SmartSecretSealedFileStoreError) throw errorArg;
|
|
893
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const acquireLease = async (
|
|
898
|
+
optionsArg: INormalizedOptions,
|
|
899
|
+
deadlineArg = performance.now() + operationTimeoutMs,
|
|
900
|
+
): Promise<plugins.smartipc.NamedMutexLease> => {
|
|
901
|
+
const remainingMs = deadlineArg - performance.now();
|
|
902
|
+
if (remainingMs <= 0) {
|
|
903
|
+
throw createSmartSecretSealedFileStoreError('MUTEX_FAILED');
|
|
904
|
+
}
|
|
905
|
+
try {
|
|
906
|
+
const mutex = new plugins.smartipc.NamedMutex(optionsArg.mutexNamespace);
|
|
907
|
+
const lease = await mutex.acquire({ timeoutMs: Math.max(0, Math.floor(remainingMs)) });
|
|
908
|
+
if (performance.now() >= deadlineArg) {
|
|
909
|
+
await lease.release().catch(() => undefined);
|
|
910
|
+
throw createSmartSecretSealedFileStoreError('MUTEX_FAILED');
|
|
911
|
+
}
|
|
912
|
+
return lease;
|
|
913
|
+
} catch (errorArg) {
|
|
914
|
+
throw mapMutexError(errorArg);
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const readMasterKey = async (optionsArg: INormalizedOptions): Promise<Uint8Array | null> => {
|
|
919
|
+
let value: unknown;
|
|
920
|
+
try {
|
|
921
|
+
value = await optionsArg.kernelStore.getEntry(optionsArg.masterKeyAccount);
|
|
922
|
+
} catch (errorArg) {
|
|
923
|
+
throw mapKernelError(errorArg);
|
|
924
|
+
}
|
|
925
|
+
if (value === null) return null;
|
|
926
|
+
let result: Uint8Array | undefined;
|
|
927
|
+
try {
|
|
928
|
+
if (!(value instanceof Uint8Array)) {
|
|
929
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
930
|
+
}
|
|
931
|
+
result = new Uint8Array(value);
|
|
932
|
+
try {
|
|
933
|
+
value.fill(0);
|
|
934
|
+
} catch {}
|
|
935
|
+
if (result.byteLength !== masterKeyBytes) {
|
|
936
|
+
result.fill(0);
|
|
937
|
+
result = undefined;
|
|
938
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
939
|
+
}
|
|
940
|
+
return result;
|
|
941
|
+
} catch (errorArg) {
|
|
942
|
+
result?.fill(0);
|
|
943
|
+
if (errorArg instanceof SmartSecretSealedFileStoreError) throw errorArg;
|
|
944
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
945
|
+
}
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
const mapRelocationKernelError = (errorArg: unknown): SmartSecretSealedFileStoreError => {
|
|
949
|
+
if (!(errorArg instanceof SmartSecretKernelStoreError)) {
|
|
950
|
+
return createSmartSecretSealedFileStoreError('KERNEL_UNAVAILABLE');
|
|
951
|
+
}
|
|
952
|
+
if (errorArg.code === 'MUTATION_OUTCOME_UNKNOWN') {
|
|
953
|
+
return createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
954
|
+
}
|
|
955
|
+
if (errorArg.code === 'TARGET_CONFLICT' || errorArg.code === 'SOURCE_CHANGED') {
|
|
956
|
+
return createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
957
|
+
}
|
|
958
|
+
if (errorArg.code === 'INVALID_ARGUMENT') {
|
|
959
|
+
return createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
960
|
+
}
|
|
961
|
+
return createSmartSecretSealedFileStoreError('KERNEL_UNAVAILABLE');
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
const acquireRelocationLeases = async (
|
|
965
|
+
optionsArg: INormalizedRelocationOptions,
|
|
966
|
+
): Promise<plugins.smartipc.NamedMutexLease[]> => {
|
|
967
|
+
const deadline = performance.now() + operationTimeoutMs;
|
|
968
|
+
const ordered = [optionsArg.source, optionsArg.destination]
|
|
969
|
+
.sort((leftArg, rightArg) => leftArg.mutexNamespace < rightArg.mutexNamespace
|
|
970
|
+
? -1
|
|
971
|
+
: leftArg.mutexNamespace > rightArg.mutexNamespace
|
|
972
|
+
? 1
|
|
973
|
+
: 0);
|
|
974
|
+
const leases: plugins.smartipc.NamedMutexLease[] = [];
|
|
975
|
+
try {
|
|
976
|
+
for (const options of ordered) leases.push(await acquireLease(options, deadline));
|
|
977
|
+
return leases;
|
|
978
|
+
} catch (errorArg) {
|
|
979
|
+
let operationError: unknown = errorArg;
|
|
980
|
+
for (const lease of leases.reverse()) {
|
|
981
|
+
try {
|
|
982
|
+
await lease.release();
|
|
983
|
+
} catch {
|
|
984
|
+
operationError = preferCleanupError(
|
|
985
|
+
operationError,
|
|
986
|
+
createSmartSecretSealedFileStoreError('MUTEX_FAILED'),
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
throw operationError;
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
|
|
994
|
+
const inspectRelocationDirectory = async (
|
|
995
|
+
directoryHandleArg: plugins.fs.promises.FileHandle,
|
|
996
|
+
optionsArg: INormalizedOptions,
|
|
997
|
+
): Promise<IRelocationDirectoryInspection> => {
|
|
998
|
+
const names = await listOwnedFiles(directoryHandleArg, true);
|
|
999
|
+
if (!names.includes(manifestName)) {
|
|
1000
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1001
|
+
}
|
|
1002
|
+
const manifestValue = await readStrictJsonFile(
|
|
1003
|
+
directoryHandleArg,
|
|
1004
|
+
manifestName,
|
|
1005
|
+
4 * 1_024,
|
|
1006
|
+
);
|
|
1007
|
+
if (manifestValue === undefined) {
|
|
1008
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1009
|
+
}
|
|
1010
|
+
const receiptValue = names.includes(relocationReceiptName)
|
|
1011
|
+
? await readStrictJsonFile(directoryHandleArg, relocationReceiptName, 4 * 1_024)
|
|
1012
|
+
: undefined;
|
|
1013
|
+
if (names.includes(relocationReceiptName) && receiptValue === undefined) {
|
|
1014
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1015
|
+
}
|
|
1016
|
+
return {
|
|
1017
|
+
manifest: parseManifest(manifestValue, optionsArg),
|
|
1018
|
+
...(receiptValue === undefined ? {} : { receiptValue }),
|
|
1019
|
+
};
|
|
1020
|
+
};
|
|
1021
|
+
|
|
1022
|
+
const readRelocationMasterKey = async (
|
|
1023
|
+
sourceArg: INormalizedOptions,
|
|
1024
|
+
destinationArg: INormalizedOptions,
|
|
1025
|
+
expectedFingerprintArg: string,
|
|
1026
|
+
): Promise<IRelocationMasterKeyState> => {
|
|
1027
|
+
let sourceKey: Uint8Array | null = null;
|
|
1028
|
+
let destinationKey: Uint8Array | null = null;
|
|
1029
|
+
try {
|
|
1030
|
+
sourceKey = await readMasterKey(sourceArg);
|
|
1031
|
+
destinationKey = await readMasterKey(destinationArg);
|
|
1032
|
+
if (!sourceKey && !destinationKey) {
|
|
1033
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_UNAVAILABLE');
|
|
1034
|
+
}
|
|
1035
|
+
if (
|
|
1036
|
+
(sourceKey && fingerprint(sourceKey) !== expectedFingerprintArg)
|
|
1037
|
+
|| (destinationKey && fingerprint(destinationKey) !== expectedFingerprintArg)
|
|
1038
|
+
|| (
|
|
1039
|
+
sourceKey
|
|
1040
|
+
&& destinationKey
|
|
1041
|
+
&& !plugins.crypto.timingSafeEqual(sourceKey, destinationKey)
|
|
1042
|
+
)
|
|
1043
|
+
) throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
1044
|
+
return {
|
|
1045
|
+
key: new Uint8Array(sourceKey ?? destinationKey!),
|
|
1046
|
+
sourcePresent: sourceKey !== null,
|
|
1047
|
+
destinationPresent: destinationKey !== null,
|
|
1048
|
+
};
|
|
1049
|
+
} finally {
|
|
1050
|
+
sourceKey?.fill(0);
|
|
1051
|
+
destinationKey?.fill(0);
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
const syncRelocationParents = async (
|
|
1056
|
+
sourceParentHandleArg: plugins.fs.promises.FileHandle,
|
|
1057
|
+
sourceParentPathArg: string,
|
|
1058
|
+
destinationParentHandleArg: plugins.fs.promises.FileHandle,
|
|
1059
|
+
destinationParentPathArg: string,
|
|
1060
|
+
): Promise<void> => {
|
|
1061
|
+
try {
|
|
1062
|
+
await sourceParentHandleArg.sync();
|
|
1063
|
+
if (sourceParentPathArg !== destinationParentPathArg) {
|
|
1064
|
+
await destinationParentHandleArg.sync();
|
|
1065
|
+
}
|
|
1066
|
+
} catch {
|
|
1067
|
+
throw createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
const assertOpenChildIdentity = async (
|
|
1072
|
+
parentHandleArg: plugins.fs.promises.FileHandle,
|
|
1073
|
+
nameArg: string,
|
|
1074
|
+
childHandleArg: plugins.fs.promises.FileHandle,
|
|
1075
|
+
): Promise<IDirectoryIdentity> => {
|
|
1076
|
+
try {
|
|
1077
|
+
const [pathStats, handleStats] = await Promise.all([
|
|
1078
|
+
plugins.fs.promises.lstat(directoryHandlePath(parentHandleArg, nameArg), { bigint: true }),
|
|
1079
|
+
childHandleArg.stat({ bigint: true }),
|
|
1080
|
+
]);
|
|
1081
|
+
if (
|
|
1082
|
+
pathStats.isSymbolicLink()
|
|
1083
|
+
|| !pathStats.isDirectory()
|
|
1084
|
+
|| pathStats.dev !== handleStats.dev
|
|
1085
|
+
|| pathStats.ino !== handleStats.ino
|
|
1086
|
+
) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1087
|
+
return directoryIdentity(handleStats);
|
|
1088
|
+
} catch (errorArg) {
|
|
1089
|
+
if (errorArg instanceof SmartSecretSealedFileStoreError) throw errorArg;
|
|
1090
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
const readChildIdentity = async (
|
|
1095
|
+
parentHandleArg: plugins.fs.promises.FileHandle,
|
|
1096
|
+
nameArg: string,
|
|
1097
|
+
): Promise<IDirectoryIdentity | undefined> => {
|
|
1098
|
+
try {
|
|
1099
|
+
const stats = await plugins.fs.promises.lstat(
|
|
1100
|
+
directoryHandlePath(parentHandleArg, nameArg),
|
|
1101
|
+
{ bigint: true },
|
|
1102
|
+
);
|
|
1103
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
1104
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1105
|
+
}
|
|
1106
|
+
return directoryIdentity(stats);
|
|
1107
|
+
} catch (errorArg) {
|
|
1108
|
+
if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
1109
|
+
if (errorArg instanceof SmartSecretSealedFileStoreError) throw errorArg;
|
|
1110
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
const assertChildAbsent = async (
|
|
1115
|
+
parentHandleArg: plugins.fs.promises.FileHandle,
|
|
1116
|
+
nameArg: string,
|
|
1117
|
+
): Promise<void> => {
|
|
1118
|
+
try {
|
|
1119
|
+
await plugins.fs.promises.lstat(directoryHandlePath(parentHandleArg, nameArg));
|
|
1120
|
+
} catch (errorArg) {
|
|
1121
|
+
if ((errorArg as NodeJS.ErrnoException).code === 'ENOENT') return;
|
|
1122
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1123
|
+
}
|
|
1124
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1125
|
+
};
|
|
1126
|
+
|
|
1127
|
+
const createMasterKey = async (optionsArg: INormalizedOptions): Promise<Uint8Array> => {
|
|
1128
|
+
const generated = plugins.smartcrypto.generateAes256GcmKey();
|
|
1129
|
+
let written = false;
|
|
1130
|
+
try {
|
|
1131
|
+
try {
|
|
1132
|
+
await optionsArg.kernelStore.setEntry(optionsArg.masterKeyAccount, generated);
|
|
1133
|
+
written = true;
|
|
1134
|
+
} catch (errorArg) {
|
|
1135
|
+
throw mapKernelError(errorArg);
|
|
1136
|
+
}
|
|
1137
|
+
const readback = await readMasterKey(optionsArg);
|
|
1138
|
+
if (
|
|
1139
|
+
!readback
|
|
1140
|
+
|| !plugins.crypto.timingSafeEqual(readback, generated)
|
|
1141
|
+
) {
|
|
1142
|
+
readback?.fill(0);
|
|
1143
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
1144
|
+
}
|
|
1145
|
+
return readback;
|
|
1146
|
+
} catch (errorArg) {
|
|
1147
|
+
if (written) {
|
|
1148
|
+
try {
|
|
1149
|
+
await optionsArg.kernelStore.deleteEntry(optionsArg.masterKeyAccount);
|
|
1150
|
+
} catch (deleteErrorArg) {
|
|
1151
|
+
throw mapKernelError(deleteErrorArg);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
throw errorArg;
|
|
1155
|
+
} finally {
|
|
1156
|
+
generated.fill(0);
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
const bootstrapUnderLease = async (
|
|
1161
|
+
optionsArg: INormalizedOptions,
|
|
1162
|
+
existingIdentityArg?: IDirectoryIdentity,
|
|
1163
|
+
allowRelocationReceiptArg = false,
|
|
1164
|
+
): Promise<Uint8Array> => {
|
|
1165
|
+
const directoryHandle = existingIdentityArg
|
|
1166
|
+
? await openExistingDirectory(optionsArg.directoryPath)
|
|
1167
|
+
: await ensureDirectory(optionsArg.directoryPath);
|
|
1168
|
+
let key: Uint8Array | null = null;
|
|
1169
|
+
let result: Uint8Array | undefined;
|
|
1170
|
+
let createdMasterKey = false;
|
|
1171
|
+
let operationError: unknown;
|
|
1172
|
+
try {
|
|
1173
|
+
if (existingIdentityArg) {
|
|
1174
|
+
const currentIdentity = directoryIdentity(await directoryHandle.stat({ bigint: true }));
|
|
1175
|
+
if (!directoryIdentitiesEqual(currentIdentity, existingIdentityArg)) {
|
|
1176
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
const names = await listOwnedFiles(directoryHandle, allowRelocationReceiptArg);
|
|
1180
|
+
const manifestValue = await readStrictJsonFile(
|
|
1181
|
+
directoryHandle,
|
|
1182
|
+
manifestName,
|
|
1183
|
+
4 * 1_024,
|
|
1184
|
+
);
|
|
1185
|
+
const manifest = manifestValue === undefined ? undefined : parseManifest(manifestValue, optionsArg);
|
|
1186
|
+
key = await readMasterKey(optionsArg);
|
|
1187
|
+
if (!key) {
|
|
1188
|
+
if (names.length > 0) {
|
|
1189
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_UNAVAILABLE');
|
|
1190
|
+
}
|
|
1191
|
+
key = await createMasterKey(optionsArg);
|
|
1192
|
+
createdMasterKey = true;
|
|
1193
|
+
}
|
|
1194
|
+
if (key.byteLength !== masterKeyBytes) {
|
|
1195
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
1196
|
+
}
|
|
1197
|
+
const keyFingerprint = fingerprint(key);
|
|
1198
|
+
if (manifest && manifest.masterKeyFingerprint !== keyFingerprint) {
|
|
1199
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
1200
|
+
}
|
|
1201
|
+
for (const name of names.filter((nameArg) => temporaryFilePattern.test(nameArg))) {
|
|
1202
|
+
await removeAndSync(directoryHandle, name);
|
|
1203
|
+
}
|
|
1204
|
+
if (!manifest) {
|
|
1205
|
+
await atomicWriteJson(
|
|
1206
|
+
directoryHandle,
|
|
1207
|
+
manifestName,
|
|
1208
|
+
manifestFor(optionsArg, keyFingerprint),
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
result = new Uint8Array(key);
|
|
1212
|
+
key.fill(0);
|
|
1213
|
+
key = null;
|
|
1214
|
+
} catch (errorArg) {
|
|
1215
|
+
operationError = errorArg;
|
|
1216
|
+
}
|
|
1217
|
+
key?.fill(0);
|
|
1218
|
+
operationError = await closeFileHandleWithError(directoryHandle, operationError);
|
|
1219
|
+
if (operationError) {
|
|
1220
|
+
result?.fill(0);
|
|
1221
|
+
result = undefined;
|
|
1222
|
+
if (createdMasterKey && !mutationOutcomeUnknown(operationError)) {
|
|
1223
|
+
try {
|
|
1224
|
+
await optionsArg.kernelStore.deleteEntry(optionsArg.masterKeyAccount);
|
|
1225
|
+
} catch (deleteErrorArg) {
|
|
1226
|
+
const deleteError = mapKernelError(deleteErrorArg);
|
|
1227
|
+
throw preferCleanupError(operationError, deleteError);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
throw operationError;
|
|
1231
|
+
}
|
|
1232
|
+
return result!;
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Stores one small master key in a strict kernel store and authenticated
|
|
1237
|
+
* ciphertext in a caller-owned private directory. The kernel store lifecycle
|
|
1238
|
+
* remains owned by the caller.
|
|
1239
|
+
*/
|
|
1240
|
+
export class SmartSecretSealedFileStore {
|
|
1241
|
+
public readonly service: string;
|
|
1242
|
+
public readonly storeId: string;
|
|
1243
|
+
public readonly directoryPath: string;
|
|
1244
|
+
|
|
1245
|
+
private lifecycleState: TLifecycleState = 'ready';
|
|
1246
|
+
private operationTail: Promise<void> = Promise.resolve();
|
|
1247
|
+
private pendingOperations = 0;
|
|
1248
|
+
private closePromise?: Promise<void>;
|
|
1249
|
+
|
|
1250
|
+
private constructor(
|
|
1251
|
+
private readonly options: INormalizedOptions,
|
|
1252
|
+
private readonly masterKey: Uint8Array,
|
|
1253
|
+
) {
|
|
1254
|
+
this.service = options.service;
|
|
1255
|
+
this.storeId = options.storeId;
|
|
1256
|
+
this.directoryPath = options.directoryPath;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
public static async create(
|
|
1260
|
+
optionsArg: ISmartSecretSealedFileStoreOptions,
|
|
1261
|
+
): Promise<SmartSecretSealedFileStore> {
|
|
1262
|
+
const options = normalizeOptions(optionsArg);
|
|
1263
|
+
if (process.platform !== 'linux' || typeof process.geteuid !== 'function') {
|
|
1264
|
+
throw createSmartSecretSealedFileStoreError('KERNEL_UNAVAILABLE');
|
|
1265
|
+
}
|
|
1266
|
+
const lease = await acquireLease(options);
|
|
1267
|
+
let key: Uint8Array | undefined;
|
|
1268
|
+
let operationError: unknown;
|
|
1269
|
+
try {
|
|
1270
|
+
key = await bootstrapUnderLease(options);
|
|
1271
|
+
} catch (errorArg) {
|
|
1272
|
+
operationError = errorArg;
|
|
1273
|
+
}
|
|
1274
|
+
try {
|
|
1275
|
+
await lease.release();
|
|
1276
|
+
} catch {
|
|
1277
|
+
operationError = preferCleanupError(
|
|
1278
|
+
operationError,
|
|
1279
|
+
createSmartSecretSealedFileStoreError('MUTEX_FAILED'),
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
if (operationError) {
|
|
1283
|
+
key?.fill(0);
|
|
1284
|
+
throw operationError;
|
|
1285
|
+
}
|
|
1286
|
+
const store = new SmartSecretSealedFileStore(options, key!);
|
|
1287
|
+
key = undefined;
|
|
1288
|
+
return store;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/** Moves an initialized store and rebinds its exact kernel master key. */
|
|
1292
|
+
public static async relocate(
|
|
1293
|
+
optionsArg: ISmartSecretSealedFileStoreRelocationOptions,
|
|
1294
|
+
): Promise<SmartSecretSealedFileStore> {
|
|
1295
|
+
const options = normalizeRelocationOptions(optionsArg);
|
|
1296
|
+
if (process.platform !== 'linux' || typeof process.geteuid !== 'function') {
|
|
1297
|
+
throw createSmartSecretSealedFileStoreError('KERNEL_UNAVAILABLE');
|
|
1298
|
+
}
|
|
1299
|
+
const leases = await acquireRelocationLeases(options);
|
|
1300
|
+
let sourceParentHandle: plugins.fs.promises.FileHandle | undefined;
|
|
1301
|
+
let destinationParentHandle: plugins.fs.promises.FileHandle | undefined;
|
|
1302
|
+
let sourceDirectoryHandle: plugins.fs.promises.FileHandle | undefined;
|
|
1303
|
+
let destinationDirectoryHandle: plugins.fs.promises.FileHandle | undefined;
|
|
1304
|
+
let expectedKey: Uint8Array | undefined;
|
|
1305
|
+
let expectedIdentity: IDirectoryIdentity | undefined;
|
|
1306
|
+
let resultKey: Uint8Array | undefined;
|
|
1307
|
+
let mutationPossible = false;
|
|
1308
|
+
let operationError: unknown;
|
|
1309
|
+
const sourceParentPath = plugins.path.dirname(options.source.directoryPath);
|
|
1310
|
+
const destinationParentPath = plugins.path.dirname(options.destination.directoryPath);
|
|
1311
|
+
const sourceName = plugins.path.basename(options.source.directoryPath);
|
|
1312
|
+
const destinationName = plugins.path.basename(options.destination.directoryPath);
|
|
1313
|
+
try {
|
|
1314
|
+
sourceParentHandle = await openExistingDirectory(sourceParentPath);
|
|
1315
|
+
destinationParentHandle = await openExistingDirectory(destinationParentPath);
|
|
1316
|
+
const [sourceParentStats, destinationParentStats] = await Promise.all([
|
|
1317
|
+
sourceParentHandle.stat({ bigint: true }),
|
|
1318
|
+
destinationParentHandle.stat({ bigint: true }),
|
|
1319
|
+
]);
|
|
1320
|
+
if (sourceParentStats.dev !== destinationParentStats.dev) {
|
|
1321
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1322
|
+
}
|
|
1323
|
+
sourceDirectoryHandle = await openChildDirectoryIfPresent(sourceParentHandle, sourceName);
|
|
1324
|
+
destinationDirectoryHandle = await openChildDirectoryIfPresent(
|
|
1325
|
+
destinationParentHandle,
|
|
1326
|
+
destinationName,
|
|
1327
|
+
);
|
|
1328
|
+
if (Boolean(sourceDirectoryHandle) === Boolean(destinationDirectoryHandle)) {
|
|
1329
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1330
|
+
}
|
|
1331
|
+
const storeAtSource = sourceDirectoryHandle !== undefined;
|
|
1332
|
+
const currentOptions = storeAtSource ? options.source : options.destination;
|
|
1333
|
+
const currentHandle = sourceDirectoryHandle ?? destinationDirectoryHandle!;
|
|
1334
|
+
expectedIdentity = await assertOpenChildIdentity(
|
|
1335
|
+
storeAtSource ? sourceParentHandle : destinationParentHandle,
|
|
1336
|
+
storeAtSource ? sourceName : destinationName,
|
|
1337
|
+
currentHandle,
|
|
1338
|
+
);
|
|
1339
|
+
if (expectedIdentity.device !== sourceParentStats.dev.toString(10)) {
|
|
1340
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1341
|
+
}
|
|
1342
|
+
const inspection = await inspectRelocationDirectory(currentHandle, currentOptions);
|
|
1343
|
+
const keyState = await readRelocationMasterKey(
|
|
1344
|
+
options.source,
|
|
1345
|
+
options.destination,
|
|
1346
|
+
inspection.manifest.masterKeyFingerprint,
|
|
1347
|
+
);
|
|
1348
|
+
expectedKey = keyState.key;
|
|
1349
|
+
let receipt = inspection.receiptValue === undefined
|
|
1350
|
+
? undefined
|
|
1351
|
+
: parseRelocationReceipt(
|
|
1352
|
+
inspection.receiptValue,
|
|
1353
|
+
options,
|
|
1354
|
+
inspection.manifest.masterKeyFingerprint,
|
|
1355
|
+
expectedIdentity,
|
|
1356
|
+
);
|
|
1357
|
+
if (storeAtSource) {
|
|
1358
|
+
if (!keyState.sourcePresent) {
|
|
1359
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_UNAVAILABLE');
|
|
1360
|
+
}
|
|
1361
|
+
if (!receipt) {
|
|
1362
|
+
await atomicWriteJson(
|
|
1363
|
+
currentHandle,
|
|
1364
|
+
relocationReceiptName,
|
|
1365
|
+
relocationReceiptFor(
|
|
1366
|
+
options,
|
|
1367
|
+
inspection.manifest.masterKeyFingerprint,
|
|
1368
|
+
expectedIdentity,
|
|
1369
|
+
),
|
|
1370
|
+
);
|
|
1371
|
+
mutationPossible = true;
|
|
1372
|
+
const receiptValue = await readStrictJsonFile(
|
|
1373
|
+
currentHandle,
|
|
1374
|
+
relocationReceiptName,
|
|
1375
|
+
4 * 1_024,
|
|
1376
|
+
);
|
|
1377
|
+
if (receiptValue === undefined) {
|
|
1378
|
+
throw createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
1379
|
+
}
|
|
1380
|
+
receipt = parseRelocationReceipt(
|
|
1381
|
+
receiptValue,
|
|
1382
|
+
options,
|
|
1383
|
+
inspection.manifest.masterKeyFingerprint,
|
|
1384
|
+
expectedIdentity,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
await assertOpenChildIdentity(sourceParentHandle, sourceName, sourceDirectoryHandle!);
|
|
1388
|
+
await assertChildAbsent(destinationParentHandle, destinationName);
|
|
1389
|
+
try {
|
|
1390
|
+
await plugins.fs.promises.rename(
|
|
1391
|
+
directoryHandlePath(sourceParentHandle, sourceName),
|
|
1392
|
+
directoryHandlePath(destinationParentHandle, destinationName),
|
|
1393
|
+
);
|
|
1394
|
+
mutationPossible = true;
|
|
1395
|
+
} catch {
|
|
1396
|
+
const [sourceAfter, destinationAfter] = await Promise.all([
|
|
1397
|
+
readChildIdentity(sourceParentHandle, sourceName),
|
|
1398
|
+
readChildIdentity(destinationParentHandle, destinationName),
|
|
1399
|
+
]);
|
|
1400
|
+
if (
|
|
1401
|
+
sourceAfter
|
|
1402
|
+
&& directoryIdentitiesEqual(sourceAfter, expectedIdentity)
|
|
1403
|
+
&& destinationAfter === undefined
|
|
1404
|
+
) {
|
|
1405
|
+
try {
|
|
1406
|
+
if (!await removeAndSync(currentHandle, relocationReceiptName)) {
|
|
1407
|
+
throw createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
1408
|
+
}
|
|
1409
|
+
} catch (errorArg) {
|
|
1410
|
+
if (mutationOutcomeUnknown(errorArg)) throw errorArg;
|
|
1411
|
+
throw createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
1412
|
+
}
|
|
1413
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1414
|
+
}
|
|
1415
|
+
if (
|
|
1416
|
+
sourceAfter === undefined
|
|
1417
|
+
&& destinationAfter
|
|
1418
|
+
&& directoryIdentitiesEqual(destinationAfter, expectedIdentity)
|
|
1419
|
+
) {
|
|
1420
|
+
mutationPossible = true;
|
|
1421
|
+
} else {
|
|
1422
|
+
throw createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
} else {
|
|
1426
|
+
if (!receipt && (keyState.sourcePresent || !keyState.destinationPresent)) {
|
|
1427
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
await syncRelocationParents(
|
|
1431
|
+
sourceParentHandle,
|
|
1432
|
+
sourceParentPath,
|
|
1433
|
+
destinationParentHandle,
|
|
1434
|
+
destinationParentPath,
|
|
1435
|
+
);
|
|
1436
|
+
mutationPossible = true;
|
|
1437
|
+
let moveStatus: TSmartSecretKernelMoveStatus;
|
|
1438
|
+
try {
|
|
1439
|
+
moveStatus = await options.kernelStore.moveEntry(
|
|
1440
|
+
options.source.masterKeyAccount,
|
|
1441
|
+
options.destination.masterKeyAccount,
|
|
1442
|
+
);
|
|
1443
|
+
} catch (errorArg) {
|
|
1444
|
+
throw mapRelocationKernelError(errorArg);
|
|
1445
|
+
}
|
|
1446
|
+
if (moveStatus === 'sourceAbsent') {
|
|
1447
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_UNAVAILABLE');
|
|
1448
|
+
}
|
|
1449
|
+
const destinationKey = await readMasterKey(options.destination);
|
|
1450
|
+
try {
|
|
1451
|
+
if (!destinationKey || !plugins.crypto.timingSafeEqual(destinationKey, expectedKey)) {
|
|
1452
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
1453
|
+
}
|
|
1454
|
+
} finally {
|
|
1455
|
+
destinationKey?.fill(0);
|
|
1456
|
+
}
|
|
1457
|
+
resultKey = await bootstrapUnderLease(options.destination, expectedIdentity, Boolean(receipt));
|
|
1458
|
+
if (!plugins.crypto.timingSafeEqual(resultKey, expectedKey)) {
|
|
1459
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
1460
|
+
}
|
|
1461
|
+
if (receipt) {
|
|
1462
|
+
try {
|
|
1463
|
+
if (!await removeAndSync(currentHandle, relocationReceiptName)) {
|
|
1464
|
+
throw createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
1465
|
+
}
|
|
1466
|
+
} catch (errorArg) {
|
|
1467
|
+
if (mutationOutcomeUnknown(errorArg)) throw errorArg;
|
|
1468
|
+
throw createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN');
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
} catch (errorArg) {
|
|
1472
|
+
operationError = errorArg;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
for (const handle of [
|
|
1476
|
+
sourceDirectoryHandle,
|
|
1477
|
+
destinationDirectoryHandle,
|
|
1478
|
+
sourceParentHandle,
|
|
1479
|
+
destinationParentHandle,
|
|
1480
|
+
]) {
|
|
1481
|
+
if (!handle) continue;
|
|
1482
|
+
try {
|
|
1483
|
+
await closeFileHandle(handle);
|
|
1484
|
+
} catch (errorArg) {
|
|
1485
|
+
operationError = preferCleanupError(
|
|
1486
|
+
operationError,
|
|
1487
|
+
mutationPossible
|
|
1488
|
+
? createSmartSecretSealedFileStoreError('MUTATION_OUTCOME_UNKNOWN')
|
|
1489
|
+
: errorArg,
|
|
1490
|
+
);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
for (const lease of leases.reverse()) {
|
|
1494
|
+
try {
|
|
1495
|
+
await lease.release();
|
|
1496
|
+
} catch {
|
|
1497
|
+
operationError = preferCleanupError(
|
|
1498
|
+
operationError,
|
|
1499
|
+
createSmartSecretSealedFileStoreError(
|
|
1500
|
+
mutationPossible ? 'MUTATION_OUTCOME_UNKNOWN' : 'MUTEX_FAILED',
|
|
1501
|
+
),
|
|
1502
|
+
);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
expectedKey?.fill(0);
|
|
1506
|
+
if (operationError) {
|
|
1507
|
+
resultKey?.fill(0);
|
|
1508
|
+
throw operationError;
|
|
1509
|
+
}
|
|
1510
|
+
const store = new SmartSecretSealedFileStore(options.destination, resultKey!);
|
|
1511
|
+
resultKey = undefined;
|
|
1512
|
+
return store;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
/** Explicitly discards this store's ciphertext and kernel master key. */
|
|
1516
|
+
public static async reset(
|
|
1517
|
+
optionsArg: ISmartSecretSealedFileStoreOptions,
|
|
1518
|
+
): Promise<SmartSecretSealedFileStore> {
|
|
1519
|
+
const options = normalizeOptions(optionsArg);
|
|
1520
|
+
if (process.platform !== 'linux' || typeof process.geteuid !== 'function') {
|
|
1521
|
+
throw createSmartSecretSealedFileStoreError('KERNEL_UNAVAILABLE');
|
|
1522
|
+
}
|
|
1523
|
+
const lease = await acquireLease(options);
|
|
1524
|
+
let key: Uint8Array | undefined;
|
|
1525
|
+
let operationError: unknown;
|
|
1526
|
+
try {
|
|
1527
|
+
const directoryHandle = await ensureDirectory(options.directoryPath);
|
|
1528
|
+
try {
|
|
1529
|
+
const names = await listOwnedFiles(directoryHandle);
|
|
1530
|
+
if (names.some((nameArg) => (
|
|
1531
|
+
nameArg === manifestName || entryFilePattern.test(nameArg)
|
|
1532
|
+
))) {
|
|
1533
|
+
const manifestValue = await readStrictJsonFile(
|
|
1534
|
+
directoryHandle,
|
|
1535
|
+
manifestName,
|
|
1536
|
+
4 * 1_024,
|
|
1537
|
+
);
|
|
1538
|
+
if (manifestValue === undefined) {
|
|
1539
|
+
throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1540
|
+
}
|
|
1541
|
+
parseManifest(manifestValue, options);
|
|
1542
|
+
}
|
|
1543
|
+
try {
|
|
1544
|
+
await options.kernelStore.deleteEntry(options.masterKeyAccount);
|
|
1545
|
+
} catch (errorArg) {
|
|
1546
|
+
throw mapKernelError(errorArg);
|
|
1547
|
+
}
|
|
1548
|
+
const deletionOrder = names.filter((nameArg) => nameArg !== manifestName);
|
|
1549
|
+
if (names.includes(manifestName)) deletionOrder.push(manifestName);
|
|
1550
|
+
for (const name of deletionOrder) {
|
|
1551
|
+
await removeAndSync(directoryHandle, name);
|
|
1552
|
+
}
|
|
1553
|
+
} catch (errorArg) {
|
|
1554
|
+
operationError = errorArg;
|
|
1555
|
+
}
|
|
1556
|
+
operationError = await closeFileHandleWithError(directoryHandle, operationError);
|
|
1557
|
+
if (operationError) throw operationError;
|
|
1558
|
+
key = await bootstrapUnderLease(options);
|
|
1559
|
+
} catch (errorArg) {
|
|
1560
|
+
operationError = errorArg;
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
await lease.release();
|
|
1564
|
+
} catch {
|
|
1565
|
+
operationError = preferCleanupError(
|
|
1566
|
+
operationError,
|
|
1567
|
+
createSmartSecretSealedFileStoreError('MUTEX_FAILED'),
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
if (operationError) {
|
|
1571
|
+
key?.fill(0);
|
|
1572
|
+
throw operationError;
|
|
1573
|
+
}
|
|
1574
|
+
const store = new SmartSecretSealedFileStore(options, key!);
|
|
1575
|
+
key = undefined;
|
|
1576
|
+
return store;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
public getEntry(accountArg: string): Promise<Uint8Array | null> {
|
|
1580
|
+
let account: string;
|
|
1581
|
+
try {
|
|
1582
|
+
account = normalizeSmartSecretKernelAccount(accountArg);
|
|
1583
|
+
} catch {
|
|
1584
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
1585
|
+
}
|
|
1586
|
+
return this.reserveOperation((deadlineArg, markAcquiredArg) => this.withLease(
|
|
1587
|
+
deadlineArg,
|
|
1588
|
+
markAcquiredArg,
|
|
1589
|
+
async () => {
|
|
1590
|
+
const directoryHandle = await openExistingDirectory(this.directoryPath);
|
|
1591
|
+
const id = entryId(this.options, account);
|
|
1592
|
+
let value: unknown | undefined;
|
|
1593
|
+
let operationError: unknown;
|
|
1594
|
+
try {
|
|
1595
|
+
await this.verifyManifest(directoryHandle);
|
|
1596
|
+
value = await readStrictJsonFile(
|
|
1597
|
+
directoryHandle,
|
|
1598
|
+
`${id}.sealed.json`,
|
|
1599
|
+
maximumEnvelopeBytes,
|
|
1600
|
+
);
|
|
1601
|
+
} catch (errorArg) {
|
|
1602
|
+
operationError = errorArg;
|
|
1603
|
+
}
|
|
1604
|
+
operationError = await closeFileHandleWithError(directoryHandle, operationError);
|
|
1605
|
+
if (operationError) throw operationError;
|
|
1606
|
+
if (value === undefined) return null;
|
|
1607
|
+
const parsed = parseEnvelope(value);
|
|
1608
|
+
const aad = entryAad(this.options, account, id, fingerprint(this.masterKey));
|
|
1609
|
+
try {
|
|
1610
|
+
if (
|
|
1611
|
+
parsed.envelope.entryId !== id
|
|
1612
|
+
|| parsed.envelope.masterKeyFingerprint !== fingerprint(this.masterKey)
|
|
1613
|
+
) throw createSmartSecretSealedFileStoreError('ENVELOPE_INVALID');
|
|
1614
|
+
try {
|
|
1615
|
+
return await plugins.smartcrypto.aesGcmDecrypt({
|
|
1616
|
+
key: this.masterKey,
|
|
1617
|
+
encryptedData: parsed.encryptedData,
|
|
1618
|
+
aad,
|
|
1619
|
+
});
|
|
1620
|
+
} catch {
|
|
1621
|
+
throw createSmartSecretSealedFileStoreError('AUTHENTICATION_FAILED');
|
|
1622
|
+
}
|
|
1623
|
+
} finally {
|
|
1624
|
+
aad.fill(0);
|
|
1625
|
+
parsed.encryptedData.nonce.fill(0);
|
|
1626
|
+
parsed.encryptedData.ciphertext.fill(0);
|
|
1627
|
+
parsed.encryptedData.tag.fill(0);
|
|
1628
|
+
}
|
|
1629
|
+
},
|
|
1630
|
+
));
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
public setEntry(accountArg: string, valueArg: Uint8Array): Promise<void> {
|
|
1634
|
+
let account: string;
|
|
1635
|
+
try {
|
|
1636
|
+
account = normalizeSmartSecretKernelAccount(accountArg);
|
|
1637
|
+
} catch {
|
|
1638
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
1639
|
+
}
|
|
1640
|
+
let value: Uint8Array;
|
|
1641
|
+
try {
|
|
1642
|
+
if (!(valueArg instanceof Uint8Array)) {
|
|
1643
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
1644
|
+
}
|
|
1645
|
+
if (valueArg.byteLength > smartSecretSealedFileMaximumEntryBytes) {
|
|
1646
|
+
throw createSmartSecretSealedFileStoreError('SIZE_LIMIT_EXCEEDED');
|
|
1647
|
+
}
|
|
1648
|
+
value = new Uint8Array(valueArg);
|
|
1649
|
+
} catch (errorArg) {
|
|
1650
|
+
if (errorArg instanceof SmartSecretSealedFileStoreError) throw errorArg;
|
|
1651
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
1652
|
+
}
|
|
1653
|
+
let result: Promise<void>;
|
|
1654
|
+
try {
|
|
1655
|
+
result = this.reserveOperation((deadlineArg, markAcquiredArg) => this.withLease(
|
|
1656
|
+
deadlineArg,
|
|
1657
|
+
markAcquiredArg,
|
|
1658
|
+
async () => {
|
|
1659
|
+
const directoryHandle = await openExistingDirectory(this.directoryPath);
|
|
1660
|
+
let encrypted: plugins.smartcrypto.IAesGcmCiphertext | undefined;
|
|
1661
|
+
const id = entryId(this.options, account);
|
|
1662
|
+
const keyFingerprint = fingerprint(this.masterKey);
|
|
1663
|
+
const aad = entryAad(this.options, account, id, keyFingerprint);
|
|
1664
|
+
let operationError: unknown;
|
|
1665
|
+
try {
|
|
1666
|
+
await this.verifyManifest(directoryHandle);
|
|
1667
|
+
try {
|
|
1668
|
+
encrypted = await plugins.smartcrypto.aesGcmEncrypt({
|
|
1669
|
+
key: this.masterKey,
|
|
1670
|
+
plaintext: value,
|
|
1671
|
+
aad,
|
|
1672
|
+
});
|
|
1673
|
+
} catch {
|
|
1674
|
+
throw createSmartSecretSealedFileStoreError('AUTHENTICATION_FAILED');
|
|
1675
|
+
}
|
|
1676
|
+
const envelope: IEnvelopeV1 = {
|
|
1677
|
+
schemaVersion,
|
|
1678
|
+
profile,
|
|
1679
|
+
entryId: id,
|
|
1680
|
+
masterKeyFingerprint: keyFingerprint,
|
|
1681
|
+
...encodeEncryptedData(encrypted),
|
|
1682
|
+
};
|
|
1683
|
+
await atomicWriteJson(
|
|
1684
|
+
directoryHandle,
|
|
1685
|
+
`${id}.sealed.json`,
|
|
1686
|
+
envelope,
|
|
1687
|
+
);
|
|
1688
|
+
} catch (errorArg) {
|
|
1689
|
+
operationError = errorArg;
|
|
1690
|
+
}
|
|
1691
|
+
try {
|
|
1692
|
+
aad.fill(0);
|
|
1693
|
+
encrypted?.nonce.fill(0);
|
|
1694
|
+
encrypted?.ciphertext.fill(0);
|
|
1695
|
+
encrypted?.tag.fill(0);
|
|
1696
|
+
} finally {
|
|
1697
|
+
operationError = await closeFileHandleWithError(directoryHandle, operationError);
|
|
1698
|
+
}
|
|
1699
|
+
if (operationError) throw operationError;
|
|
1700
|
+
},
|
|
1701
|
+
));
|
|
1702
|
+
} catch (errorArg) {
|
|
1703
|
+
value.fill(0);
|
|
1704
|
+
throw errorArg;
|
|
1705
|
+
}
|
|
1706
|
+
return result.finally(() => value.fill(0));
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
public deleteEntry(accountArg: string): Promise<boolean> {
|
|
1710
|
+
let account: string;
|
|
1711
|
+
try {
|
|
1712
|
+
account = normalizeSmartSecretKernelAccount(accountArg);
|
|
1713
|
+
} catch {
|
|
1714
|
+
throw createSmartSecretSealedFileStoreError('INVALID_ARGUMENT');
|
|
1715
|
+
}
|
|
1716
|
+
return this.reserveOperation((deadlineArg, markAcquiredArg) => this.withLease(
|
|
1717
|
+
deadlineArg,
|
|
1718
|
+
markAcquiredArg,
|
|
1719
|
+
async () => {
|
|
1720
|
+
const directoryHandle = await openExistingDirectory(this.directoryPath);
|
|
1721
|
+
let result: boolean | undefined;
|
|
1722
|
+
let operationError: unknown;
|
|
1723
|
+
try {
|
|
1724
|
+
await this.verifyManifest(directoryHandle);
|
|
1725
|
+
const id = entryId(this.options, account);
|
|
1726
|
+
result = await removeAndSync(directoryHandle, `${id}.sealed.json`);
|
|
1727
|
+
} catch (errorArg) {
|
|
1728
|
+
operationError = errorArg;
|
|
1729
|
+
}
|
|
1730
|
+
operationError = await closeFileHandleWithError(directoryHandle, operationError);
|
|
1731
|
+
if (operationError) throw operationError;
|
|
1732
|
+
return result!;
|
|
1733
|
+
},
|
|
1734
|
+
));
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
public close(): Promise<void> {
|
|
1738
|
+
if (this.closePromise) return this.closePromise;
|
|
1739
|
+
this.lifecycleState = 'closing';
|
|
1740
|
+
const operationTail = this.operationTail;
|
|
1741
|
+
const closePromise = operationTail.finally(() => {
|
|
1742
|
+
this.masterKey.fill(0);
|
|
1743
|
+
this.lifecycleState = 'closed';
|
|
1744
|
+
});
|
|
1745
|
+
this.closePromise = closePromise;
|
|
1746
|
+
return closePromise;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
private reserveOperation<T>(
|
|
1750
|
+
operationArg: (deadlineArg: number, markAcquiredArg: () => void) => Promise<T>,
|
|
1751
|
+
): Promise<T> {
|
|
1752
|
+
if (this.lifecycleState !== 'ready') {
|
|
1753
|
+
throw createSmartSecretSealedFileStoreError('STORE_CLOSED');
|
|
1754
|
+
}
|
|
1755
|
+
if (this.pendingOperations >= maximumPendingOperations) {
|
|
1756
|
+
throw createSmartSecretSealedFileStoreError('OPERATION_LIMIT_REACHED');
|
|
1757
|
+
}
|
|
1758
|
+
this.pendingOperations += 1;
|
|
1759
|
+
const deadline = performance.now() + operationTimeoutMs;
|
|
1760
|
+
let admissionExpired = false;
|
|
1761
|
+
let admissionTimer: NodeJS.Timeout | undefined;
|
|
1762
|
+
let rejectAdmission!: (errorArg: SmartSecretSealedFileStoreError) => void;
|
|
1763
|
+
const admissionTimeout = new Promise<never>((_resolveArg, rejectArg) => {
|
|
1764
|
+
rejectAdmission = rejectArg;
|
|
1765
|
+
});
|
|
1766
|
+
const clearAdmissionTimer = (): void => {
|
|
1767
|
+
if (admissionTimer) {
|
|
1768
|
+
clearTimeout(admissionTimer);
|
|
1769
|
+
admissionTimer = undefined;
|
|
1770
|
+
}
|
|
1771
|
+
};
|
|
1772
|
+
const markAcquired = (): void => clearAdmissionTimer();
|
|
1773
|
+
admissionTimer = setTimeout(() => {
|
|
1774
|
+
admissionExpired = true;
|
|
1775
|
+
admissionTimer = undefined;
|
|
1776
|
+
rejectAdmission(createSmartSecretSealedFileStoreError('MUTEX_FAILED'));
|
|
1777
|
+
}, operationTimeoutMs);
|
|
1778
|
+
const execution = this.operationTail.then(() => {
|
|
1779
|
+
if (admissionExpired || performance.now() >= deadline) {
|
|
1780
|
+
throw createSmartSecretSealedFileStoreError('MUTEX_FAILED');
|
|
1781
|
+
}
|
|
1782
|
+
return operationArg(deadline, markAcquired);
|
|
1783
|
+
});
|
|
1784
|
+
this.operationTail = execution.then(() => undefined, () => undefined);
|
|
1785
|
+
execution.then(() => {
|
|
1786
|
+
clearAdmissionTimer();
|
|
1787
|
+
this.pendingOperations -= 1;
|
|
1788
|
+
}, () => {
|
|
1789
|
+
clearAdmissionTimer();
|
|
1790
|
+
this.pendingOperations -= 1;
|
|
1791
|
+
});
|
|
1792
|
+
return Promise.race([execution, admissionTimeout]);
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
private async withLease<T>(
|
|
1796
|
+
deadlineArg: number,
|
|
1797
|
+
markAcquiredArg: () => void,
|
|
1798
|
+
operationArg: () => Promise<T>,
|
|
1799
|
+
): Promise<T> {
|
|
1800
|
+
const lease = await acquireLease(this.options, deadlineArg);
|
|
1801
|
+
markAcquiredArg();
|
|
1802
|
+
let result: T | undefined;
|
|
1803
|
+
let operationError: unknown;
|
|
1804
|
+
try {
|
|
1805
|
+
result = await operationArg();
|
|
1806
|
+
} catch (errorArg) {
|
|
1807
|
+
operationError = errorArg;
|
|
1808
|
+
}
|
|
1809
|
+
try {
|
|
1810
|
+
await lease.release();
|
|
1811
|
+
} catch {
|
|
1812
|
+
wipeDiscardedResult(result);
|
|
1813
|
+
if (mutationOutcomeUnknown(operationError)) throw operationError;
|
|
1814
|
+
throw createSmartSecretSealedFileStoreError('MUTEX_FAILED');
|
|
1815
|
+
}
|
|
1816
|
+
if (operationError) throw operationError;
|
|
1817
|
+
return result as T;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
private async verifyManifest(
|
|
1821
|
+
directoryHandleArg: plugins.fs.promises.FileHandle,
|
|
1822
|
+
): Promise<void> {
|
|
1823
|
+
const value = await readStrictJsonFile(
|
|
1824
|
+
directoryHandleArg,
|
|
1825
|
+
manifestName,
|
|
1826
|
+
4 * 1_024,
|
|
1827
|
+
);
|
|
1828
|
+
if (value === undefined) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
|
|
1829
|
+
const manifest = parseManifest(value, this.options);
|
|
1830
|
+
if (manifest.masterKeyFingerprint !== fingerprint(this.masterKey)) {
|
|
1831
|
+
throw createSmartSecretSealedFileStoreError('MASTER_KEY_CONFLICT');
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
}
|