@push.rocks/smartsecret 1.5.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.
@@ -6,6 +6,7 @@ import {
6
6
  normalizeSmartSecretKernelAccount,
7
7
  normalizeSmartSecretKernelService,
8
8
  type ISmartSecretKernelOperationOptions,
9
+ type TSmartSecretKernelMoveStatus,
9
10
  } from './smartsecret.kernel.protocol.js';
10
11
  import {
11
12
  SmartSecretSealedFileStoreError,
@@ -17,6 +18,7 @@ export const smartSecretSealedFileMaximumEntryBytes = 512 * 1_024;
17
18
  const schemaVersion = 1 as const;
18
19
  const profile = 'smartsecret-kernel-aes-256-gcm-file-v1' as const;
19
20
  const manifestName = 'manifest.json';
21
+ const relocationReceiptName = '.smartsecret-relocation.json';
20
22
  const masterKeyBytes = 32;
21
23
  const maximumEnvelopeBytes = 768 * 1_024;
22
24
  const operationTimeoutMs = 60_000;
@@ -43,12 +45,34 @@ export interface ISmartSecretSealedFileKernelStore {
43
45
  ): Promise<boolean>;
44
46
  }
45
47
 
48
+ export interface ISmartSecretSealedFileRelocationKernelStore
49
+ extends ISmartSecretSealedFileKernelStore {
50
+ moveEntry(
51
+ sourceAccountArg: string,
52
+ destinationAccountArg: string,
53
+ optionsArg?: ISmartSecretKernelOperationOptions,
54
+ ): Promise<TSmartSecretKernelMoveStatus>;
55
+ }
56
+
46
57
  export interface ISmartSecretSealedFileStoreOptions {
47
58
  kernelStore: ISmartSecretSealedFileKernelStore;
48
59
  storeId: string;
49
60
  directoryPath: string;
50
61
  }
51
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
+
52
76
  interface INormalizedOptions {
53
77
  kernelStore: ISmartSecretSealedFileKernelStore;
54
78
  service: string;
@@ -76,6 +100,33 @@ interface IEnvelopeV1 {
76
100
  tag: string;
77
101
  }
78
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
+
79
130
  type TLifecycleState = 'ready' | 'closing' | 'closed';
80
131
  type TBigIntStats = plugins.fs.BigIntStats;
81
132
 
@@ -195,6 +246,71 @@ const normalizeOptions = (optionsArg: ISmartSecretSealedFileStoreOptions): INorm
195
246
  }
196
247
  };
197
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
+
198
314
  const exactObject = (
199
315
  valueArg: unknown,
200
316
  keysArg: readonly string[],
@@ -349,6 +465,66 @@ const manifestFor = (optionsArg: INormalizedOptions, fingerprintArg: string): IM
349
465
  masterKeyFingerprint: fingerprintArg,
350
466
  });
351
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
+
352
528
  const parseManifest = (valueArg: unknown, optionsArg: INormalizedOptions): IManifestV1 => {
353
529
  const value = exactObject(valueArg, [
354
530
  'schemaVersion',
@@ -455,7 +631,10 @@ const validateDirectory = (
455
631
  ) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
456
632
  };
457
633
 
458
- const ensureDirectory = async (directoryPathArg: string): Promise<plugins.fs.promises.FileHandle> => {
634
+ const openDirectory = async (
635
+ directoryPathArg: string,
636
+ createArg: boolean,
637
+ ): Promise<plugins.fs.promises.FileHandle> => {
459
638
  let currentHandle: plugins.fs.promises.FileHandle | undefined;
460
639
  try {
461
640
  const effectiveUid = process.geteuid?.();
@@ -475,10 +654,12 @@ const ensureDirectory = async (directoryPathArg: string): Promise<plugins.fs.pro
475
654
  validateDirectory(await currentHandle.stat({ bigint: true }), BigInt(effectiveUid!), false);
476
655
  for (let index = 0; index < components.length; index++) {
477
656
  const componentPath = directoryHandlePath(currentHandle, components[index]);
478
- try {
479
- await plugins.fs.promises.mkdir(componentPath, { mode: 0o700 });
480
- } catch (errorArg) {
481
- if ((errorArg as NodeJS.ErrnoException).code !== 'EEXIST') throw errorArg;
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
+ }
482
663
  }
483
664
  let nextHandle: plugins.fs.promises.FileHandle | undefined;
484
665
  try {
@@ -514,6 +695,47 @@ const ensureDirectory = async (directoryPathArg: string): Promise<plugins.fs.pro
514
695
  }
515
696
  };
516
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
+
517
739
  const readStrictJsonFile = async (
518
740
  directoryHandleArg: plugins.fs.promises.FileHandle,
519
741
  fileNameArg: string,
@@ -655,11 +877,13 @@ const removeAndSync = async (
655
877
 
656
878
  const listOwnedFiles = async (
657
879
  directoryHandleArg: plugins.fs.promises.FileHandle,
880
+ allowRelocationReceiptArg = false,
658
881
  ): Promise<string[]> => {
659
882
  try {
660
883
  const names = await plugins.fs.promises.readdir(directoryHandlePath(directoryHandleArg));
661
884
  if (names.some((nameArg) => (
662
885
  nameArg !== manifestName
886
+ && !(allowRelocationReceiptArg && nameArg === relocationReceiptName)
663
887
  && !entryFilePattern.test(nameArg)
664
888
  && !temporaryFilePattern.test(nameArg)
665
889
  ))) throw createSmartSecretSealedFileStoreError('FILESYSTEM_FAILED');
@@ -721,6 +945,185 @@ const readMasterKey = async (optionsArg: INormalizedOptions): Promise<Uint8Array
721
945
  }
722
946
  };
723
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
+
724
1127
  const createMasterKey = async (optionsArg: INormalizedOptions): Promise<Uint8Array> => {
725
1128
  const generated = plugins.smartcrypto.generateAes256GcmKey();
726
1129
  let written = false;
@@ -754,14 +1157,26 @@ const createMasterKey = async (optionsArg: INormalizedOptions): Promise<Uint8Arr
754
1157
  }
755
1158
  };
756
1159
 
757
- const bootstrapUnderLease = async (optionsArg: INormalizedOptions): Promise<Uint8Array> => {
758
- const directoryHandle = await ensureDirectory(optionsArg.directoryPath);
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);
759
1168
  let key: Uint8Array | null = null;
760
1169
  let result: Uint8Array | undefined;
761
1170
  let createdMasterKey = false;
762
1171
  let operationError: unknown;
763
1172
  try {
764
- const names = await listOwnedFiles(directoryHandle);
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);
765
1180
  const manifestValue = await readStrictJsonFile(
766
1181
  directoryHandle,
767
1182
  manifestName,
@@ -873,6 +1288,230 @@ export class SmartSecretSealedFileStore {
873
1288
  return store;
874
1289
  }
875
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
+
876
1515
  /** Explicitly discards this store's ciphertext and kernel master key. */
877
1516
  public static async reset(
878
1517
  optionsArg: ISmartSecretSealedFileStoreOptions,
@@ -948,7 +1587,7 @@ export class SmartSecretSealedFileStore {
948
1587
  deadlineArg,
949
1588
  markAcquiredArg,
950
1589
  async () => {
951
- const directoryHandle = await ensureDirectory(this.directoryPath);
1590
+ const directoryHandle = await openExistingDirectory(this.directoryPath);
952
1591
  const id = entryId(this.options, account);
953
1592
  let value: unknown | undefined;
954
1593
  let operationError: unknown;
@@ -1017,7 +1656,7 @@ export class SmartSecretSealedFileStore {
1017
1656
  deadlineArg,
1018
1657
  markAcquiredArg,
1019
1658
  async () => {
1020
- const directoryHandle = await ensureDirectory(this.directoryPath);
1659
+ const directoryHandle = await openExistingDirectory(this.directoryPath);
1021
1660
  let encrypted: plugins.smartcrypto.IAesGcmCiphertext | undefined;
1022
1661
  const id = entryId(this.options, account);
1023
1662
  const keyFingerprint = fingerprint(this.masterKey);
@@ -1078,7 +1717,7 @@ export class SmartSecretSealedFileStore {
1078
1717
  deadlineArg,
1079
1718
  markAcquiredArg,
1080
1719
  async () => {
1081
- const directoryHandle = await ensureDirectory(this.directoryPath);
1720
+ const directoryHandle = await openExistingDirectory(this.directoryPath);
1082
1721
  let result: boolean | undefined;
1083
1722
  let operationError: unknown;
1084
1723
  try {