@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.
- package/.smartconfig.json +0 -1
- package/dist_rust/smartsecret-kernel_linux_amd64 +0 -0
- package/dist_ts/00_commitinfo_data.js +1 -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 +12 -1
- package/dist_ts/smartsecret.sealedfile.js +484 -15
- package/package.json +1 -1
- package/readme.hints.md +6 -0
- package/readme.md +24 -2
- package/ts/00_commitinfo_data.ts +1 -1
- 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.ts +650 -11
package/package.json
CHANGED
package/readme.hints.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
- No shell, PATH lookup, environment override, Secret Service, or filesystem secret fallback
|
|
25
25
|
- Generic values are framed and limited to 0..16384 bytes; service/account names are valid scalar UTF-8 strings of 1..1024 bytes
|
|
26
26
|
- Persistent-root service rings are revalidated on every operation; process keyring ownership is staging-only
|
|
27
|
+
- Generic entry moves publish and verify the exact framed value at the destination before invalidating the source
|
|
27
28
|
- Service-wide SmartIPC NamedMutex plus a single monotonic 1..60000 ms deadline bounds cooperating cross-process access
|
|
28
29
|
- Any post-dispatch mutation ambiguity or worker integrity failure terminates and poisons the store
|
|
29
30
|
- DevIdP v1 migration is opaque, receipt-bound, keyutils-only, and requires a quiescent legacy writer
|
|
@@ -36,6 +37,11 @@
|
|
|
36
37
|
- A non-secret manifest fingerprints the master key; missing kernel material never creates a replacement silently
|
|
37
38
|
- Explicit reset is destructive and removes only store-owned manifest, entry, and temporary files
|
|
38
39
|
- All identities targeting the same directory share one mutex; reset verifies any manifest identity before deletion
|
|
40
|
+
- Relocation locks both paths in deterministic order, atomically renames on one filesystem, fsyncs both parents, and rebinds the unchanged master key
|
|
41
|
+
- Relocation is idempotent after its own interrupted rename through an inode-bound receipt; arbitrary external moves are rejected
|
|
42
|
+
- Callers must persist an exact-path relocation intent before invocation and retain it after every error because the directory may already be at the destination
|
|
43
|
+
- While an intent or receipt may remain, recovery must retry relocate with the same paths instead of creating or resetting either location
|
|
44
|
+
- Source writers must be quiescent and the retired source must not be initialized again
|
|
39
45
|
- Directory traversal and store I/O are anchored to open descriptors; ancestors, final components, owners, modes, and O_NOFOLLOW are validated
|
|
40
46
|
- A 64-operation admission cap and one 60-second monotonic queue-plus-mutex deadline bound retained operation inputs
|
|
41
47
|
- Filesystem isolation assumes other processes under the same UID are trusted, matching the kernel-keyring boundary
|
package/readme.md
CHANGED
|
@@ -195,7 +195,7 @@ try {
|
|
|
195
195
|
|
|
196
196
|
Service and account strings must contain valid Unicode scalar values and encode to 1 through 1,024 UTF-8 bytes. Values may contain 0 through 16,384 bytes. The stored value is framed with a fixed version and exact length before publication. Existing service rings and entries are accepted only when their type, description, owner, and private permissions match the package contract. Kernel per-user quotas may still reject writes before this per-entry limit is reached; the store reports the kernel failure and never falls back to filesystem storage.
|
|
197
197
|
|
|
198
|
-
`setEntry()` copies its input before dispatch, and `getEntry()` returns a fresh byte array. The caller still owns its input and returned copies and should wipe them when no longer needed. JavaScript and operating-system buffers can retain additional copies, so this remains best-effort zeroization rather than guaranteed memory erasure.
|
|
198
|
+
`setEntry()` copies its input before dispatch, and `getEntry()` returns a fresh byte array. `moveEntry()` moves one exact framed entry to another account under the same service-wide lease: it verifies the destination before invalidating the source, and matching source/destination values converge to the destination. A differing destination rejects with `TARGET_CONFLICT`. When the source is already absent, `alreadyMoved` reports only that a structurally valid destination entry exists; callers that did not observe the original source must independently bind the destination value to their durable state. The caller still owns its input and returned copies and should wipe them when no longer needed. JavaScript and operating-system buffers can retain additional copies, so this remains best-effort zeroization rather than guaranteed memory erasure.
|
|
199
199
|
|
|
200
200
|
The store uses one persistent-user-owned service ring and stages unpublished objects in the process keyring. An inherited session link is preserved and revalidated, but it cannot override or conflict with the persistent root. A revoked inherited session keyring, which can remain after PAM logout, is treated as absent because it is optional and unusable; any failure to establish or validate the required process and persistent roots still fails closed. Publication is verified before staging ownership is removed. Every operation also acquires a service-wide `NamedMutex`, which coordinates cooperating processes running as the same OS identity on the same machine.
|
|
201
201
|
|
|
@@ -240,6 +240,10 @@ The directory is required to be owned by the effective user and mode `0700`; env
|
|
|
240
240
|
|
|
241
241
|
The non-secret manifest binds the directory to the kernel master-key fingerprint. If the kernel key is lost or expires while any manifest, envelope, or temporary artifact remains, `create()` fails with `MASTER_KEY_UNAVAILABLE` and never silently generates a replacement. `SmartSecretSealedFileStore.reset()` is an explicit destructive recovery operation: after verifying any existing manifest belongs to the requested service and store ID, it removes only the store-owned manifest, envelope, and temporary files, removes the old kernel entry if present, and creates a new empty store.
|
|
242
242
|
|
|
243
|
+
`SmartSecretSealedFileStore.relocate()` moves an initialized store to another absolute path on the same filesystem and rebinds the exact master key to the destination path-derived account. It never decrypts or rewrites the manifest or envelopes. Both private parent directories must already exist, while the destination store directory must not exist before the first attempt. The operation locks both paths in deterministic order, writes an inode- and request-bound relocation receipt, atomically renames the directory, fsyncs both parents, moves the kernel entry, removes the receipt, and returns a ready destination store. The receipt allows the same call to resume its own interrupted rename; arbitrary external moves and copied destinations are rejected. A completed call remains idempotent because the destination key is verified against the unchanged manifest.
|
|
244
|
+
|
|
245
|
+
The caller must durably record the exact service, store ID, source path, and destination path before quiescing writers and calling `relocate()`. Preserve that intent after every rejected call: `KERNEL_UNAVAILABLE`, `FILESYSTEM_FAILED`, master-key errors, and `MUTATION_OUTCOME_UNKNOWN` can all be reported after the directory was renamed, so an error code does not establish which path currently exists. Recovery must call `relocate()` again with the same path pair; create a fresh kernel store first when the prior store was poisoned or its worker integrity is uncertain. Do not call `create()` or `reset()` at either path while the intent or relocation receipt may remain, and never initialize a new store at the retired source path. Clear the caller-owned intent only after `relocate()` returns a ready destination store and the application has durably committed the destination as authoritative.
|
|
246
|
+
|
|
243
247
|
Each store instance admits at most 64 operations at once. Each admitted operation has one 60-second monotonic deadline covering the local operation queue and cross-process mutex acquisition. The mutex is directory-wide, so different service or store identities targeting the same directory serialize with each other. Work already running after mutex acquisition is not interrupted by that admission deadline. `close()` rejects new operations, drains admitted operations, and then best-effort wipes the retained master-key copy.
|
|
244
248
|
|
|
245
249
|
### DevIdP v1 keyutils migration
|
|
@@ -319,11 +323,12 @@ SmartSecretKernelStore.create(
|
|
|
319
323
|
| `getEntry` | `(account: string, options?) => Promise<Uint8Array \| null>` | Read a copied raw value or return `null` when absent |
|
|
320
324
|
| `setEntry` | `(account: string, value: Uint8Array, options?) => Promise<void>` | Create or replace one framed entry |
|
|
321
325
|
| `deleteEntry` | `(account: string, options?) => Promise<boolean>` | Delete an entry and report whether it existed |
|
|
326
|
+
| `moveEntry` | `(sourceAccount: string, destinationAccount: string, options?) => Promise<'moved' \| 'alreadyMoved' \| 'sourceAbsent'>` | Idempotently move one exact framed entry without exposing its value |
|
|
322
327
|
| `readDevIdpV1Legacy` | `(account: string, options?) => Promise<IDevIdpV1LegacyRead \| null>` | Read one verified, opaque keyutils legacy value |
|
|
323
328
|
| `deleteDevIdpV1Legacy` | `(receipt, options?) => Promise<'deleted' \| 'alreadyAbsent'>` | Revalidate and consume one legacy receipt |
|
|
324
329
|
| `close` | `() => Promise<void>` | Wait for reserved operations and confirm worker termination |
|
|
325
330
|
|
|
326
|
-
Operation options contain optional `timeoutMs` and `signal: AbortSignal` properties. Failures are code-only `SmartSecretKernelStoreError` instances. Stable codes distinguish invalid input, unsupported runtime, unavailable kernel/worker/mutex resources, root conflicts, corrupt entries, changed
|
|
331
|
+
Operation options contain optional `timeoutMs` and `signal: AbortSignal` properties. Failures are code-only `SmartSecretKernelStoreError` instances. Stable codes distinguish invalid input, unsupported runtime, unavailable kernel/worker/mutex resources, root conflicts, corrupt entries, changed sources, target conflicts, pre-dispatch aborts/timeouts, unknown mutation outcomes, poisoned/closed lifecycle state, and worker integrity failures. Secret values and underlying causes are never retained on these errors.
|
|
327
332
|
|
|
328
333
|
### `SmartSecretSealedFileStore`
|
|
329
334
|
|
|
@@ -360,6 +365,22 @@ interface ISmartSecretSealedFileStoreOptions {
|
|
|
360
365
|
storeId: string;
|
|
361
366
|
directoryPath: string;
|
|
362
367
|
}
|
|
368
|
+
|
|
369
|
+
interface ISmartSecretSealedFileRelocationKernelStore
|
|
370
|
+
extends ISmartSecretSealedFileKernelStore {
|
|
371
|
+
moveEntry(
|
|
372
|
+
sourceAccount: string,
|
|
373
|
+
destinationAccount: string,
|
|
374
|
+
options?: ISmartSecretKernelOperationOptions,
|
|
375
|
+
): Promise<'moved' | 'alreadyMoved' | 'sourceAbsent'>;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
interface ISmartSecretSealedFileStoreRelocationOptions {
|
|
379
|
+
kernelStore: ISmartSecretSealedFileRelocationKernelStore;
|
|
380
|
+
storeId: string;
|
|
381
|
+
sourceDirectoryPath: string;
|
|
382
|
+
destinationDirectoryPath: string;
|
|
383
|
+
}
|
|
363
384
|
```
|
|
364
385
|
|
|
365
386
|
Instances expose readonly `service`, `storeId`, and `directoryPath` properties. The exported `smartSecretSealedFileMaximumEntryBytes` constant is `524288`.
|
|
@@ -367,6 +388,7 @@ Instances expose readonly `service`, `storeId`, and `directoryPath` properties.
|
|
|
367
388
|
| Method | Signature | Description |
|
|
368
389
|
| --- | --- | --- |
|
|
369
390
|
| `create` | `(options) => Promise<SmartSecretSealedFileStore>` | Open or initialize a store without replacing missing key material |
|
|
391
|
+
| `relocate` | `(options) => Promise<SmartSecretSealedFileStore>` | Move one initialized store, rebind its exact master key, and resume completed or interrupted moves |
|
|
370
392
|
| `reset` | `(options) => Promise<SmartSecretSealedFileStore>` | Destructively discard store-owned ciphertext and create a new empty store |
|
|
371
393
|
| `getEntry` | `(account: string) => Promise<Uint8Array \| null>` | Authenticate and decrypt a copied value or return `null` when absent |
|
|
372
394
|
| `setEntry` | `(account: string, value: Uint8Array) => Promise<void>` | Atomically encrypt and replace an entry up to 524,288 bytes |
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type TSmartSecretKernelStoreErrorCode =
|
|
|
11
11
|
| 'SOURCE_CHANGED'
|
|
12
12
|
| 'STORE_CLOSED'
|
|
13
13
|
| 'STORE_POISONED'
|
|
14
|
+
| 'TARGET_CONFLICT'
|
|
14
15
|
| 'UNSUPPORTED_RUNTIME'
|
|
15
16
|
| 'WORKER_INTEGRITY_FAILED'
|
|
16
17
|
| 'WORKER_UNAVAILABLE';
|
|
@@ -35,6 +36,7 @@ const errorCodes = new Set<TSmartSecretKernelStoreErrorCode>([
|
|
|
35
36
|
'SOURCE_CHANGED',
|
|
36
37
|
'STORE_CLOSED',
|
|
37
38
|
'STORE_POISONED',
|
|
39
|
+
'TARGET_CONFLICT',
|
|
38
40
|
'UNSUPPORTED_RUNTIME',
|
|
39
41
|
'WORKER_INTEGRITY_FAILED',
|
|
40
42
|
'WORKER_UNAVAILABLE',
|
|
@@ -32,7 +32,8 @@ export type TSmartSecretKernelDomainErrorCode =
|
|
|
32
32
|
| 'KERNEL_UNAVAILABLE'
|
|
33
33
|
| 'MUTATION_OUTCOME_UNKNOWN'
|
|
34
34
|
| 'ROOT_CONFLICT'
|
|
35
|
-
| 'SOURCE_CHANGED'
|
|
35
|
+
| 'SOURCE_CHANGED'
|
|
36
|
+
| 'TARGET_CONFLICT';
|
|
36
37
|
|
|
37
38
|
export interface ISmartSecretKernelDomainErrorResult {
|
|
38
39
|
outcome: 'domainError';
|
|
@@ -86,6 +87,17 @@ export type TSmartSecretKernelDeleteResult =
|
|
|
86
87
|
| ISmartSecretKernelDomainErrorResult
|
|
87
88
|
| ISmartSecretKernelDeleteSuccess;
|
|
88
89
|
|
|
90
|
+
export type TSmartSecretKernelMoveStatus = 'moved' | 'alreadyMoved' | 'sourceAbsent';
|
|
91
|
+
|
|
92
|
+
export interface ISmartSecretKernelMoveSuccess {
|
|
93
|
+
outcome: 'success';
|
|
94
|
+
status: TSmartSecretKernelMoveStatus;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type TSmartSecretKernelMoveResult =
|
|
98
|
+
| ISmartSecretKernelDomainErrorResult
|
|
99
|
+
| ISmartSecretKernelMoveSuccess;
|
|
100
|
+
|
|
89
101
|
export interface ISmartSecretKernelLegacySuccessAbsent {
|
|
90
102
|
outcome: 'success';
|
|
91
103
|
found: false;
|
|
@@ -142,6 +154,10 @@ export type TSmartSecretKernelCommands = {
|
|
|
142
154
|
params: { description: string };
|
|
143
155
|
result: TSmartSecretKernelDeleteResult;
|
|
144
156
|
};
|
|
157
|
+
moveEntry: {
|
|
158
|
+
params: { sourceDescription: string; destinationDescription: string };
|
|
159
|
+
result: TSmartSecretKernelMoveResult;
|
|
160
|
+
};
|
|
145
161
|
readDevIdpV1Legacy: {
|
|
146
162
|
params: { account: string };
|
|
147
163
|
result: TSmartSecretKernelLegacyReadResult;
|
|
@@ -159,6 +175,7 @@ const domainErrorCodes: ReadonlySet<string> = new Set([
|
|
|
159
175
|
'MUTATION_OUTCOME_UNKNOWN',
|
|
160
176
|
'ROOT_CONFLICT',
|
|
161
177
|
'SOURCE_CHANGED',
|
|
178
|
+
'TARGET_CONFLICT',
|
|
162
179
|
]);
|
|
163
180
|
|
|
164
181
|
const isSmartSecretKernelDomainErrorCode = (
|
|
@@ -539,6 +556,28 @@ export const parseSmartSecretKernelDeleteResult = (
|
|
|
539
556
|
return { kind: 'success', deleted: result.deleted };
|
|
540
557
|
};
|
|
541
558
|
|
|
559
|
+
export const parseSmartSecretKernelMoveResult = (
|
|
560
|
+
valueArg: unknown,
|
|
561
|
+
):
|
|
562
|
+
| { kind: 'domainError'; domainError: ISmartSecretKernelDomainErrorResult }
|
|
563
|
+
| { kind: 'success'; status: TSmartSecretKernelMoveStatus } => {
|
|
564
|
+
const value = requireResultObject(valueArg);
|
|
565
|
+
const domainError = parseDomainError(value);
|
|
566
|
+
if (domainError) return { kind: 'domainError', domainError };
|
|
567
|
+
const result = requireExactObject(value, ['outcome', 'status'], false);
|
|
568
|
+
if (
|
|
569
|
+
result.outcome !== 'success'
|
|
570
|
+
|| (
|
|
571
|
+
result.status !== 'moved'
|
|
572
|
+
&& result.status !== 'alreadyMoved'
|
|
573
|
+
&& result.status !== 'sourceAbsent'
|
|
574
|
+
)
|
|
575
|
+
) {
|
|
576
|
+
throw createSmartSecretKernelStoreError('WORKER_INTEGRITY_FAILED');
|
|
577
|
+
}
|
|
578
|
+
return { kind: 'success', status: result.status };
|
|
579
|
+
};
|
|
580
|
+
|
|
542
581
|
export type TParsedSmartSecretKernelLegacyRead =
|
|
543
582
|
| { kind: 'domainError'; domainError: ISmartSecretKernelDomainErrorResult }
|
|
544
583
|
| { kind: 'success'; found: false }
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
parseSmartSecretKernelGetResult,
|
|
18
18
|
parseSmartSecretKernelLegacyDeleteResult,
|
|
19
19
|
parseSmartSecretKernelLegacyReadResult,
|
|
20
|
+
parseSmartSecretKernelMoveResult,
|
|
20
21
|
parseSmartSecretKernelSetResult,
|
|
21
22
|
smartSecretKernelDefaultTimeoutMs,
|
|
22
23
|
smartSecretKernelLegacyService,
|
|
@@ -27,12 +28,14 @@ import {
|
|
|
27
28
|
type ISmartSecretKernelOperationOptions,
|
|
28
29
|
type ISmartSecretKernelStoreOptions,
|
|
29
30
|
type TSmartSecretKernelCommands,
|
|
31
|
+
type TSmartSecretKernelMoveStatus,
|
|
30
32
|
} from './smartsecret.kernel.protocol.js';
|
|
31
33
|
import { isSupportedSmartSecretKernelRuntime } from './smartsecret.kernel.runtime.js';
|
|
32
34
|
|
|
33
35
|
export type {
|
|
34
36
|
ISmartSecretKernelOperationOptions,
|
|
35
37
|
ISmartSecretKernelStoreOptions,
|
|
38
|
+
TSmartSecretKernelMoveStatus,
|
|
36
39
|
} from './smartsecret.kernel.protocol.js';
|
|
37
40
|
|
|
38
41
|
const legacyReceiptBrand = Symbol('SmartSecretDevIdpV1LegacyReceipt');
|
|
@@ -280,6 +283,38 @@ export class SmartSecretKernelStore {
|
|
|
280
283
|
}));
|
|
281
284
|
}
|
|
282
285
|
|
|
286
|
+
public moveEntry(
|
|
287
|
+
sourceAccountArg: string,
|
|
288
|
+
destinationAccountArg: string,
|
|
289
|
+
optionsArg?: ISmartSecretKernelOperationOptions,
|
|
290
|
+
): Promise<TSmartSecretKernelMoveStatus> {
|
|
291
|
+
const sourceAccount = normalizeSmartSecretKernelAccount(sourceAccountArg);
|
|
292
|
+
const destinationAccount = normalizeSmartSecretKernelAccount(destinationAccountArg);
|
|
293
|
+
if (sourceAccount === destinationAccount) {
|
|
294
|
+
throw createSmartSecretKernelStoreError('INVALID_ARGUMENT');
|
|
295
|
+
}
|
|
296
|
+
const options = normalizeSmartSecretKernelOperationOptions(optionsArg);
|
|
297
|
+
const context = createOperationContext(options, true);
|
|
298
|
+
const sourceDescription = deriveSmartSecretKernelEntryDescription(this.service, sourceAccount);
|
|
299
|
+
const destinationDescription = deriveSmartSecretKernelEntryDescription(
|
|
300
|
+
this.service,
|
|
301
|
+
destinationAccount,
|
|
302
|
+
);
|
|
303
|
+
return this.reserveOperation(context, () => this.withKernelLease(context, async () => {
|
|
304
|
+
const parsed = await this.dispatchMutation(context, async (requestOptionsArg) =>
|
|
305
|
+
parseSmartSecretKernelMoveResult(await this.bridge.sendCommand(
|
|
306
|
+
'moveEntry',
|
|
307
|
+
{ sourceDescription, destinationDescription },
|
|
308
|
+
requestOptionsArg,
|
|
309
|
+
))
|
|
310
|
+
);
|
|
311
|
+
if (parsed.kind === 'domainError') {
|
|
312
|
+
return this.throwDomainError(parsed.domainError, context);
|
|
313
|
+
}
|
|
314
|
+
return parsed.status;
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
283
318
|
public readDevIdpV1Legacy(
|
|
284
319
|
accountArg: string,
|
|
285
320
|
optionsArg?: ISmartSecretKernelOperationOptions,
|
|
@@ -630,6 +665,9 @@ export class SmartSecretKernelStore {
|
|
|
630
665
|
if (domainErrorArg.code === 'SOURCE_CHANGED') {
|
|
631
666
|
throw createSmartSecretKernelStoreError('SOURCE_CHANGED');
|
|
632
667
|
}
|
|
668
|
+
if (domainErrorArg.code === 'TARGET_CONFLICT') {
|
|
669
|
+
throw createSmartSecretKernelStoreError('TARGET_CONFLICT');
|
|
670
|
+
}
|
|
633
671
|
if (
|
|
634
672
|
domainErrorArg.code === 'MUTATION_OUTCOME_UNKNOWN'
|
|
635
673
|
&& contextArg.mutation
|