deadbyte-mcp 0.15.47 → 0.15.49
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/MANIFEST.SHA256 +111 -48
- package/PROVENANCE.json +1 -1
- package/README.txt +18 -18
- package/RELEASE.json +3 -3
- package/SBOM.cdx.json +2 -2
- package/TEST-MATRIX.json +2 -2
- package/bench/r121-epoch-readhead.mjs +54 -0
- package/bench/r124-epoch-predecessor.mjs +53 -0
- package/bin/deadbyte-epoch-lock-r85.node +0 -0
- package/controller/deadbyte-process-policy.json +4 -4
- package/docs/HARNESS2-ADAPTATION-20261020.md +24 -0
- package/docs/R128-RELEASE-CANDIDATE.md +26 -0
- package/docs/R75-RECOVERY-HANDOFF.md +16 -0
- package/docs/R85-EPOCH-LOCK-BINARY.json +13 -0
- package/native/deadbyte-epoch-lock-r85.cpp +241 -0
- package/package.json +1 -1
- package/scripts/build-epoch-lock-r85.ps1 +34 -0
- package/scripts/deploy-r48.mjs +3 -1
- package/scripts/npm-release-r48.mjs +18 -4
- package/scripts/r75-npm-bin-integrity-gate.mjs +33 -0
- package/scripts/release-manifest.mjs +1 -1
- package/scripts/verify-authority-epoch-history.mjs +38 -0
- package/scripts/verify-r70-full-regression.ps1 +12 -2
- package/src/activation-authority-proof-r75.mjs +67 -0
- package/src/activation-continuity-engine-r65.mjs +17 -10
- package/src/activation-preflight-r65.mjs +13 -0
- package/src/agent-eval-r42.mjs +30 -0
- package/src/authority-continuity-r65.mjs +442 -29
- package/src/autonomous-local-supervisor.mjs +77 -47
- package/src/deadbyte-cli.mjs +73 -46
- package/src/epoch-lock-r85.mjs +48 -0
- package/src/foreground-marker-cas-win32.mjs +284 -0
- package/src/foreground-process-identity.mjs +3 -3
- package/src/privileged-install-trust-r89.mjs +196 -0
- package/src/remote-live-log.mjs +15 -8
- package/src/remote-log-decode-r60.mjs +65 -0
- package/src/remote-session-guardian.mjs +13 -12
- package/src/runtime-update.mjs +5 -22
- package/src/version.mjs +1 -1
- package/src/windows-powershell-environment.mjs +24 -0
- package/test/autonomous-local-supervisor.test.mjs +1 -1
- package/test/cli-entrypoint.test.mjs +1 -1
- package/test/harness2-eval-context-isolation.test.mjs +90 -0
- package/test/helpers/r75-authority-proof.mjs +9 -0
- package/test/r100-posix-owned-lock-race.test.mjs +72 -0
- package/test/r101-acl-probe-environment.test.mjs +51 -0
- package/test/r106-trusted-powershell-spawn.test.mjs +53 -0
- package/test/r107-updater-powershell-path.test.mjs +77 -0
- package/test/r108-remote-powershell-path.test.mjs +59 -0
- package/test/r110-supervisor-powershell-path.test.mjs +28 -0
- package/test/r111-supervisor-marker-cas.test.mjs +116 -0
- package/test/r112-supervisor-claim-cas.test.mjs +63 -0
- package/test/r113-supervisor-cleanup-terminality.test.mjs +100 -0
- package/test/r114-supervisor-absence-proof.test.mjs +45 -0
- package/test/r115-posix-continuity-entrypoint.test.mjs +17 -0
- package/test/r116-authority-crash-matrix.test.mjs +83 -0
- package/test/r117-concurrent-orphan-recovery.test.mjs +54 -0
- package/test/r118-cross-process-orphan-recovery.test.mjs +99 -0
- package/test/r119-recovery-crash-after-commit.test.mjs +91 -0
- package/test/r120-authority-history-audit.test.mjs +103 -0
- package/test/r121-signed-transition-invariant.test.mjs +132 -0
- package/test/r122-epoch-bounded-read.test.mjs +58 -0
- package/test/r123-recovery-bounded-read.test.mjs +64 -0
- package/test/r124-live-log-integrity.test.mjs +66 -0
- package/test/r124-live-log-tail-boundaries.test.mjs +57 -0
- package/test/r124-signed-predecessor-shape.test.mjs +66 -0
- package/test/r125-restore-binding-types.test.mjs +63 -0
- package/test/r126-restore-policy-envelope.test.mjs +89 -0
- package/test/r127-restore-append-policy-boundary.test.mjs +25 -0
- package/test/r33-closeout-regression.test.mjs +2 -1
- package/test/r54-offline-predecessor-self-exclusion.test.mjs +2 -2
- package/test/r55-offline-predecessor-guardian-family.test.mjs +2 -2
- package/test/r56-rollback-launcher-restore.test.mjs +2 -2
- package/test/r57-core-startup-race.test.mjs +2 -2
- package/test/r58-concurrent-recovery.test.mjs +10 -7
- package/test/r58-stale-owner.test.mjs +5 -6
- package/test/r59-tool-surface-compat.test.mjs +1 -1
- package/test/r65-activation-orchestration.test.mjs +2 -1
- package/test/r65-activation-preflight.test.mjs +7 -6
- package/test/r65-authority-epoch-recovery.test.mjs +3 -1
- package/test/r65-authority-epoch-store.test.mjs +9 -6
- package/test/r65-authority-restored-safe.test.mjs +2 -1
- package/test/r65-deploy-preflight.test.mjs +13 -14
- package/test/r65-epoch-native-activation-integration.test.mjs +5 -4
- package/test/r65-legacy-authority-migration.test.mjs +2 -1
- package/test/r67-npm-cross-volume.test.mjs +9 -2
- package/test/r71-full-verifier-artifact.test.mjs +21 -15
- package/test/r71-isolated-slot-rollback-restore.test.mjs +3 -1
- package/test/r71-release-identity.test.mjs +3 -1
- package/test/r74-kernel-lock.test.mjs +12 -6
- package/test/r74-prepublish-contract.test.mjs +3 -1
- package/test/r75-activation-authority-proof.test.mjs +46 -0
- package/test/r75-authority-evidence.test.mjs +77 -0
- package/test/r76-pinned-marker-cas.test.mjs +78 -0
- package/test/r77-epoch-grant-cas.test.mjs +51 -0
- package/test/r78-parent-junction-cas.test.mjs +62 -0
- package/test/r79-orphan-revocation-reconcile.test.mjs +88 -0
- package/test/r79-windows-fsync-regression.test.mjs +45 -0
- package/test/r80-durable-epoch-commit.test.mjs +76 -0
- package/test/r82-epoch-lock-owned-cas.test.mjs +93 -0
- package/test/r83-epoch-lock-fileid-cas.test.mjs +92 -0
- package/test/r84-epoch-lock-created-fileid.test.mjs +112 -0
- package/test/r85-epoch-lock-release-lifetime.test.mjs +25 -0
- package/test/r85-epoch-repeat-append.test.mjs +30 -0
- package/test/r86-epoch-cleanup-and-loader.test.mjs +126 -0
- package/test/r87-crashed-orphan-revoke-reconcile.test.mjs +110 -0
- package/test/r88-native-disposition-failure.test.mjs +35 -0
- package/test/r89-privileged-install-boundary.test.mjs +123 -0
- package/test/r92-acl-ace-applicability.test.mjs +45 -0
- package/test/r94-win32-propagation-enum.test.mjs +51 -0
- package/test/r99-privileged-import-closure.test.mjs +45 -0
- package/test/remote-session-cli.test.mjs +69 -9
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {createHash,createPrivateKey,createPublicKey,randomBytes,sign as cryptoSign,verify as cryptoVerify} from 'node:crypto';
|
|
2
|
-
import {mkdir,open,readFile,readdir,rename,rm,writeFile} from 'node:fs/promises';
|
|
2
|
+
import {lstat,mkdir,open,readFile,readdir,rename,rm,writeFile} from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {canonicalJson} from './canonical-json.mjs';
|
|
5
5
|
import {decodeUtf8Strict} from './text-policy.mjs';
|
|
@@ -214,10 +214,63 @@ const durablePublicKeys=new Map();
|
|
|
214
214
|
const RESTORE_BINDING_FIELDS=Object.freeze(['prior_grant_sha256','prior_grant_instance_nonce','predecessor_policy_envelope_sha256','successor_policy_envelope_sha256','candidate_manifest_sha256','continuity_subject_sha256']);
|
|
215
215
|
function normalizeRestoreBinding(value,{required=false}={}){
|
|
216
216
|
if(value===undefined||value===null){if(required)throw new Error('authority epoch transaction restore binding required');return null;}
|
|
217
|
-
if(!plain(value)||Object.keys(value).length!==RESTORE_BINDING_FIELDS.length||
|
|
217
|
+
if(!plain(value)||Object.keys(value).length!==RESTORE_BINDING_FIELDS.length||
|
|
218
|
+
RESTORE_BINDING_FIELDS.some(key=>typeof value[key]!=='string'||!SHAPAT.test(value[key])))
|
|
218
219
|
throw new Error('authority epoch transaction restore binding invalid');
|
|
219
220
|
for(const key of Object.keys(value))if(!RESTORE_BINDING_FIELDS.includes(key))throw new Error('authority epoch transaction restore binding invalid');
|
|
220
|
-
return Object.freeze(Object.fromEntries(RESTORE_BINDING_FIELDS.map(key=>[key,
|
|
221
|
+
return Object.freeze(Object.fromEntries(RESTORE_BINDING_FIELDS.map(key=>[key,value[key]])));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function assertEpochActionIdentity(current){
|
|
225
|
+
const action=current?.action;
|
|
226
|
+
if(!DURABLE_EPOCH_ACTIONS.has(action)||
|
|
227
|
+
(action.startsWith('transaction_')?
|
|
228
|
+
typeof current.update_id!=='string'||!/^[A-Za-z0-9._-]{1,96}$/.test(current.update_id):
|
|
229
|
+
current.update_id!==null))
|
|
230
|
+
throw new Error('authority epoch transition invalid');
|
|
231
|
+
if(action==='transaction_restore')normalizeRestoreBinding(current.restore_binding,{required:true});
|
|
232
|
+
else if(current.restore_binding!==null)throw new Error('authority epoch transition invalid');
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertEpochPredecessorIdentity(event){
|
|
236
|
+
if(event.schema!==DURABLE_EPOCH_SCHEMA||
|
|
237
|
+
typeof event.grant_sha256!=='string'||!SHAPAT.test(event.grant_sha256)||
|
|
238
|
+
typeof event.grant_instance_nonce!=='string'||!SHAPAT.test(event.grant_instance_nonce)||
|
|
239
|
+
(event.seq===1?event.previous_event_sha256!==null:
|
|
240
|
+
typeof event.previous_event_sha256!=='string'||!SHAPAT.test(event.previous_event_sha256))||
|
|
241
|
+
((event.policy_sha256!==undefined||event.policy_envelope_sha256!==undefined)&&
|
|
242
|
+
(typeof event.policy_sha256!=='string'||!SHAPAT.test(event.policy_sha256)||
|
|
243
|
+
typeof event.policy_envelope_sha256!=='string'||!SHAPAT.test(event.policy_envelope_sha256))))
|
|
244
|
+
throw new Error('authority epoch transition invalid');
|
|
245
|
+
assertEpochActionIdentity(event);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function assertEpochTransition(current,previous){
|
|
249
|
+
assertEpochActionIdentity(current);
|
|
250
|
+
const action=current.action;
|
|
251
|
+
if(!previous){
|
|
252
|
+
if(action.startsWith('transaction_'))throw new Error('authority epoch transition invalid');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if(action==='transaction_revoke'&&
|
|
256
|
+
(!['grant','transaction_restore'].includes(previous.action)||
|
|
257
|
+
current.grant_sha256!==previous.grant_sha256||
|
|
258
|
+
current.grant_instance_nonce!==previous.grant_instance_nonce))
|
|
259
|
+
throw new Error('authority epoch transition invalid');
|
|
260
|
+
if(action==='transaction_restore'){
|
|
261
|
+
const binding=normalizeRestoreBinding(current.restore_binding,{required:true});
|
|
262
|
+
if(previous.action!=='transaction_revoke'||current.update_id!==previous.update_id||
|
|
263
|
+
binding.prior_grant_sha256!==previous.grant_sha256||
|
|
264
|
+
binding.prior_grant_instance_nonce!==previous.grant_instance_nonce||
|
|
265
|
+
current.grant_instance_nonce===previous.grant_instance_nonce||
|
|
266
|
+
(previous.policy_envelope_sha256!==undefined&&
|
|
267
|
+
binding.predecessor_policy_envelope_sha256!==previous.policy_envelope_sha256)||
|
|
268
|
+
(current.policy_envelope_sha256!==undefined&&
|
|
269
|
+
binding.successor_policy_envelope_sha256!==current.policy_envelope_sha256))
|
|
270
|
+
throw new Error('authority epoch transition invalid');
|
|
271
|
+
}
|
|
272
|
+
if(action==='grant'&¤t.grant_instance_nonce===previous.grant_instance_nonce)
|
|
273
|
+
throw new Error('authority epoch transition invalid');
|
|
221
274
|
}
|
|
222
275
|
|
|
223
276
|
async function durableEpochKeys(runtimeDir,{privateRequired=false}={}){
|
|
@@ -271,38 +324,159 @@ function processAlive(pid){
|
|
|
271
324
|
if(pid===process.pid)return true;
|
|
272
325
|
try{process.kill(pid,0);return true;}catch(error){return error?.code==='EPERM';}
|
|
273
326
|
}
|
|
274
|
-
|
|
327
|
+
let cachedEpochOwnerIdentity=null;
|
|
328
|
+
async function windowsEpochOwnerIdentity(pid){
|
|
329
|
+
const {queryForegroundIdentity,validForegroundIdentity}=await import('./foreground-process-identity.mjs');
|
|
330
|
+
const identity=queryForegroundIdentity(pid);
|
|
331
|
+
if(identity!==null&&!validForegroundIdentity(identity))throw new Error('authority epoch owner process identity incomplete');
|
|
332
|
+
return identity;
|
|
333
|
+
}
|
|
334
|
+
async function currentEpochOwnerIdentity(){
|
|
335
|
+
if(process.platform!=='win32')return null;
|
|
336
|
+
if(!cachedEpochOwnerIdentity)cachedEpochOwnerIdentity=await windowsEpochOwnerIdentity(process.pid);
|
|
337
|
+
if(!cachedEpochOwnerIdentity||cachedEpochOwnerIdentity.pid!==process.pid)
|
|
338
|
+
throw new Error('authority epoch owner process identity unavailable');
|
|
339
|
+
return cachedEpochOwnerIdentity;
|
|
340
|
+
}
|
|
341
|
+
// R80: A successful file write is not a durability barrier. Flush the exact
|
|
342
|
+
// open file handle BEFORE publishing an event or replacing the signed head.
|
|
343
|
+
// Node on Windows does not expose a verified directory-metadata flush: keep
|
|
344
|
+
// that limitation explicit instead of treating rename() as a power-loss proof.
|
|
345
|
+
export function authorityEpochDurabilityAssessment(){
|
|
346
|
+
return Object.freeze({
|
|
347
|
+
event_file:'FLUSH_REQUIRED',head_temp_file:'FLUSH_REQUIRED',
|
|
348
|
+
directory_metadata:process.platform==='win32'?'UNVERIFIED_ON_WINDOWS':'FSYNC_REQUIRED',
|
|
349
|
+
power_loss:'NOT_VERIFIED_BY_TESTS'
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
async function syncDirectoryMetadata(dir){
|
|
353
|
+
if(process.platform==='win32')return false; // No verified Node directory FlushFileBuffers contract.
|
|
354
|
+
const handle=await open(dir,'r');
|
|
355
|
+
try{await handle.sync();}
|
|
356
|
+
finally{await handle.close();}
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
async function writeDurableEvent(file,bytes,{stage=async()=>{}}={}){
|
|
360
|
+
const handle=await open(file,'wx'); // immutable identity, exclusive creation
|
|
361
|
+
try{
|
|
362
|
+
await stage('event_created');
|
|
363
|
+
await handle.writeFile(bytes);
|
|
364
|
+
await stage('event_written');
|
|
365
|
+
await handle.sync(); // event bytes must be flushed BEFORE head visibility
|
|
366
|
+
await stage('event_file_synced');
|
|
367
|
+
}finally{await handle.close();}
|
|
368
|
+
await syncDirectoryMetadata(path.dirname(file));
|
|
369
|
+
await stage('event_directory_barrier');
|
|
370
|
+
}
|
|
371
|
+
async function atomicJsonWrite(file,value,{stage=async()=>{}}={}){
|
|
275
372
|
const dir=path.dirname(file);
|
|
276
373
|
await mkdir(dir,{recursive:true});
|
|
277
374
|
const tmp=`${file}.tmp-${process.pid}-${randomBytes(8).toString('hex')}`;
|
|
278
375
|
try{
|
|
279
|
-
await
|
|
376
|
+
const handle=await open(tmp,'wx');
|
|
377
|
+
try{
|
|
378
|
+
await stage('head_temp_created');
|
|
379
|
+
await handle.writeFile(Buffer.from(canonicalJson(value),'utf8'));
|
|
380
|
+
await stage('head_temp_written');
|
|
381
|
+
await handle.sync(); // temp head content flushed BEFORE rename
|
|
382
|
+
await stage('head_temp_file_synced');
|
|
383
|
+
}finally{await handle.close();}
|
|
280
384
|
await rename(tmp,file);
|
|
385
|
+
await stage('head_renamed'); // rename succeeded: caller outcome may now be ambiguous
|
|
386
|
+
await syncDirectoryMetadata(dir);
|
|
387
|
+
await stage('head_directory_barrier');
|
|
281
388
|
}finally{
|
|
282
389
|
await rm(tmp,{force:true}).catch(()=>{});
|
|
283
390
|
}
|
|
284
391
|
}
|
|
285
|
-
|
|
392
|
+
// R82: On Windows, lock reclamation/release MUST be conditional on the
|
|
393
|
+
// exact observed lock bytes and must dispose through one pinned native handle.
|
|
394
|
+
// The R78 implementation also pins every non-reparse ancestor directory.
|
|
395
|
+
// R83: Windows lock snapshots now bind owner bytes + native FileIdInfo to
|
|
396
|
+
// the same pinned handle. The later pinned deletion compares BOTH identity
|
|
397
|
+
// and content before disposition, defeating byte-identical file replacement.
|
|
398
|
+
// POSIX remains legacy/unsupported against adversarial lock-path replacement.
|
|
399
|
+
async function epochLockSnapshot(lockPath){
|
|
400
|
+
if(process.platform==='win32'){
|
|
401
|
+
const {readPinnedEpochLockSnapshot}=await import('./foreground-marker-cas-win32.mjs');
|
|
402
|
+
return readPinnedEpochLockSnapshot(lockPath);
|
|
403
|
+
}
|
|
404
|
+
try{return {bytes:await readFile(lockPath),fileId:null};}
|
|
405
|
+
catch(error){if(error?.code==='ENOENT')return null;throw error;}
|
|
406
|
+
}
|
|
407
|
+
async function disposeObservedEpochLock(lockPath,snapshot){
|
|
408
|
+
if(process.platform==='win32'){
|
|
409
|
+
if(!snapshot?.fileId)throw new Error('epoch lock native identity snapshot is required');
|
|
410
|
+
const {deletePinnedRemoteMarker}=await import('./foreground-marker-cas-win32.mjs');
|
|
411
|
+
return deletePinnedRemoteMarker(lockPath,hashBytes(snapshot.bytes),{expectedFileId:snapshot.fileId});
|
|
412
|
+
}
|
|
413
|
+
throw new Error('authority epoch POSIX lock disposition unsupported');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function acquireEpochLock(dir,{allowStaleRecovery=true,onLockDispositionStage=null}={}){
|
|
417
|
+
// POSIX open('wx') protects creation, but a later read/check followed by
|
|
418
|
+
// unlink(path) cannot atomically prove the pathname still denotes our lock.
|
|
419
|
+
// Until a kernel-held, identity-bound POSIX protocol exists, no authority
|
|
420
|
+
// epoch mutation may start there. Read-only head verification remains usable.
|
|
421
|
+
if(process.platform!=='win32')
|
|
422
|
+
throw new Error('authority epoch POSIX mutation unsupported: lock pathname cannot be disposed atomically');
|
|
286
423
|
await mkdir(dir,{recursive:true});
|
|
287
424
|
const lockPath=path.join(dir,'.lock');
|
|
425
|
+
const stage=async label=>{if(onLockDispositionStage)await onLockDispositionStage(label,lockPath);};
|
|
288
426
|
for(let attempt=0;attempt<2;attempt++){
|
|
289
427
|
const token=randomBytes(16).toString('hex');
|
|
290
428
|
try{
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
429
|
+
const ownerIdentity=await currentEpochOwnerIdentity();
|
|
430
|
+
const lockBytes=Buffer.from(canonicalJson({schema:'deadbyte.authority-epoch-lock.r65.v1',pid:process.pid,token,
|
|
431
|
+
created_at_utc:new Date().toISOString(),...(ownerIdentity?{owner_identity:ownerIdentity}:{})}),'utf8');
|
|
432
|
+
let nativeLease=null;
|
|
433
|
+
if(process.platform==='win32'){
|
|
434
|
+
const {acquireHeldEpochLock}=await import('./epoch-lock-r85.mjs');
|
|
435
|
+
nativeLease=await acquireHeldEpochLock(lockPath,lockBytes);
|
|
436
|
+
if(!nativeLease)throw Object.assign(new Error('authority epoch lock exists'),{code:'EEXIST'});
|
|
437
|
+
}else throw new Error('authority epoch POSIX mutation unsupported');
|
|
438
|
+
const verifyOwned=async()=>{
|
|
439
|
+
// On Windows the in-process native handle holds the leaf and every
|
|
440
|
+
// non-reparse ancestor through the mutation. No pathname recheck can
|
|
441
|
+
// improve that atomic ownership guarantee.
|
|
442
|
+
if(!nativeLease)throw new Error('authority epoch lock ownership unverified');
|
|
443
|
+
return null;
|
|
299
444
|
};
|
|
445
|
+
const release=async()=>{
|
|
446
|
+
if(nativeLease){
|
|
447
|
+
let stageError=null;
|
|
448
|
+
try{await stage('release_before_cas');}catch(error){stageError=error;}
|
|
449
|
+
let releaseError=null;
|
|
450
|
+
try{await nativeLease.release();}catch(error){releaseError=error;}
|
|
451
|
+
if(stageError&&releaseError)throw new AggregateError([stageError,releaseError],
|
|
452
|
+
'authority epoch release observer and native cleanup both failed');
|
|
453
|
+
if(stageError)throw stageError;
|
|
454
|
+
if(releaseError)throw releaseError;
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
throw new Error('authority epoch lock release without native ownership denied');
|
|
458
|
+
};
|
|
459
|
+
release.verifyOwned=verifyOwned;
|
|
460
|
+
return release;
|
|
300
461
|
}catch(error){
|
|
301
462
|
if(error?.code!=='EEXIST')throw error;
|
|
463
|
+
const snapshot=await epochLockSnapshot(lockPath);
|
|
302
464
|
let owner=null;
|
|
303
|
-
try{owner=JSON.parse(
|
|
304
|
-
if(attempt===0&&owner&&Number.isInteger(owner.pid)
|
|
305
|
-
|
|
465
|
+
try{if(snapshot)owner=JSON.parse(snapshot.bytes.toString('utf8'));}catch{}
|
|
466
|
+
if(attempt===0&&allowStaleRecovery&&snapshot&&owner&&Number.isInteger(owner.pid)&&owner.pid>0){
|
|
467
|
+
if(process.platform!=='win32')throw new Error('authority epoch mutation locked (POSIX automatic recovery disabled)');
|
|
468
|
+
const {validForegroundIdentity,foregroundIdentityMatches}=await import('./foreground-process-identity.mjs');
|
|
469
|
+
if(!validForegroundIdentity(owner.owner_identity)||owner.owner_identity.pid!==owner.pid)
|
|
470
|
+
throw new Error('authority epoch mutation locked (owner identity unverified)');
|
|
471
|
+
const observed=await windowsEpochOwnerIdentity(owner.pid);
|
|
472
|
+
if(observed){
|
|
473
|
+
if(!foregroundIdentityMatches(owner.owner_identity,observed))
|
|
474
|
+
throw new Error('authority epoch mutation locked (owner process birth changed)');
|
|
475
|
+
throw new Error('authority epoch mutation locked (owner still active)');
|
|
476
|
+
}
|
|
477
|
+
await stage('stale_before_cas');
|
|
478
|
+
const disposition=await disposeObservedEpochLock(lockPath,snapshot);
|
|
479
|
+
if(disposition!=='REMOVED')throw new Error(`authority epoch mutation locked (stale owner changed: ${disposition})`);
|
|
306
480
|
continue;
|
|
307
481
|
}
|
|
308
482
|
throw new Error('authority epoch mutation locked');
|
|
@@ -310,12 +484,40 @@ async function acquireEpochLock(dir){
|
|
|
310
484
|
}
|
|
311
485
|
throw new Error('authority epoch mutation locked');
|
|
312
486
|
}
|
|
313
|
-
|
|
487
|
+
const MAX_DURABLE_EPOCH_RECORD_BYTES=1048576;
|
|
488
|
+
|
|
489
|
+
async function readBoundedEpochHandle(handle,label){
|
|
490
|
+
const stat=await handle.stat();
|
|
491
|
+
if(!stat.isFile()||!Number.isSafeInteger(stat.size)||stat.size>MAX_DURABLE_EPOCH_RECORD_BYTES)
|
|
492
|
+
throw new Error(`authority epoch ${label} oversized`);
|
|
493
|
+
// Read at most the observed size plus one byte. A concurrent extension
|
|
494
|
+
// cannot turn this into an unbounded read, and is rejected rather than
|
|
495
|
+
// silently truncating the record that will be hashed or parsed.
|
|
496
|
+
const bytes=Buffer.alloc(stat.size+1);
|
|
497
|
+
let used=0;
|
|
498
|
+
while(used<bytes.length){
|
|
499
|
+
const {bytesRead}=await handle.read(bytes,used,bytes.length-used,used);
|
|
500
|
+
if(bytesRead===0)break;
|
|
501
|
+
used+=bytesRead;
|
|
502
|
+
}
|
|
503
|
+
if(used!==stat.size)throw new Error(`authority epoch ${label} changed during read`);
|
|
504
|
+
return bytes.subarray(0,used);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function readBoundedEpochRecord(file,label){
|
|
508
|
+
const handle=await open(file,'r');
|
|
509
|
+
try{
|
|
510
|
+
return await readBoundedEpochHandle(handle,label);
|
|
511
|
+
}finally{await handle.close();}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function readDurableHead(runtimeDir,plane,{recoveryOrphanFile=null}={}){
|
|
314
515
|
const dir=durableEpochDir(runtimeDir,plane),headPath=path.join(dir,'head.json');
|
|
315
516
|
let head;
|
|
316
|
-
try{head=JSON.parse(await
|
|
517
|
+
try{head=JSON.parse(decodeUtf8Strict(await readBoundedEpochRecord(headPath,'head')));}
|
|
317
518
|
catch(error){
|
|
318
519
|
if(error?.code==='ENOENT')return null;
|
|
520
|
+
if(String(error?.message??'').includes('authority epoch head oversized'))throw error;
|
|
319
521
|
throw new Error('authority epoch head invalid');
|
|
320
522
|
}
|
|
321
523
|
if(!plain(head)||head.schema!==DURABLE_HEAD_SCHEMA||head.plane!==plane||
|
|
@@ -334,7 +536,8 @@ async function readDurableHead(runtimeDir,plane){
|
|
|
334
536
|
// the head. The head remains freshly read on every authority decision.
|
|
335
537
|
const [eventNames,eventRead,keys]=await Promise.all([
|
|
336
538
|
readdir(path.join(dir,'events')),
|
|
337
|
-
|
|
539
|
+
readBoundedEpochRecord(path.join(dir,'events',head.event_file),'event')
|
|
540
|
+
.then(bytes=>({bytes}),error=>({error})),
|
|
338
541
|
durableEpochKeys(runtimeDir)
|
|
339
542
|
]);
|
|
340
543
|
if(eventNames.length>10000)throw new Error('authority epoch event budget exceeded');
|
|
@@ -342,11 +545,14 @@ async function readDurableHead(runtimeDir,plane){
|
|
|
342
545
|
const match=/^(\d{16})-([0-9a-f]{64})\.json$/.exec(name);
|
|
343
546
|
if(!match)throw new Error('authority epoch unexpected event file');
|
|
344
547
|
const seq=Number(match[1]);
|
|
345
|
-
if(!Number.isSafeInteger(seq)||seq>head.seq||(seq===head.seq&&name!==head.event_file))
|
|
548
|
+
if(!Number.isSafeInteger(seq)||(seq>head.seq&&name!==recoveryOrphanFile)||(seq===head.seq&&name!==head.event_file))
|
|
346
549
|
throw new Error('authority epoch head replay or incomplete write');
|
|
347
550
|
}
|
|
551
|
+
if(eventRead.error){
|
|
552
|
+
if(eventRead.error.code==='ENOENT')throw new Error('authority epoch event missing');
|
|
553
|
+
throw eventRead.error;
|
|
554
|
+
}
|
|
348
555
|
const eventBytes=eventRead.bytes;
|
|
349
|
-
if(!eventBytes)throw new Error('authority epoch event missing');
|
|
350
556
|
if(hashBytes(eventBytes)!==head.event_sha256)throw new Error('authority epoch event hash mismatch');
|
|
351
557
|
let event;
|
|
352
558
|
try{event=JSON.parse(decodeUtf8Strict(eventBytes));}catch{throw new Error('authority epoch event invalid');}
|
|
@@ -363,30 +569,223 @@ async function readDurableHead(runtimeDir,plane){
|
|
|
363
569
|
(!SHAPAT.test(String(head.policy_sha256))||!SHAPAT.test(String(head.policy_envelope_sha256))||
|
|
364
570
|
head.policy_sha256!==event.policy_sha256||head.policy_envelope_sha256!==event.policy_envelope_sha256))
|
|
365
571
|
throw new Error('authority epoch policy binding invalid');
|
|
366
|
-
if(head.seq===1
|
|
572
|
+
if(head.seq===1){
|
|
573
|
+
if(head.previous_event_sha256!==null)throw new Error('authority epoch genesis chain invalid');
|
|
574
|
+
assertEpochTransition(head,null);
|
|
575
|
+
}
|
|
367
576
|
if(head.seq>1){
|
|
368
577
|
if(!SHAPAT.test(String(head.previous_event_sha256)))throw new Error('authority epoch predecessor missing');
|
|
369
578
|
const priorName=durableEventName(head.seq-1,head.previous_event_sha256);
|
|
370
579
|
let priorBytes;
|
|
371
|
-
try{priorBytes=await
|
|
372
|
-
catch
|
|
580
|
+
try{priorBytes=await readBoundedEpochRecord(path.join(dir,'events',priorName),'predecessor event');}
|
|
581
|
+
catch(error){
|
|
582
|
+
if(error?.code==='ENOENT')throw new Error('authority epoch predecessor event missing');
|
|
583
|
+
throw error;
|
|
584
|
+
}
|
|
373
585
|
if(hashBytes(priorBytes)!==head.previous_event_sha256)throw new Error('authority epoch predecessor hash mismatch');
|
|
374
586
|
let prior;
|
|
375
587
|
try{prior=JSON.parse(decodeUtf8Strict(priorBytes));}catch{throw new Error('authority epoch predecessor invalid');}
|
|
376
588
|
if(!verifyDurableEvent(prior,publicKey,keyId)||prior.seq!==head.seq-1||prior.plane!==plane)
|
|
377
589
|
throw new Error('authority epoch predecessor signature invalid');
|
|
590
|
+
assertEpochPredecessorIdentity(prior);
|
|
591
|
+
if(prior.seq===1)assertEpochTransition(prior,null);
|
|
592
|
+
assertEpochTransition(head,prior);
|
|
378
593
|
}
|
|
379
594
|
return Object.freeze({...head});
|
|
380
595
|
}
|
|
381
596
|
|
|
382
|
-
|
|
597
|
+
// Deliberately separate from the per-request authority decision. This is an
|
|
598
|
+
// independent, bounded audit of the complete signed chain, including events
|
|
599
|
+
// older than the immediate predecessor checked by readDurableHead().
|
|
600
|
+
async function auditDurableHistory(runtimeDir,plane){
|
|
601
|
+
const dir=durableEpochDir(runtimeDir,plane),events=path.join(dir,'events');
|
|
602
|
+
const head=await readDurableHead(runtimeDir,plane);
|
|
603
|
+
if(!head){
|
|
604
|
+
let names;
|
|
605
|
+
try{names=await readdir(events);}catch(error){if(error?.code==='ENOENT')return Object.freeze({status:'EMPTY',plane,verified_events:0});throw error;}
|
|
606
|
+
if(names.length)throw new Error('authority epoch history events exist without head');
|
|
607
|
+
return Object.freeze({status:'EMPTY',plane,verified_events:0});
|
|
608
|
+
}
|
|
609
|
+
if(head.seq>10000)throw new Error('authority epoch history event budget exceeded');
|
|
610
|
+
const headPath=path.join(dir,'head.json'),headBytes=await readBoundedEpochRecord(headPath,'head');
|
|
611
|
+
let pinnedHead;
|
|
612
|
+
try{pinnedHead=JSON.parse(decodeUtf8Strict(headBytes));}catch{throw new Error('authority epoch history head changed');}
|
|
613
|
+
if(canonicalJson(pinnedHead)!==canonicalJson(head))throw new Error('authority epoch history head changed');
|
|
614
|
+
const names=await readdir(events);
|
|
615
|
+
if(names.length!==head.seq)throw new Error('authority epoch history event count mismatch');
|
|
616
|
+
const namesSet=new Set(names);
|
|
617
|
+
const {publicKey,keyId}=await durableEpochKeys(runtimeDir);
|
|
618
|
+
let expectedSha=head.event_sha256,totalBytes=0,newer=null;
|
|
619
|
+
for(let seq=head.seq;seq>=1;seq--){
|
|
620
|
+
const name=durableEventName(seq,expectedSha);
|
|
621
|
+
if(!namesSet.has(name))throw new Error('authority epoch history event missing');
|
|
622
|
+
const file=path.join(events,name),st=await lstat(file);
|
|
623
|
+
if(!st.isFile()||st.isSymbolicLink()||st.size>1048576)
|
|
624
|
+
throw new Error('authority epoch history event identity invalid');
|
|
625
|
+
totalBytes+=st.size;
|
|
626
|
+
if(totalBytes>67108864)throw new Error('authority epoch history byte budget exceeded');
|
|
627
|
+
const bytes=await readBoundedEpochRecord(file,'event');
|
|
628
|
+
if(hashBytes(bytes)!==expectedSha)throw new Error('authority epoch history hash mismatch');
|
|
629
|
+
let event;
|
|
630
|
+
try{event=JSON.parse(decodeUtf8Strict(bytes));}catch{throw new Error('authority epoch history event invalid');}
|
|
631
|
+
if(!verifyDurableEvent(event,publicKey,keyId)||event.schema!==DURABLE_EPOCH_SCHEMA||
|
|
632
|
+
event.plane!==plane||event.seq!==seq||!DURABLE_EPOCH_ACTIONS.has(event.action)||
|
|
633
|
+
!SHAPAT.test(String(event.grant_sha256))||!SHAPAT.test(String(event.grant_instance_nonce)))
|
|
634
|
+
throw new Error('authority epoch history event signature or identity invalid');
|
|
635
|
+
normalizeRestoreBinding(event.restore_binding,{required:event.action==='transaction_restore'});
|
|
636
|
+
if((event.action.startsWith('transaction_')?!/^[A-Za-z0-9._-]{1,96}$/.test(String(event.update_id??'')):event.update_id!==null))
|
|
637
|
+
throw new Error('authority epoch history update identity invalid');
|
|
638
|
+
if(event.policy_sha256!==undefined||event.policy_envelope_sha256!==undefined){
|
|
639
|
+
if(!SHAPAT.test(String(event.policy_sha256))||!SHAPAT.test(String(event.policy_envelope_sha256)))
|
|
640
|
+
throw new Error('authority epoch history policy binding invalid');
|
|
641
|
+
}
|
|
642
|
+
if(seq===1){
|
|
643
|
+
if(event.previous_event_sha256!==null)throw new Error('authority epoch history genesis chain invalid');
|
|
644
|
+
}else if(!SHAPAT.test(String(event.previous_event_sha256)))
|
|
645
|
+
throw new Error('authority epoch history predecessor missing');
|
|
646
|
+
if(newer)assertEpochTransition(newer,event);
|
|
647
|
+
else if(seq===1)assertEpochTransition(event,null);
|
|
648
|
+
newer=event;
|
|
649
|
+
expectedSha=event.previous_event_sha256;
|
|
650
|
+
}
|
|
651
|
+
if(newer&&head.seq>1)assertEpochTransition(newer,null);
|
|
652
|
+
if(!(await readBoundedEpochRecord(headPath,'head')).equals(headBytes))throw new Error('authority epoch history head changed during audit');
|
|
653
|
+
if((await durableEpochKeys(runtimeDir)).keyId!==keyId)throw new Error('authority epoch history trust changed during audit');
|
|
654
|
+
return Object.freeze({status:'PASS',plane,seq:head.seq,event_sha256:head.event_sha256,
|
|
655
|
+
verified_events:head.seq,verified_bytes:totalBytes});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// R79: An orphan event is not a committed head. Ordinary reads, verification
|
|
659
|
+
// and appends MUST keep rejecting it. Only explicit revocation-only recovery
|
|
660
|
+
// can use the bounded exception to inspect an older, still-signed head.
|
|
661
|
+
async function inspectOrphanRevocation(runtimeDir,plane,{expectedHeadSha256,expectedOrphanSha256}){
|
|
662
|
+
if(!SCHEMAS[plane]||!SHAPAT.test(String(expectedHeadSha256))||!SHAPAT.test(String(expectedOrphanSha256)))
|
|
663
|
+
throw new Error('authority epoch recovery requires exact signed event identities');
|
|
664
|
+
const dir=durableEpochDir(runtimeDir,plane),events=path.join(dir,'events');
|
|
665
|
+
// Reject known static reparse/junction boundaries. Handle-pinned directory
|
|
666
|
+
// protection for concurrent substitutions is a separate Windows requirement.
|
|
667
|
+
for(const parent of [path.resolve(runtimeDir),path.join(path.resolve(runtimeDir),'authority-epochs'),dir,events]){
|
|
668
|
+
const st=await lstat(parent);
|
|
669
|
+
if(!st.isDirectory()||st.isSymbolicLink())throw new Error('authority epoch recovery directory identity invalid');
|
|
670
|
+
}
|
|
671
|
+
const headPath=path.join(dir,'head.json');
|
|
672
|
+
const headStat=await lstat(headPath);
|
|
673
|
+
if(!headStat.isFile()||headStat.isSymbolicLink())throw new Error('authority epoch recovery head identity invalid');
|
|
674
|
+
const rawHead=await readBoundedEpochRecord(headPath,'head');
|
|
675
|
+
let untrusted;
|
|
676
|
+
try{untrusted=JSON.parse(decodeUtf8Strict(rawHead));}catch{throw new Error('authority epoch recovery head invalid');}
|
|
677
|
+
if(!Number.isSafeInteger(untrusted?.seq)||untrusted.seq<1)
|
|
678
|
+
throw new Error('authority epoch recovery head invalid');
|
|
679
|
+
const names=await readdir(events);
|
|
680
|
+
// Do not accept more than one event beyond the old head, even if signed.
|
|
681
|
+
const orphanNames=names.filter(name=>{
|
|
682
|
+
const m=/^(\d{16})-([0-9a-f]{64})\.json$/.exec(name);
|
|
683
|
+
return m&&Number(m[1])>untrusted.seq;
|
|
684
|
+
});
|
|
685
|
+
if(orphanNames.length!==1)throw new Error('authority epoch recovery requires exactly one orphan event');
|
|
686
|
+
const orphanFile=orphanNames[0];
|
|
687
|
+
if(orphanFile!==durableEventName(untrusted.seq+1,expectedOrphanSha256))
|
|
688
|
+
throw new Error('authority epoch recovery orphan identity mismatch');
|
|
689
|
+
const previous=await readDurableHead(runtimeDir,plane,{recoveryOrphanFile:orphanFile});
|
|
690
|
+
if(!previous||previous.event_sha256!==expectedHeadSha256||previous.seq!==untrusted.seq)
|
|
691
|
+
throw new Error('authority epoch recovery previous head mismatch');
|
|
692
|
+
if(!['grant','transaction_restore','transaction_revoke'].includes(previous.action))
|
|
693
|
+
throw new Error('authority epoch recovery previous action invalid');
|
|
694
|
+
const orphanPath=path.join(events,orphanFile);
|
|
695
|
+
const orphanStat=await lstat(orphanPath);
|
|
696
|
+
if(!orphanStat.isFile()||orphanStat.isSymbolicLink())throw new Error('authority epoch recovery orphan file identity invalid');
|
|
697
|
+
const orphanBytes=await readBoundedEpochRecord(orphanPath,'event');
|
|
698
|
+
if(hashBytes(orphanBytes)!==expectedOrphanSha256)
|
|
699
|
+
throw new Error('authority epoch recovery orphan bytes mismatch');
|
|
700
|
+
let event;
|
|
701
|
+
try{event=JSON.parse(decodeUtf8Strict(orphanBytes));}catch{throw new Error('authority epoch recovery orphan invalid');}
|
|
702
|
+
const {publicKey,keyId}=await durableEpochKeys(runtimeDir);
|
|
703
|
+
if(!verifyDurableEvent(event,publicKey,keyId))
|
|
704
|
+
throw new Error('authority epoch recovery orphan signature invalid');
|
|
705
|
+
if(event.schema!==DURABLE_EPOCH_SCHEMA||event.plane!==plane||event.seq!==previous.seq+1||
|
|
706
|
+
!['transaction_revoke','user_revoke'].includes(event.action)||
|
|
707
|
+
(event.action==='transaction_revoke'&&!/^[A-Za-z0-9._-]{1,96}$/.test(String(event.update_id??'')))||
|
|
708
|
+
(event.action==='user_revoke'&&event.update_id!==null)||
|
|
709
|
+
event.previous_event_sha256!==previous.event_sha256||
|
|
710
|
+
event.grant_sha256!==previous.grant_sha256||event.grant_instance_nonce!==previous.grant_instance_nonce||
|
|
711
|
+
event.policy_sha256!==previous.policy_sha256||event.policy_envelope_sha256!==previous.policy_envelope_sha256||
|
|
712
|
+
event.restore_binding!==null||
|
|
713
|
+
(event.action==='transaction_revoke'&&!['grant','transaction_restore'].includes(previous.action)))
|
|
714
|
+
throw new Error('authority epoch recovery orphan revocation binding invalid');
|
|
715
|
+
// Head cannot be promoted unless it is still exactly the same committed
|
|
716
|
+
// predecessor. No authority-creating action is ever eligible.
|
|
717
|
+
const replay=await readBoundedEpochRecord(headPath,'head');
|
|
718
|
+
if(!replay.equals(rawHead))throw new Error('authority epoch recovery head changed');
|
|
719
|
+
const newHead={schema:DURABLE_HEAD_SCHEMA,plane,seq:event.seq,action:event.action,
|
|
720
|
+
update_id:event.update_id,grant_sha256:event.grant_sha256,
|
|
721
|
+
grant_instance_nonce:event.grant_instance_nonce,restore_binding:null,
|
|
722
|
+
...(event.policy_sha256===undefined?{}:{policy_sha256:event.policy_sha256,policy_envelope_sha256:event.policy_envelope_sha256}),
|
|
723
|
+
previous_event_sha256:event.previous_event_sha256,event_sha256:expectedOrphanSha256,event_file:orphanFile};
|
|
724
|
+
return Object.freeze({previous,newHead,headBytes:rawHead,orphanBytes});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function createAuthorityEpochStore({runtimeDir,policyBytes=null,onDurabilityStage=null,onLockDispositionStage=null}={}){
|
|
383
728
|
const root=path.resolve(String(runtimeDir??''));
|
|
384
729
|
if(!runtimeDir)throw new Error('authority epoch runtime directory invalid');
|
|
385
730
|
if(policyBytes!==null&&!Buffer.isBuffer(policyBytes))throw new Error('authority epoch policy bytes invalid');
|
|
731
|
+
if(onDurabilityStage!==null&&(typeof onDurabilityStage!=='function'||process.env.NODE_TEST_CONTEXT!=='child-v8'))
|
|
732
|
+
throw new Error('authority epoch stage observer is restricted to node test workers');
|
|
733
|
+
if(onLockDispositionStage!==null&&(typeof onLockDispositionStage!=='function'||process.env.NODE_TEST_CONTEXT!=='child-v8'))
|
|
734
|
+
throw new Error('authority epoch lock observer is restricted to node test workers');
|
|
735
|
+
// Node-test-worker-only fault observer. It can abort, never bypass, flush/order checks.
|
|
736
|
+
const stage=async name=>{if(onDurabilityStage)await onDurabilityStage(name);};
|
|
386
737
|
return Object.freeze({
|
|
387
738
|
async readHead({plane}){
|
|
388
739
|
return readDurableHead(root,plane);
|
|
389
740
|
},
|
|
741
|
+
async auditHistory({plane}){
|
|
742
|
+
return auditDurableHistory(root,plane);
|
|
743
|
+
},
|
|
744
|
+
async reconcileOrphanedRevocation({plane,expectedHeadSha256,expectedOrphanSha256,operatorConfirmed=false}){
|
|
745
|
+
// NOT called from grant verification, append, startup or automatic
|
|
746
|
+
// recovery. The operator must supply two observed event identities.
|
|
747
|
+
if(operatorConfirmed!==true)throw new Error('authority epoch recovery requires explicit operator confirmation');
|
|
748
|
+
const dir=durableEpochDir(root,plane);
|
|
749
|
+
// Reject wrong or stale operator evidence before even reclaiming a dead
|
|
750
|
+
// owner's lock. This is a read-only preflight, not an ownership proof;
|
|
751
|
+
// the same exact evidence is independently rechecked under the lock.
|
|
752
|
+
await inspectOrphanRevocation(root,plane,{expectedHeadSha256,expectedOrphanSha256});
|
|
753
|
+
// Explicit, identity-bound revocation reconciliation may reclaim a
|
|
754
|
+
// verified dead owner's lock through the same native stale-owner CAS as
|
|
755
|
+
// append. A live or unverified owner still fails closed. The orphan
|
|
756
|
+
// itself is inspected only after exclusive ownership is established.
|
|
757
|
+
const release=await acquireEpochLock(dir,{allowStaleRecovery:true,onLockDispositionStage});
|
|
758
|
+
try{
|
|
759
|
+
const {newHead,headBytes,orphanBytes}=await inspectOrphanRevocation(root,plane,{expectedHeadSha256,expectedOrphanSha256});
|
|
760
|
+
// Windows FlushFileBuffers requires a writable file handle. The old
|
|
761
|
+
// read-only ('r') handle returned EPERM on Windows even for a valid,
|
|
762
|
+
// signed revocation. 'r+' grants the handle sync rights; we do not
|
|
763
|
+
// write event bytes. Revalidate exact bytes through this same handle
|
|
764
|
+
// before syncing, refusing a replaced or modified orphan event.
|
|
765
|
+
// Directory/power-loss durability is NOT proven here.
|
|
766
|
+
const fh=await open(path.join(dir,'events',newHead.event_file),'r+');
|
|
767
|
+
try{
|
|
768
|
+
const st=await fh.stat();
|
|
769
|
+
if(!st.isFile()||st.size!==orphanBytes.length)
|
|
770
|
+
throw new Error('authority epoch recovery pinned orphan identity invalid');
|
|
771
|
+
const pinnedBytes=await readBoundedEpochHandle(fh,'event');
|
|
772
|
+
if(!pinnedBytes.equals(orphanBytes)||hashBytes(pinnedBytes)!==expectedOrphanSha256)
|
|
773
|
+
throw new Error('authority epoch recovery orphan changed before sync');
|
|
774
|
+
await fh.sync();
|
|
775
|
+
}finally{await fh.close();}
|
|
776
|
+
if(!(await readBoundedEpochRecord(path.join(dir,'head.json'),'head')).equals(headBytes))
|
|
777
|
+
throw new Error('authority epoch recovery head changed before commit');
|
|
778
|
+
await release.verifyOwned();
|
|
779
|
+
await atomicJsonWrite(path.join(dir,'head.json'),newHead,{stage});
|
|
780
|
+
const committed=await readDurableHead(root,plane);
|
|
781
|
+
if(committed.event_sha256!==expectedOrphanSha256||
|
|
782
|
+
!['transaction_revoke','user_revoke'].includes(committed.action))
|
|
783
|
+
throw new Error('authority epoch recovered head re-verification failed');
|
|
784
|
+
return Object.freeze({status:'REVOKED_ONLY_RECONCILED',plane,seq:committed.seq,
|
|
785
|
+
action:committed.action,event_sha256:committed.event_sha256,
|
|
786
|
+
standing_grant_authorized:false});
|
|
787
|
+
}finally{await release();}
|
|
788
|
+
},
|
|
390
789
|
async append({plane,action,grantBytes,grantInstanceNonce,updateId=null,expectedHeadSha256=undefined,restoreBinding=null}){
|
|
391
790
|
const transactional=action?.startsWith('transaction_')===true;
|
|
392
791
|
if(!SCHEMAS[plane]||!DURABLE_EPOCH_ACTIONS.has(action)||!Buffer.isBuffer(grantBytes)||
|
|
@@ -409,8 +808,13 @@ export function createAuthorityEpochStore({runtimeDir,policyBytes=null}={}){
|
|
|
409
808
|
if(parsedGrant?.policy_sha256!==policySha256)throw new Error('authority epoch grant policy digest mismatch');
|
|
410
809
|
policyBinding={policy_sha256:policySha256,policy_envelope_sha256:deriveSecurityEnvelope(plane,parsedPolicy).sha256};
|
|
411
810
|
}
|
|
811
|
+
// A mismatched successor envelope is known before acquiring a native
|
|
812
|
+
// lock or writing any signed event. Refuse it without side effects.
|
|
813
|
+
if(action==='transaction_restore'&&policyBinding?.policy_envelope_sha256!==undefined&&
|
|
814
|
+
normalizedRestoreBinding.successor_policy_envelope_sha256!==policyBinding.policy_envelope_sha256)
|
|
815
|
+
throw new Error('authority epoch transaction restore successor policy envelope mismatch');
|
|
412
816
|
const dir=durableEpochDir(root,plane),events=path.join(dir,'events');
|
|
413
|
-
const release=await acquireEpochLock(dir);
|
|
817
|
+
const release=await acquireEpochLock(dir,{onLockDispositionStage});
|
|
414
818
|
try{
|
|
415
819
|
await mkdir(events,{recursive:true});
|
|
416
820
|
const previous=await readDurableHead(root,plane);
|
|
@@ -433,6 +837,12 @@ export function createAuthorityEpochStore({runtimeDir,policyBytes=null}={}){
|
|
|
433
837
|
throw new Error('authority epoch transaction restore predecessor grant binding mismatch');
|
|
434
838
|
if(previous.grant_instance_nonce===grantInstanceNonce)
|
|
435
839
|
throw new Error('authority epoch transaction restore requires fresh nonce; revoked nonce cannot be resurrected');
|
|
840
|
+
if(previous.policy_envelope_sha256!==undefined&&
|
|
841
|
+
normalizedRestoreBinding.predecessor_policy_envelope_sha256!==previous.policy_envelope_sha256)
|
|
842
|
+
throw new Error('authority epoch transaction restore predecessor policy envelope mismatch');
|
|
843
|
+
if(policyBinding?.policy_envelope_sha256!==undefined&&
|
|
844
|
+
normalizedRestoreBinding.successor_policy_envelope_sha256!==policyBinding.policy_envelope_sha256)
|
|
845
|
+
throw new Error('authority epoch transaction restore successor policy envelope mismatch');
|
|
436
846
|
}
|
|
437
847
|
if(action==='grant'&&previous?.grant_instance_nonce===grantInstanceNonce)
|
|
438
848
|
throw new Error('authority epoch grant requires fresh nonce; prior nonce cannot be resurrected');
|
|
@@ -445,12 +855,15 @@ export function createAuthorityEpochStore({runtimeDir,policyBytes=null}={}){
|
|
|
445
855
|
const event={...body,attestation:{algorithm:'ed25519',domain:'DEADBYTE-AUTHORITY-DURABLE-EPOCH-R65-V1',key_id:keyId,signature_base64:signature.toString('base64')}};
|
|
446
856
|
const eventBytes=Buffer.from(canonicalJson(event),'utf8'),eventSha256=hashBytes(eventBytes);
|
|
447
857
|
const eventFile=durableEventName(seq,eventSha256);
|
|
448
|
-
await
|
|
858
|
+
await writeDurableEvent(path.join(events,eventFile),eventBytes,{stage});
|
|
449
859
|
const head={schema:DURABLE_HEAD_SCHEMA,plane,seq,action,update_id:updateId,
|
|
450
860
|
grant_sha256:grantSha256,grant_instance_nonce:grantInstanceNonce,restore_binding:normalizedRestoreBinding,
|
|
451
861
|
...(policyBinding??{}),
|
|
452
862
|
previous_event_sha256:event.previous_event_sha256,event_sha256:eventSha256,event_file:eventFile};
|
|
453
|
-
|
|
863
|
+
// A replaced lock observed before publication must not authorize a
|
|
864
|
+
// head commit. This narrows the window but is not a held-handle lock.
|
|
865
|
+
await release.verifyOwned();
|
|
866
|
+
await atomicJsonWrite(path.join(dir,'head.json'),head,{stage});
|
|
454
867
|
return Object.freeze({...head});
|
|
455
868
|
}finally{await release();}
|
|
456
869
|
},
|