deadbyte-mcp 0.15.46 → 0.15.48
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 +97 -45
- package/PROVENANCE.json +22 -11
- package/README.txt +18 -18
- package/RELEASE.json +378 -369
- package/SBOM.cdx.json +2 -2
- package/TEST-MATRIX.json +2 -2
- package/bin/deadbyte-epoch-lock-r85.node +0 -0
- package/bin/deadbyte-kernel-lock-r74.exe +0 -0
- package/bridge/package.json +1 -1
- package/controller/deadbyte-process-policy.json +7 -7
- package/docs/R73-CAPABILITY-GAP-MATRIX.json +17 -0
- package/docs/R73-EXECUTION-CONTRACT.md +11 -0
- package/docs/R73-IMPLEMENTATION-CHANGELOG.md +12 -0
- package/docs/R73-REMAINING-DEFECTS.md +9 -0
- package/docs/R74-KERNEL-LOCK-BINARY.json +11 -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/native/deadbyte-kernel-lock-r74.cpp +65 -0
- package/package.json +1 -1
- package/scripts/build-epoch-lock-r85.ps1 +34 -0
- package/scripts/build-kernel-lock-r74.ps1 +28 -0
- package/scripts/deploy-r48.mjs +3 -1
- package/scripts/gate-windows-r72.ps1 +2 -2
- package/scripts/npm-prepublish-contract-r74.mjs +113 -0
- package/scripts/npm-release-r48.mjs +24 -4
- package/scripts/npm-release-readiness-r108.mjs +134 -0
- package/scripts/npm-release-readiness-r74.mjs +46 -0
- package/scripts/r73-atomic-benchmark.mjs +39 -0
- package/scripts/r75-npm-bin-integrity-gate.mjs +33 -0
- package/scripts/release-manifest.mjs +1 -1
- 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 -4
- package/src/activation-preflight-r65.mjs +13 -0
- package/src/atomic-mutation.mjs +99 -9
- package/src/authority-continuity-r65.mjs +265 -21
- package/src/deadbyte-cli.mjs +55 -18
- 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/kernel-lock-r74.mjs +125 -0
- package/src/privileged-install-trust-r89.mjs +196 -0
- package/src/remote-session-guardian.mjs +9 -7
- package/src/runtime-update.mjs +5 -22
- package/src/version.mjs +1 -1
- package/src/windows-powershell-environment.mjs +24 -0
- package/test/atomic-mutation.test.mjs +68 -2
- package/test/cli-entrypoint.test.mjs +1 -1
- package/test/commander-schema-differential-r48.test.mjs +1 -1
- 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/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-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/r73-package-boundary.test.mjs +22 -0
- package/test/r74-kernel-lock.test.mjs +156 -0
- package/test/r74-prepublish-contract.test.mjs +89 -0
- 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
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {createHash, randomBytes} from 'node:crypto';
|
|
2
|
+
import {lstat,readFile} from 'node:fs/promises';
|
|
3
|
+
import {createRequire} from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {fileURLToPath} from 'node:url';
|
|
6
|
+
import {inspectTrustedInstallation,privilegedEpochInstallFiles} from './privileged-install-trust-r89.mjs';
|
|
7
|
+
|
|
8
|
+
const root=path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
9
|
+
const binary=path.join(root,'bin','deadbyte-epoch-lock-r85.node');
|
|
10
|
+
const metadata=path.join(root,'docs','R85-EPOCH-LOCK-BINARY.json');
|
|
11
|
+
const require=createRequire(import.meta.url);
|
|
12
|
+
const hex64=/^[0-9a-f]{64}$/;
|
|
13
|
+
let addon=null;
|
|
14
|
+
|
|
15
|
+
async function loadVerifiedAddon(){
|
|
16
|
+
if(process.platform!=='win32'||process.arch!=='x64')throw new Error('epoch held-handle lock requires Windows x64');
|
|
17
|
+
await inspectTrustedInstallation(privilegedEpochInstallFiles(root,process.execPath));
|
|
18
|
+
const meta=JSON.parse(await readFile(metadata,'utf8'));
|
|
19
|
+
if(meta.schema!=='deadbyte.r85-epoch-lock-binary.v1'||meta.platform!=='win32-x64'||
|
|
20
|
+
meta.addon!=='bin/deadbyte-epoch-lock-r85.node'||meta.source!=='native/deadbyte-epoch-lock-r85.cpp'||
|
|
21
|
+
meta.node_api_version>Number(process.versions.napi)||!hex64.test(meta.addon_sha256)||!hex64.test(meta.source_sha256))
|
|
22
|
+
throw new Error('epoch held-handle binary metadata invalid');
|
|
23
|
+
const [st,bytes,sourceBytes]=await Promise.all([lstat(binary),readFile(binary),readFile(path.join(root,meta.source))]);
|
|
24
|
+
if(!st.isFile()||st.isSymbolicLink()||
|
|
25
|
+
createHash('sha256').update(bytes).digest('hex')!==meta.addon_sha256||
|
|
26
|
+
createHash('sha256').update(sourceBytes).digest('hex')!==meta.source_sha256)
|
|
27
|
+
throw new Error('epoch held-handle binary/source hash mismatch');
|
|
28
|
+
addon??=require(binary);
|
|
29
|
+
if(typeof addon.acquire!=='function'||typeof addon.release!=='function')
|
|
30
|
+
throw new Error('epoch held-handle exports invalid');
|
|
31
|
+
return addon;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function acquireHeldEpochLock(file,bytes){
|
|
35
|
+
if(!Buffer.isBuffer(bytes)||bytes.length<2||bytes.length>16384||typeof file!=='string'||!path.isAbsolute(file))
|
|
36
|
+
throw new Error('epoch held-handle lock arguments invalid');
|
|
37
|
+
const native=await loadVerifiedAddon();
|
|
38
|
+
const handle=native.acquire(file,bytes,randomBytes(16).toString('hex'));
|
|
39
|
+
if(handle===null)return null;
|
|
40
|
+
let released=false;
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
async release(){
|
|
43
|
+
if(released)throw new Error('epoch held-handle lock already released');
|
|
44
|
+
native.release(handle);
|
|
45
|
+
released=true;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// Windows-only, handle-bound conditional deletion. Never use pathname rm after a read.
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {lstat,readFile} from 'node:fs/promises';
|
|
4
|
+
import {spawnSync} from 'node:child_process';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {windowsPowerShellEnvironment} from './windows-powershell-environment.mjs';
|
|
7
|
+
|
|
8
|
+
const HEX64=/^[0-9a-f]{64}$/;
|
|
9
|
+
const digest=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
const sameFile=(a,b)=>a&&b&&a.isFile()&&b.isFile()&&!a.isSymbolicLink()&&!b.isSymbolicLink()&&
|
|
11
|
+
a.dev===b.dev&&a.ino===b.ino&&a.size===b.size&&a.mtimeMs===b.mtimeMs&&a.ctimeMs===b.ctimeMs;
|
|
12
|
+
|
|
13
|
+
export async function readRemoteMarkerSnapshot(file){
|
|
14
|
+
let before;
|
|
15
|
+
try{before=await lstat(file);}catch(error){if(error?.code==='ENOENT')return null;throw error;}
|
|
16
|
+
if(!before.isFile()||before.isSymbolicLink()||before.size>16384||before.size<2)
|
|
17
|
+
throw new Error('remote marker not a bounded regular file');
|
|
18
|
+
const bytes=await readFile(file);
|
|
19
|
+
const after=await lstat(file);
|
|
20
|
+
if(!sameFile(before,after)||bytes.length!==before.size)
|
|
21
|
+
throw new Error('remote marker identity changed during snapshot');
|
|
22
|
+
let marker;
|
|
23
|
+
try{marker=JSON.parse(bytes.toString('utf8').replace(/^\uFEFF/,''));}
|
|
24
|
+
catch{throw new Error('remote marker JSON invalid');}
|
|
25
|
+
return Object.freeze({marker,sha256:digest(bytes)});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Native ancestor handles pin the non-reparse path chain, then a single leaf handle owns read/hash/deletion.
|
|
29
|
+
// Open share=READ only: concurrent replace, rename, delete and writes must fail.
|
|
30
|
+
// Do not replace this with a Node readFile()+rm(), rename(), or link()+unlink().
|
|
31
|
+
const nativeCommand=String.raw`
|
|
32
|
+
$ErrorActionPreference='Stop'
|
|
33
|
+
$source=@'
|
|
34
|
+
using System;
|
|
35
|
+
using System.IO;
|
|
36
|
+
using System.ComponentModel;
|
|
37
|
+
using System.Runtime.InteropServices;
|
|
38
|
+
using System.Security.Cryptography;
|
|
39
|
+
using Microsoft.Win32.SafeHandles;
|
|
40
|
+
using System.Collections.Generic;
|
|
41
|
+
public static class DeadbytePinnedDeleteV2 {
|
|
42
|
+
const uint FILE_READ_DATA = 0x0001;
|
|
43
|
+
const uint FILE_WRITE_DATA = 0x0002;
|
|
44
|
+
const uint FILE_READ_ATTRIBUTES = 0x0080;
|
|
45
|
+
const uint DELETE = 0x00010000;
|
|
46
|
+
const uint FILE_SHARE_READ = 0x00000001;
|
|
47
|
+
const uint OPEN_EXISTING = 3;
|
|
48
|
+
const uint CREATE_NEW = 1;
|
|
49
|
+
const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
|
|
50
|
+
const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
|
|
51
|
+
const uint FILE_ATTRIBUTE_DIRECTORY = 0x00000010;
|
|
52
|
+
const uint FILE_ATTRIBUTE_REPARSE_POINT = 0x00000400;
|
|
53
|
+
[StructLayout(LayoutKind.Sequential)] struct FILE_ATTRIBUTE_TAG_INFO {
|
|
54
|
+
public uint FileAttributes;
|
|
55
|
+
public uint ReparseTag;
|
|
56
|
+
}
|
|
57
|
+
// FileIdInfo = 18, FILE_ID_INFO = 8-byte volume serial + 16-byte file ID.
|
|
58
|
+
// No fallback to filename, hashes, inode heuristics, or legacy 64-bit file index.
|
|
59
|
+
[StructLayout(LayoutKind.Sequential)] struct FILE_ID_INFO {
|
|
60
|
+
public ulong VolumeSerialNumber;
|
|
61
|
+
[MarshalAs(UnmanagedType.ByValArray, SizeConst=16)] public byte[] FileId;
|
|
62
|
+
}
|
|
63
|
+
[DllImport("kernel32.dll",EntryPoint="CreateFileW",CharSet=CharSet.Unicode,SetLastError=true)]
|
|
64
|
+
static extern SafeFileHandle CreateFile(string file,uint access,uint share,IntPtr security,uint creation,uint flags,IntPtr templateFile);
|
|
65
|
+
[DllImport("kernel32.dll",SetLastError=true)]
|
|
66
|
+
static extern bool GetFileInformationByHandleEx(SafeFileHandle file,int cls,out FILE_ATTRIBUTE_TAG_INFO info,uint size);
|
|
67
|
+
[DllImport("kernel32.dll",EntryPoint="GetFileInformationByHandleEx",SetLastError=true)]
|
|
68
|
+
static extern bool GetFileIdInformationByHandleEx(SafeFileHandle file,int cls,out FILE_ID_INFO info,uint size);
|
|
69
|
+
[DllImport("kernel32.dll",SetLastError=true)]
|
|
70
|
+
static extern bool SetFileInformationByHandle(SafeFileHandle file,int cls,IntPtr info,uint size);
|
|
71
|
+
// Pin the ENTIRE local-drive ancestor chain from the drive root to the leaf's parent.
|
|
72
|
+
// Share=READ only denies concurrent write/rename/delete of each pinned component.
|
|
73
|
+
// Checking names separately and closing each handle would reintroduce the race.
|
|
74
|
+
static void PinAncestor(string directory,List<SafeFileHandle> pins) {
|
|
75
|
+
var handle=CreateFile(directory,FILE_READ_ATTRIBUTES,FILE_SHARE_READ,
|
|
76
|
+
IntPtr.Zero,OPEN_EXISTING,FILE_FLAG_BACKUP_SEMANTICS|FILE_FLAG_OPEN_REPARSE_POINT,IntPtr.Zero);
|
|
77
|
+
if(handle.IsInvalid){
|
|
78
|
+
int error=Marshal.GetLastWin32Error();handle.Dispose();
|
|
79
|
+
throw new Win32Exception(error,"cannot pin path ancestor (fail closed)");
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
FILE_ATTRIBUTE_TAG_INFO attr;
|
|
83
|
+
if(!GetFileInformationByHandleEx(handle,9,out attr,(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
|
|
84
|
+
throw new Win32Exception(Marshal.GetLastWin32Error(),"ancestor attributes unavailable");
|
|
85
|
+
if((attr.FileAttributes&FILE_ATTRIBUTE_DIRECTORY)==0 ||
|
|
86
|
+
(attr.FileAttributes&FILE_ATTRIBUTE_REPARSE_POINT)!=0)
|
|
87
|
+
throw new InvalidOperationException("ancestor is a junction, symlink, reparse point or non-directory");
|
|
88
|
+
pins.Add(handle);
|
|
89
|
+
} catch {handle.Dispose();throw;}
|
|
90
|
+
}
|
|
91
|
+
static string FileIdentity(SafeFileHandle handle) {
|
|
92
|
+
FILE_ID_INFO info;
|
|
93
|
+
if(!GetFileIdInformationByHandleEx(handle,18,out info,(uint)Marshal.SizeOf(typeof(FILE_ID_INFO))))
|
|
94
|
+
throw new Win32Exception(Marshal.GetLastWin32Error(),"128-bit native File ID unavailable; fail closed");
|
|
95
|
+
if(info.FileId==null||info.FileId.Length!=16)
|
|
96
|
+
throw new InvalidOperationException("native file identity unavailable");
|
|
97
|
+
bool allZero=true;
|
|
98
|
+
foreach(byte b in info.FileId)if(b!=0){allZero=false;break;}
|
|
99
|
+
if(allZero)throw new InvalidOperationException("zero native File ID; fail closed");
|
|
100
|
+
return info.VolumeSerialNumber.ToString("x16")+":"+BitConverter.ToString(info.FileId).Replace("-","").ToLowerInvariant();
|
|
101
|
+
}
|
|
102
|
+
public static string DeleteIfHash(string file,string expectedSha,string expectedId) {
|
|
103
|
+
return WithPinnedAncestors(file,"delete",expectedSha,expectedId,null);
|
|
104
|
+
}
|
|
105
|
+
public static string Snapshot(string file) {
|
|
106
|
+
return WithPinnedAncestors(file,"snapshot",null,null,null);
|
|
107
|
+
}
|
|
108
|
+
public static string CreateLock(string file,string encodedBytes) {
|
|
109
|
+
return WithPinnedAncestors(file,"create",null,null,encodedBytes);
|
|
110
|
+
}
|
|
111
|
+
static string WithPinnedAncestors(string file,string operation,string expectedSha,string expectedId,string encodedBytes) {
|
|
112
|
+
string full=Path.GetFullPath(file);
|
|
113
|
+
// No UNC paths, device paths, ADS or extended-path aliases in this safety boundary.
|
|
114
|
+
if(full.Length<4 || !Char.IsLetter(full[0]) || full[1]!=':' || full[2]!='\\' ||
|
|
115
|
+
full.IndexOf(':',2)>=0 || String.IsNullOrEmpty(Path.GetFileName(full)))
|
|
116
|
+
throw new InvalidOperationException("pinned deletion requires an ordinary local drive file path");
|
|
117
|
+
string root=Path.GetPathRoot(full);
|
|
118
|
+
string parent=Path.GetDirectoryName(full);
|
|
119
|
+
var pins=new List<SafeFileHandle>();
|
|
120
|
+
try {
|
|
121
|
+
string current=root;
|
|
122
|
+
PinAncestor(current,pins);
|
|
123
|
+
string tail=parent.Substring(root.Length);
|
|
124
|
+
foreach(string component in tail.Split(new[]{'\\'},StringSplitOptions.RemoveEmptyEntries)){
|
|
125
|
+
current=Path.Combine(current,component);
|
|
126
|
+
PinAncestor(current,pins);
|
|
127
|
+
}
|
|
128
|
+
if(operation=="snapshot")return SnapshotPinnedLeaf(full);
|
|
129
|
+
if(operation=="create")return CreatePinnedLeaf(full,encodedBytes);
|
|
130
|
+
return DeletePinnedLeaf(full,expectedSha,expectedId);
|
|
131
|
+
} finally {
|
|
132
|
+
for(int i=pins.Count-1;i>=0;i--)pins[i].Dispose();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
static byte[] ReadBoundedLeaf(SafeFileHandle handle){
|
|
136
|
+
using(var stream=new FileStream(handle,FileAccess.Read)){
|
|
137
|
+
if(stream.Length<2||stream.Length>16384)throw new InvalidOperationException("pinned file size invalid");
|
|
138
|
+
byte[] content=new byte[(int)stream.Length];int index=0,read;
|
|
139
|
+
while(index<content.Length&&(read=stream.Read(content,index,content.Length-index))>0)index+=read;
|
|
140
|
+
if(index!=content.Length)throw new IOException("pinned file truncated");
|
|
141
|
+
return content;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
static void ValidateLeaf(SafeFileHandle handle){
|
|
145
|
+
FILE_ATTRIBUTE_TAG_INFO attr;
|
|
146
|
+
if(!GetFileInformationByHandleEx(handle,9,out attr,(uint)Marshal.SizeOf(typeof(FILE_ATTRIBUTE_TAG_INFO))))
|
|
147
|
+
throw new Win32Exception(Marshal.GetLastWin32Error(),"pinned leaf attributes unavailable");
|
|
148
|
+
if((attr.FileAttributes&(FILE_ATTRIBUTE_DIRECTORY|FILE_ATTRIBUTE_REPARSE_POINT))!=0)
|
|
149
|
+
throw new InvalidOperationException("pinned leaf is a reparse point or directory");
|
|
150
|
+
}
|
|
151
|
+
static string SnapshotPinnedLeaf(string file){
|
|
152
|
+
using(var handle=CreateFile(file,FILE_READ_DATA|FILE_READ_ATTRIBUTES,FILE_SHARE_READ,IntPtr.Zero,OPEN_EXISTING,FILE_FLAG_OPEN_REPARSE_POINT,IntPtr.Zero)){
|
|
153
|
+
if(handle.IsInvalid){
|
|
154
|
+
int error=Marshal.GetLastWin32Error();
|
|
155
|
+
if(error==2||error==3)return "MISSING";
|
|
156
|
+
throw new Win32Exception(error,"epoch lock could not be pinned for identity snapshot");
|
|
157
|
+
}
|
|
158
|
+
ValidateLeaf(handle);
|
|
159
|
+
string id=FileIdentity(handle);
|
|
160
|
+
byte[] bytes=ReadBoundedLeaf(handle);
|
|
161
|
+
string hash=BitConverter.ToString(SHA256.Create().ComputeHash(bytes)).Replace("-","").ToLowerInvariant();
|
|
162
|
+
return "SNAPSHOT|"+id+"|"+hash+"|"+Convert.ToBase64String(bytes);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
static string CreatePinnedLeaf(string file,string encodedBytes){
|
|
166
|
+
byte[] bytes=Convert.FromBase64String(encodedBytes);
|
|
167
|
+
if(bytes.Length<2||bytes.Length>16384)throw new InvalidOperationException("epoch lock bytes invalid");
|
|
168
|
+
using(var handle=CreateFile(file,FILE_READ_DATA|FILE_WRITE_DATA|FILE_READ_ATTRIBUTES,
|
|
169
|
+
FILE_SHARE_READ,IntPtr.Zero,CREATE_NEW,FILE_FLAG_OPEN_REPARSE_POINT,IntPtr.Zero)){
|
|
170
|
+
if(handle.IsInvalid){
|
|
171
|
+
int error=Marshal.GetLastWin32Error();
|
|
172
|
+
if(error==80||error==183)return "EXISTS";
|
|
173
|
+
throw new Win32Exception(error,"epoch lock creation failed closed");
|
|
174
|
+
}
|
|
175
|
+
ValidateLeaf(handle);
|
|
176
|
+
string id=FileIdentity(handle);
|
|
177
|
+
using(var stream=new FileStream(handle,FileAccess.ReadWrite)){
|
|
178
|
+
stream.Write(bytes,0,bytes.Length);
|
|
179
|
+
stream.Flush(true);
|
|
180
|
+
}
|
|
181
|
+
string hash=BitConverter.ToString(SHA256.Create().ComputeHash(bytes)).Replace("-","").ToLowerInvariant();
|
|
182
|
+
return "CREATED|"+id+"|"+hash;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
static string DeletePinnedLeaf(string file,string expectedSha,string expectedId) {
|
|
186
|
+
using(var handle=CreateFile(file,FILE_READ_DATA|FILE_READ_ATTRIBUTES|DELETE,FILE_SHARE_READ,IntPtr.Zero,OPEN_EXISTING,FILE_FLAG_OPEN_REPARSE_POINT,IntPtr.Zero)) {
|
|
187
|
+
if(handle.IsInvalid){
|
|
188
|
+
int error=Marshal.GetLastWin32Error();
|
|
189
|
+
if(error==2||error==3)return "MISSING";
|
|
190
|
+
throw new Win32Exception(error,"remote marker cannot be exclusively pinned");
|
|
191
|
+
}
|
|
192
|
+
ValidateLeaf(handle);
|
|
193
|
+
// PowerShell can marshal $null to an empty string for a C# string parameter.
|
|
194
|
+
// Only epoch-lock callers with an explicit File ID require this comparison.
|
|
195
|
+
string actualId=String.IsNullOrEmpty(expectedId)?null:FileIdentity(handle);
|
|
196
|
+
// Identity must be checked on the SAME handle used for hash and disposition.
|
|
197
|
+
if(!String.IsNullOrEmpty(expectedId)&&!String.Equals(actualId,expectedId,StringComparison.Ordinal))return "CHANGED";
|
|
198
|
+
using(var stream=new FileStream(handle,FileAccess.Read)){
|
|
199
|
+
if(stream.Length<2||stream.Length>16384)throw new InvalidOperationException("pinned file size invalid");
|
|
200
|
+
byte[] content=new byte[(int)stream.Length];int index=0,read;
|
|
201
|
+
while(index<content.Length&&(read=stream.Read(content,index,content.Length-index))>0)index+=read;
|
|
202
|
+
if(index!=content.Length)throw new IOException("pinned file truncated");
|
|
203
|
+
string actual=BitConverter.ToString(SHA256.Create().ComputeHash(content)).Replace("-","").ToLowerInvariant();
|
|
204
|
+
if(!String.Equals(actual,expectedSha,StringComparison.Ordinal))return "CHANGED";
|
|
205
|
+
IntPtr flag=Marshal.AllocHGlobal(4);
|
|
206
|
+
try {
|
|
207
|
+
Marshal.WriteInt32(flag,1);
|
|
208
|
+
if(!SetFileInformationByHandle(handle,4,flag,4))
|
|
209
|
+
throw new Win32Exception(Marshal.GetLastWin32Error(),"pinned file delete disposition denied");
|
|
210
|
+
} finally {Marshal.FreeHGlobal(flag);}
|
|
211
|
+
return "REMOVED";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
'@
|
|
217
|
+
Add-Type -TypeDefinition $source -Language CSharp -ErrorAction Stop | Out-Null
|
|
218
|
+
$file=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($env:DEADBYTE_PINNED_MARKER_B64))
|
|
219
|
+
$sha=$env:DEADBYTE_PINNED_MARKER_SHA
|
|
220
|
+
$id=$env:DEADBYTE_PINNED_MARKER_FILE_ID
|
|
221
|
+
if($env:DEADBYTE_PINNED_MARKER_OP -eq 'snapshot') {
|
|
222
|
+
[Console]::Out.WriteLine([DeadbytePinnedDeleteV2]::Snapshot($file))
|
|
223
|
+
} elseif($env:DEADBYTE_PINNED_MARKER_OP -eq 'create') {
|
|
224
|
+
[Console]::Out.WriteLine([DeadbytePinnedDeleteV2]::CreateLock($file,$env:DEADBYTE_PINNED_MARKER_BYTES_B64))
|
|
225
|
+
} else {
|
|
226
|
+
if([String]::IsNullOrEmpty($id)){$id=$null}
|
|
227
|
+
[Console]::Out.WriteLine([DeadbytePinnedDeleteV2]::DeleteIfHash($file,$sha,$id))
|
|
228
|
+
}
|
|
229
|
+
`;
|
|
230
|
+
|
|
231
|
+
const FILE_ID_PATTERN=/^[0-9a-f]{16}:[0-9a-f]{32}$/;
|
|
232
|
+
function invokePinnedNative(file,{op,sha=null,fileId=null,bytes=null}={}){
|
|
233
|
+
if(process.platform!=='win32')throw new Error('native pinned file I/O is Windows-only');
|
|
234
|
+
if(typeof file!=='string'||!path.isAbsolute(file)||!['snapshot','delete','create'].includes(op)||
|
|
235
|
+
(op==='delete'&&!HEX64.test(sha??''))||
|
|
236
|
+
(op==='create'&&(!Buffer.isBuffer(bytes)||bytes.length<2||bytes.length>16384))||
|
|
237
|
+
(fileId!==null&&!FILE_ID_PATTERN.test(fileId)))
|
|
238
|
+
throw new Error('native pinned file arguments invalid');
|
|
239
|
+
const powershell='C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
240
|
+
const result=spawnSync(powershell,['-NoLogo','-NoProfile','-NonInteractive','-EncodedCommand',
|
|
241
|
+
Buffer.from(nativeCommand,'utf16le').toString('base64')],{
|
|
242
|
+
encoding:'utf8',windowsHide:true,shell:false,timeout:25000,maxBuffer:262144,
|
|
243
|
+
env:windowsPowerShellEnvironment({...process.env,SystemRoot:'C:\\Windows',WINDIR:'C:\\Windows',
|
|
244
|
+
DEADBYTE_PINNED_MARKER_B64:Buffer.from(file,'utf8').toString('base64'),
|
|
245
|
+
DEADBYTE_PINNED_MARKER_SHA:sha??'',
|
|
246
|
+
DEADBYTE_PINNED_MARKER_FILE_ID:fileId??'',
|
|
247
|
+
DEADBYTE_PINNED_MARKER_BYTES_B64:bytes?.toString('base64')??'',
|
|
248
|
+
DEADBYTE_PINNED_MARKER_OP:op})
|
|
249
|
+
});
|
|
250
|
+
if(result.error||result.status!==0)throw new Error('native pinned file I/O failed closed');
|
|
251
|
+
return result.stdout.trim();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Snapshot locks using one native handle for owner bytes, SHA-256, and FileIdInfo.
|
|
255
|
+
// Node lstat dev/ino is insufficient as the sole cross-API identity authority.
|
|
256
|
+
export function readPinnedEpochLockSnapshot(file){
|
|
257
|
+
const result=invokePinnedNative(file,{op:'snapshot'});
|
|
258
|
+
if(result==='MISSING')return null;
|
|
259
|
+
const match=/^SNAPSHOT\|([0-9a-f]{16}:[0-9a-f]{32})\|([0-9a-f]{64})\|([A-Za-z0-9+/]+={0,2})$/.exec(result);
|
|
260
|
+
if(!match)throw new Error('native pinned epoch lock snapshot malformed');
|
|
261
|
+
const bytes=Buffer.from(match[3],'base64');
|
|
262
|
+
if(bytes.length<2||bytes.length>16384||bytes.toString('base64')!==match[3]||digest(bytes)!==match[2])
|
|
263
|
+
throw new Error('native pinned epoch lock snapshot content mismatch');
|
|
264
|
+
return Object.freeze({bytes,sha256:match[2],fileId:match[1]});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Creation is pinned under the same non-reparse ancestor chain. The returned
|
|
268
|
+
// File ID belongs to the handle that wrote and flushed the original lock bytes.
|
|
269
|
+
export function createPinnedEpochLock(file,bytes){
|
|
270
|
+
const result=invokePinnedNative(file,{op:'create',bytes});
|
|
271
|
+
if(result==='EXISTS')return null;
|
|
272
|
+
const match=/^CREATED\|([0-9a-f]{16}:[0-9a-f]{32})\|([0-9a-f]{64})$/.exec(result);
|
|
273
|
+
if(!match||digest(bytes)!==match[2])throw new Error('native epoch lock creation identity invalid');
|
|
274
|
+
return Object.freeze({fileId:match[1],sha256:match[2]});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Existing remote-marker API remains content-CAS for backward compatibility.
|
|
278
|
+
// Epoch locks MUST pass an expected FileIdInfo captured from a native snapshot.
|
|
279
|
+
export function deletePinnedRemoteMarker(file,expectedSha256,options={}){
|
|
280
|
+
const expectedFileId=options?.expectedFileId??null;
|
|
281
|
+
const answer=invokePinnedNative(file,{op:'delete',sha:expectedSha256,fileId:expectedFileId});
|
|
282
|
+
if(!['REMOVED','MISSING','CHANGED'].includes(answer))throw new Error('handle-bound deletion result invalid');
|
|
283
|
+
return answer;
|
|
284
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {spawnSync} from 'node:child_process';
|
|
2
2
|
import {createHash} from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import {windowsPowerShellEnvironment} from './
|
|
4
|
+
import {windowsPowerShellEnvironment} from './windows-powershell-environment.mjs';
|
|
5
5
|
|
|
6
6
|
export function validForegroundIdentity(value){
|
|
7
7
|
return value?.schema==='deadbyte.foreground-process.v1'&&Number.isSafeInteger(value.pid)&&value.pid>0&&Number.isSafeInteger(value.parent_pid)&&value.parent_pid>=0&&typeof value.creation_utc==='string'&&/^\d{4}-\d\d-\d\dT.*Z$/.test(value.creation_utc)&&Number.isFinite(Date.parse(value.creation_utc))&&typeof value.executable_path==='string'&&path.isAbsolute(value.executable_path)&&/^[0-9a-f]{64}$/.test(value.command_line_sha256??'');
|
|
@@ -14,9 +14,9 @@ export function foregroundIdentityMatches(expected,observed){
|
|
|
14
14
|
}
|
|
15
15
|
export function queryForegroundIdentity(pid){
|
|
16
16
|
if(process.platform!=='win32'||!Number.isSafeInteger(pid)||pid<1)throw new Error('foreground identity requires a positive Windows PID');
|
|
17
|
-
const
|
|
17
|
+
const powershell='C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
18
18
|
const command=`$p=Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}'; if ($null -eq $p) {Write-Output 'null';exit 0}; [pscustomobject]@{pid=$p.ProcessId;parent_pid=$p.ParentProcessId;creation_utc=$p.CreationDate.ToUniversalTime().ToString('o');executable_path=$p.ExecutablePath;command_line=$p.CommandLine} | ConvertTo-Json -Compress`;
|
|
19
|
-
const result=spawnSync(
|
|
19
|
+
const result=spawnSync(powershell,['-NoLogo','-NoProfile','-Command',command],{encoding:'utf8',windowsHide:true,env:windowsPowerShellEnvironment({...process.env,SystemRoot:'C:\\Windows',WINDIR:'C:\\Windows'}),timeout:15000,maxBuffer:1024*1024});
|
|
20
20
|
if(result.error||result.status!==0)throw new Error('foreground process identity query failed');
|
|
21
21
|
let raw;try{raw=JSON.parse(result.stdout);}catch{throw new Error('foreground process identity query malformed');}
|
|
22
22
|
if(raw===null)return null;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {spawn} from 'node:child_process';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
import {lstat, readFile} from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {performance} from 'node:perf_hooks';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
|
|
8
|
+
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
9
|
+
const exePath = path.join(root, 'bin', 'deadbyte-kernel-lock-r74.exe');
|
|
10
|
+
const metaPath = path.join(root, 'docs', 'R74-KERNEL-LOCK-BINARY.json');
|
|
11
|
+
|
|
12
|
+
const sha256Bytes = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
13
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
14
|
+
|
|
15
|
+
export async function verifyKernelLockBinary() {
|
|
16
|
+
if (process.platform !== 'win32') {
|
|
17
|
+
return { ok: false, reason: 'unsupported platform; win32 required' };
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const meta = JSON.parse(await readFile(metaPath, 'utf8'));
|
|
21
|
+
if (meta?.schema !== 'deadbyte.r74-kernel-lock-binary.v1' ||
|
|
22
|
+
meta?.exe !== 'bin/deadbyte-kernel-lock-r74.exe' ||
|
|
23
|
+
!/^[0-9a-f]{64}$/.test(String(meta?.sha256 ?? ''))) {
|
|
24
|
+
return { ok: false, reason: 'R74 kernel lock metadata invalid' };
|
|
25
|
+
}
|
|
26
|
+
const info = await lstat(exePath);
|
|
27
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
28
|
+
return { ok: false, reason: 'R74 kernel lock executable is not a regular file' };
|
|
29
|
+
}
|
|
30
|
+
const bytes = await readFile(exePath);
|
|
31
|
+
const hash = sha256Bytes(bytes);
|
|
32
|
+
if (hash !== meta.sha256) {
|
|
33
|
+
return { ok: false, reason: 'R74 kernel lock executable hash pin mismatch' };
|
|
34
|
+
}
|
|
35
|
+
return { ok: true, path: exePath, sha256: hash };
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { ok: false, reason: err?.message ?? String(err) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function acquireKernelExclusiveLock(lockPath, { timeoutMs = 30000, pollMs = 20 } = {}) {
|
|
42
|
+
const verified = await verifyKernelLockBinary();
|
|
43
|
+
if (!verified.ok) {
|
|
44
|
+
throw new Error(`atomic mutation lock recovery blocked: native exclusive locking unavailable (${verified.reason})`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const started = performance.now();
|
|
48
|
+
while (true) {
|
|
49
|
+
let child = null;
|
|
50
|
+
try {
|
|
51
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
52
|
+
child = spawn(verified.path, ['acquire', path.resolve(lockPath)], {
|
|
53
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
54
|
+
windowsHide: true,
|
|
55
|
+
shell: false
|
|
56
|
+
});
|
|
57
|
+
let line = '';
|
|
58
|
+
let resolved = false;
|
|
59
|
+
|
|
60
|
+
child.stdout.on('data', chunk => {
|
|
61
|
+
if (resolved) return;
|
|
62
|
+
line += chunk.toString('utf8');
|
|
63
|
+
const newline = line.indexOf('\n');
|
|
64
|
+
if (newline !== -1) {
|
|
65
|
+
resolved = true;
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(line.slice(0, newline).trim());
|
|
68
|
+
resolve(parsed);
|
|
69
|
+
} catch (parseError) {
|
|
70
|
+
reject(parseError);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
child.once('error', err => {
|
|
76
|
+
if (!resolved) {
|
|
77
|
+
resolved = true;
|
|
78
|
+
reject(err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
child.once('close', code => {
|
|
83
|
+
if (!resolved) {
|
|
84
|
+
resolved = true;
|
|
85
|
+
resolve({ status: 'exited', code });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (outcome.status === 'acquired') {
|
|
91
|
+
const lockChild = child;
|
|
92
|
+
const release = async () => {
|
|
93
|
+
if (lockChild.exitCode !== null) {
|
|
94
|
+
throw new Error('atomic mutation lock ownership lost: lock process exited unexpectedly');
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
lockChild.stdin.write('q\n');
|
|
98
|
+
lockChild.stdin.end();
|
|
99
|
+
} catch {}
|
|
100
|
+
await new Promise(resolve => {
|
|
101
|
+
if (lockChild.exitCode !== null) return resolve();
|
|
102
|
+
lockChild.once('close', resolve);
|
|
103
|
+
});
|
|
104
|
+
};
|
|
105
|
+
release.pid = outcome.pid;
|
|
106
|
+
return release;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (outcome.status === 'locked') {
|
|
110
|
+
if (performance.now() - started >= timeoutMs) {
|
|
111
|
+
throw new Error('atomic mutation lock timed out');
|
|
112
|
+
}
|
|
113
|
+
await sleep(pollMs);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw new Error(`atomic mutation lock acquisition failed: ${JSON.stringify(outcome)}`);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (child && child.exitCode === null) {
|
|
120
|
+
child.kill('SIGKILL');
|
|
121
|
+
}
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|