relembra 1.0.0-beta.3 → 1.0.0-beta.5
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/README.md +2 -2
- package/dist/sqlite/database.d.ts +3 -17
- package/dist/sqlite/database.js +44 -148
- package/dist/sqlite/windows-acl.d.ts +2 -1
- package/dist/sqlite/windows-acl.js +13 -5
- package/dist/sqlite/windows-security.d.ts +23 -0
- package/dist/sqlite/windows-security.js +256 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/FORMAT.md +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ and storage (an adapter). relembra enforces the rules:
|
|
|
25
25
|
- **Measured.** An evaluation harness with leak, scope, retrieval and wake-coverage gates, plus
|
|
26
26
|
controls that must fail. Results: [docs/eval/](docs/eval/).
|
|
27
27
|
|
|
28
|
-
Version: 1.0.0-beta.
|
|
28
|
+
Version: 1.0.0-beta.5, a beta ([changelog](CHANGELOG.md),
|
|
29
29
|
[releases](https://github.com/mneves75/relembra/releases)). Release procedure:
|
|
30
30
|
[docs/RELEASE.md](docs/RELEASE.md).
|
|
31
31
|
Design: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
|
@@ -226,7 +226,7 @@ On Windows, check the hash with `Get-FileHash` and copy the binary onto `PATH` a
|
|
|
226
226
|
|
|
227
227
|
Before replacing an installed executable, retain a backup and its hash. Verify installed bytes
|
|
228
228
|
against the release checksum and export metadata from a disposable store
|
|
229
|
-
(`relembra: "1.0.0-beta.
|
|
229
|
+
(`relembra: "1.0.0-beta.5"`); there is no `--version` flag. The [release procedure](docs/RELEASE.md) covers isolated checks and
|
|
230
230
|
rollback without opening a live memory store. After verification, `relembra init` prints the
|
|
231
231
|
session hook for your agent.
|
|
232
232
|
|
|
@@ -33,9 +33,9 @@ export declare function checkBusyTimeout(busyTimeoutMs: number): number;
|
|
|
33
33
|
/** Whether file modes say who may read a file: every platform but Windows. */
|
|
34
34
|
export declare const OWNER_MODES: boolean;
|
|
35
35
|
/**
|
|
36
|
-
* Creates
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* Creates POSIX directories as 0700 and files as 0600. On Windows a new store needs an absent
|
|
37
|
+
* directory, created with a private descriptor, and the empty store file is made in it under the
|
|
38
|
+
* same lock. Existing files keep their existing checks.
|
|
39
39
|
*/
|
|
40
40
|
export declare function createOwnerOnly(path: string): void;
|
|
41
41
|
/**
|
|
@@ -50,20 +50,6 @@ export declare function windowsSystem32(systemRoot: string | undefined): string;
|
|
|
50
50
|
* store's ACL both ask.
|
|
51
51
|
*/
|
|
52
52
|
export declare function windowsUserSid(systemRoot: string | undefined): string;
|
|
53
|
-
/**
|
|
54
|
-
* Windows: makes the current user the folder's owner, then replaces its ACL with full control for
|
|
55
|
-
* the user, SYSTEM and the Administrators group only, inherited by everything in it. An owner may
|
|
56
|
-
* always rewrite an ACL, so a folder another account made would otherwise give that account its
|
|
57
|
-
* access back; `/setowner` fails, and no store is created, unless the user may take ownership.
|
|
58
|
-
* `/inheritance:r` with the three `/grant:r` then drops what the folder inherited, in one change
|
|
59
|
-
* that never widens access: another process may already have made the store here (two first
|
|
60
|
-
* opens at once), and a step that restored the parent's grants, as `/reset` does, would let the
|
|
61
|
-
* store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
|
|
62
|
-
* ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
|
|
63
|
-
* Accounts are named by SID, so no account name or system language is involved. Any failure is
|
|
64
|
-
* thrown: a store is never created in a folder others may read.
|
|
65
|
-
*/
|
|
66
|
-
export declare function restrictFolderToOwner(dir: string): void;
|
|
67
53
|
/**
|
|
68
54
|
* The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
|
|
69
55
|
* A process running as LocalSystem is SYSTEM itself, so its folder holds two entries; counting
|
package/dist/sqlite/database.js
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { dirname, join, win32 } from "node:path";
|
|
2
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, win32 } from "node:path";
|
|
6
4
|
import { DatabaseSync } from "node:sqlite";
|
|
7
5
|
import { pathToFileURL } from "node:url";
|
|
8
6
|
import { StoreBusyError } from "../errors.js";
|
|
9
7
|
import { MIGRATIONS, migrate, SchemaTooNewError, schemaVersion } from "./migrations.js";
|
|
10
8
|
export { StoreBusyError };
|
|
11
9
|
import { walImage } from "./wal-image.js";
|
|
12
|
-
import { chainRefusal, storeFolderChain, trustedAccounts
|
|
10
|
+
import { chainRefusal, storeFolderChain, trustedAccounts } from "./windows-acl.js";
|
|
11
|
+
import { createStoreUnderLock, readWindowsChain } from "./windows-security.js";
|
|
13
12
|
/** Thrown when an existing store file, or its -wal or -shm file, grants group or others access. */
|
|
14
13
|
export class StoreFileModeError extends Error {
|
|
15
14
|
name = "StoreFileModeError";
|
|
@@ -59,31 +58,21 @@ export function checkBusyTimeout(busyTimeoutMs) {
|
|
|
59
58
|
/** Whether file modes say who may read a file: every platform but Windows. */
|
|
60
59
|
export const OWNER_MODES = process.platform !== "win32";
|
|
61
60
|
/**
|
|
62
|
-
* Creates
|
|
63
|
-
*
|
|
64
|
-
*
|
|
61
|
+
* Creates POSIX directories as 0700 and files as 0600. On Windows a new store needs an absent
|
|
62
|
+
* directory, created with a private descriptor, and the empty store file is made in it under the
|
|
63
|
+
* same lock. Existing files keep their existing checks.
|
|
65
64
|
*/
|
|
66
65
|
export function createOwnerOnly(path) {
|
|
67
|
-
|
|
66
|
+
if (OWNER_MODES)
|
|
67
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
68
68
|
if (isSymbolicLink(path))
|
|
69
69
|
throw new StoreFileTypeError(path);
|
|
70
|
-
// Windows ignores the modes:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
restrictFolderToOwner(dirname(path));
|
|
74
|
-
// A sidecar without its store was put there by someone: it keeps its own ACL, and Windows
|
|
75
|
-
// opens a file by its full path whatever the folder grants, so SQLite would write into a file
|
|
76
|
-
// another account can read. Checked after the ACL, when no other account can add one. A
|
|
77
|
-
// sidecar beside a store that exists by now belongs to another process that created the
|
|
78
|
-
// store first (its folder got the same three grants), so it is no plant.
|
|
79
|
-
for (const sidecar of [`${path}-wal`, `${path}-shm`, `${path}-journal`]) {
|
|
80
|
-
if (hasEntry(sidecar) && !hasEntry(path)) {
|
|
81
|
-
throw new Error(`${sidecar} exists without its store; no store is created beside it`);
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
70
|
+
// Windows ignores the modes: the directory must be private from creation, before any file.
|
|
71
|
+
if (!OWNER_MODES && !existsSync(path))
|
|
72
|
+
createWindowsStore(path);
|
|
85
73
|
try {
|
|
86
|
-
// O_EXCL never follows a link, so a link made since the check fails here with EEXIST.
|
|
74
|
+
// O_EXCL never follows a link, so a link made since the check fails here with EEXIST. On
|
|
75
|
+
// Windows the file exists by now, made under the store's lock, and this is a no-op.
|
|
87
76
|
closeSync(openSync(path, "wx", 0o600));
|
|
88
77
|
}
|
|
89
78
|
catch (error) {
|
|
@@ -134,134 +123,41 @@ export function windowsUserSid(systemRoot) {
|
|
|
134
123
|
return sid;
|
|
135
124
|
}
|
|
136
125
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* store inherit them for a moment. An explicit grant to anyone else is refused, not dropped: the
|
|
145
|
-
* ACL is read back and must hold one entry per account in storeFolderAccounts, and no other.
|
|
146
|
-
* Accounts are named by SID, so no account name or system language is involved. Any failure is
|
|
147
|
-
* thrown: a store is never created in a folder others may read.
|
|
126
|
+
* Creates an absent Windows store folder with its private descriptor supplied to mkdir, then the
|
|
127
|
+
* empty store file in it, under a mutex named for the account and folder: two processes opening
|
|
128
|
+
* a missing store at once both succeed, the second finding the first's file. Whether an existing
|
|
129
|
+
* folder is another creator's or a preexisting uninitialized one is decided under that lock, by
|
|
130
|
+
* the store file's presence: the latter is refused, since changing an ACL cannot revoke old
|
|
131
|
+
* handles. A sidecar without its store (a file another account could read, since Windows opens
|
|
132
|
+
* a file by its path whatever the folder grants) is refused there too, before the first byte.
|
|
148
133
|
*/
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
134
|
+
function createWindowsStore(path) {
|
|
135
|
+
const store = win32.resolve(path);
|
|
136
|
+
const folder = win32.dirname(store);
|
|
137
|
+
if (!/^[A-Za-z]:\\/.test(store) || store.slice(2).includes(":")) {
|
|
138
|
+
throw new Error("a Windows store requires a local drive path without alternate streams");
|
|
139
|
+
}
|
|
140
|
+
if (hasEntry(folder) && lstatSync(folder).isSymbolicLink())
|
|
141
|
+
throw new Error(`${folder} is a link`);
|
|
153
142
|
const systemRoot = process.env["SystemRoot"];
|
|
154
143
|
const sid = windowsUserSid(systemRoot);
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
.filter((line) => line.includes(":("));
|
|
167
|
-
if (entries.length !== accounts.length) {
|
|
168
|
-
throw new Error(`${dir} grants another account access (${entries.length} entries, not ${accounts.length}); no store is created in it: remove those grants or use another folder`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* Windows: refuses a store folder whose path another account could change (docs/specs/w-nv1/spec.md).
|
|
173
|
-
* The ACL steps work on the folder's path, so they hold only while no other account can rename,
|
|
174
|
-
* delete or re-permission a folder above it, make the store folder first, or open it before its
|
|
175
|
-
* ACL is set. Every folder from the store folder's parent up to the drive root, on the path as
|
|
176
|
-
* given (storeFolderChain), is read as SDDL with `icacls /save`, which names accounts by SID, and
|
|
177
|
-
* judged by chainRefusal. A link on the path is refused: icacls would read its target, and whoever
|
|
178
|
-
* may change the link may point it elsewhere. Owners are not read, since no stock tool prints one
|
|
179
|
-
* as a SID: an ancestor another account owns, whose access list does not name that account, is
|
|
180
|
-
* not detected.
|
|
181
|
-
*/
|
|
182
|
-
function checkFolderChain(systemRoot, dir, sid) {
|
|
183
|
-
const folders = storeFolderChain(dir);
|
|
184
|
-
for (const folder of folders) {
|
|
185
|
-
if (lstatSync(folder).isSymbolicLink()) {
|
|
186
|
-
throw new Error(`${folder} is a link; no store is created under it: use a path without links`);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
if (folders.length === 0)
|
|
190
|
-
return;
|
|
191
|
-
const chain = [];
|
|
192
|
-
const scratch = privateScratch(systemRoot, sid);
|
|
193
|
-
try {
|
|
194
|
-
for (const [index, folder] of folders.entries()) {
|
|
195
|
-
chain.push({
|
|
196
|
-
path: folder,
|
|
197
|
-
dacl: savedDacl(systemRoot, folder, scratch),
|
|
198
|
-
holdsStoreFolder: index === 0,
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
finally {
|
|
203
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
144
|
+
const executable = win32.join(windowsSystem32(systemRoot), "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
145
|
+
const ancestors = storeFolderChain(folder);
|
|
146
|
+
const missing = [folder];
|
|
147
|
+
const existing = [];
|
|
148
|
+
for (const ancestor of ancestors) {
|
|
149
|
+
if (hasEntry(ancestor))
|
|
150
|
+
existing.push(ancestor);
|
|
151
|
+
else if (existing.length === 0)
|
|
152
|
+
missing.push(ancestor);
|
|
153
|
+
else
|
|
154
|
+
throw new Error("the Windows store ancestry changed during verification");
|
|
204
155
|
}
|
|
156
|
+
const chain = readWindowsChain(executable, existing.reverse());
|
|
205
157
|
const refusal = chainRefusal(chain, trustedAccounts(sid));
|
|
206
|
-
if (refusal !== undefined)
|
|
207
|
-
throw new Error(`${refusal}; no store is created under it
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
/**
|
|
211
|
-
* A folder under TEMP for the `icacls /save` files the chain check trusts, with the store
|
|
212
|
-
* folder's own ACL: owned by the user, and full control for the user, SYSTEM and Administrators
|
|
213
|
-
* only, read back on stdout. Bun's mkdtemp already gives it those three entries, explicit; Node's
|
|
214
|
-
* gives it TEMP's inherited ACL until these steps. Each file gets a random name, so no account can
|
|
215
|
-
* make one first. The steps act on the folder's path after it exists, so TEMP is trusted for what
|
|
216
|
-
* they cannot see (docs/specs/w-nv1/spec.md): it must give other accounts no inherited access to
|
|
217
|
-
* folders made in it (a handle opened before these steps keeps its access, and could turn the
|
|
218
|
-
* folder into a junction), must not let them delete or rename its entries, and must not be theirs.
|
|
219
|
-
* The default, under the user's profile, meets that.
|
|
220
|
-
*/
|
|
221
|
-
function privateScratch(systemRoot, sid) {
|
|
222
|
-
const scratch = mkdtempSync(join(tmpdir(), "relembra-acl-"));
|
|
223
|
-
try {
|
|
224
|
-
const accounts = storeFolderAccounts(sid);
|
|
225
|
-
runSystemTool(systemRoot, "icacls.exe", [scratch, "/setowner", `*${sid}`]);
|
|
226
|
-
runSystemTool(systemRoot, "icacls.exe", [
|
|
227
|
-
scratch,
|
|
228
|
-
"/inheritance:r",
|
|
229
|
-
...accounts.flatMap((who) => ["/grant:r", `*${who}:(OI)(CI)F`]),
|
|
230
|
-
]);
|
|
231
|
-
const entries = runSystemTool(systemRoot, "icacls.exe", [scratch])
|
|
232
|
-
.split(/\r?\n/)
|
|
233
|
-
.filter((line) => line.includes(":("));
|
|
234
|
-
if (entries.length !== accounts.length) {
|
|
235
|
-
throw new Error(`${scratch} grants another account access (${entries.length} entries, not ${accounts.length}); no store is created: point TEMP at a folder only you can change`);
|
|
236
|
-
}
|
|
237
|
-
return scratch;
|
|
238
|
-
}
|
|
239
|
-
catch (error) {
|
|
240
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
241
|
-
throw error;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* A folder's access list as SDDL. `icacls <folder> /save <file>` writes the folder's name on one
|
|
246
|
-
* line (none for a drive root) and its access list on the next, in UTF-16 or the console's
|
|
247
|
-
* encoding; parseDacl drops the SACL that follows it on that line.
|
|
248
|
-
*/
|
|
249
|
-
function savedDacl(systemRoot, folder, scratch) {
|
|
250
|
-
// A fresh random name for each save, so no account can make the file first.
|
|
251
|
-
const file = join(scratch, randomBytes(16).toString("hex"));
|
|
252
|
-
runSystemTool(systemRoot, "icacls.exe", [folder, "/save", file]);
|
|
253
|
-
const bytes = readFileSync(file);
|
|
254
|
-
const utf16 = (bytes[0] === 0xff && bytes[1] === 0xfe) || bytes[1] === 0;
|
|
255
|
-
const lines = (utf16 ? bytes.toString("utf16le") : bytes.toString("utf8"))
|
|
256
|
-
.replace(/^\uFEFF/, "")
|
|
257
|
-
.split(/\r?\n/)
|
|
258
|
-
.filter((line) => line.trim() !== "");
|
|
259
|
-
// A drive root has no name, so its file holds the access list alone.
|
|
260
|
-
const dacl = lines.at(-1);
|
|
261
|
-
if (lines.length > 2 || dacl === undefined || !dacl.startsWith("D:")) {
|
|
262
|
-
throw new Error(`icacls /save did not write one access list for ${folder}: ${lines.join(" | ")}`);
|
|
263
|
-
}
|
|
264
|
-
return dacl.trim();
|
|
158
|
+
if (refusal !== undefined)
|
|
159
|
+
throw new Error(`${refusal}; no store is created under it`);
|
|
160
|
+
createStoreUnderLock(executable, store, folder, missing.reverse(), chain, sid, storeFolderAccounts(sid));
|
|
265
161
|
}
|
|
266
162
|
/**
|
|
267
163
|
* The accounts a store folder grants full control: the user, SYSTEM and Administrators, each once.
|
|
@@ -9,10 +9,11 @@ export interface Ace {
|
|
|
9
9
|
/** A SID, or an SDDL alias this module does not resolve (domain-relative ones: never trusted). */
|
|
10
10
|
readonly sid: string;
|
|
11
11
|
}
|
|
12
|
-
/**
|
|
12
|
+
/** An existing ancestor with its owner SID and DACL read directly from the filesystem. */
|
|
13
13
|
export interface ChainFolder {
|
|
14
14
|
readonly path: string;
|
|
15
15
|
readonly dacl: string;
|
|
16
|
+
readonly ownerSid: string;
|
|
16
17
|
/** The folder the store folder is made in. */
|
|
17
18
|
readonly holdsStoreFolder: boolean;
|
|
18
19
|
}
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* a folder on that path, or open the new folder before its ACL is set. Pure: the caller reads the
|
|
6
|
-
* access lists with icacls.
|
|
2
|
+
* Current owners and access lists of the existing ancestry of a new Windows store directory.
|
|
3
|
+
* The filesystem adapter reads SIDs and SDDL directly through .NET; these conservative rules
|
|
4
|
+
* neither adopt preexisting store directories nor attest to historical ancestor handles.
|
|
7
5
|
*/
|
|
8
6
|
import { win32 } from "node:path";
|
|
9
7
|
const EVERYONE = "S-1-1-0";
|
|
@@ -75,6 +73,7 @@ const DENY_TYPES = new Set(["D", "XD", "OD"]);
|
|
|
75
73
|
/** Entry types that may end in a condition; their allow ones count as unconditional here. */
|
|
76
74
|
const CALLBACK_TYPES = new Set(["XA", "XD", "ZA"]);
|
|
77
75
|
const GUID = /^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$/;
|
|
76
|
+
const WRITE_ATTRIBUTES = 0x100;
|
|
78
77
|
const DELETE = 0x10000;
|
|
79
78
|
const WRITE_DAC = 0x40000;
|
|
80
79
|
const WRITE_OWNER = 0x80000;
|
|
@@ -210,6 +209,12 @@ export function storeFolderChain(storeFolder) {
|
|
|
210
209
|
*/
|
|
211
210
|
export function chainRefusal(chain, trusted) {
|
|
212
211
|
for (const folder of chain) {
|
|
212
|
+
if (!/^S-1-[0-9]+(?:-[0-9]+)+$/.test(folder.ownerSid) || folder.ownerSid === CREATOR_OWNER) {
|
|
213
|
+
return `${folder.path} has an unverifiable owner`;
|
|
214
|
+
}
|
|
215
|
+
if (!trusted.has(folder.ownerSid)) {
|
|
216
|
+
return `${folder.path} is owned by another account (${folder.ownerSid})`;
|
|
217
|
+
}
|
|
213
218
|
const aces = parseDacl(folder.dacl) ?? [
|
|
214
219
|
{ allow: true, inheritOnly: false, containerInherit: true, mask: 0x1f01ff, sid: EVERYONE },
|
|
215
220
|
];
|
|
@@ -221,6 +226,9 @@ export function chainRefusal(chain, trusted) {
|
|
|
221
226
|
if (own & (DELETE | WRITE_DAC | WRITE_OWNER | GENERIC_ALL)) {
|
|
222
227
|
return refuse("rename, delete or re-permission it");
|
|
223
228
|
}
|
|
229
|
+
if (own & (WRITE_ATTRIBUTES | GENERIC_WRITE)) {
|
|
230
|
+
return refuse("change its attributes or turn it into a reparse point");
|
|
231
|
+
}
|
|
224
232
|
if (own & DELETE_CHILD)
|
|
225
233
|
return refuse("rename or delete the folders in it");
|
|
226
234
|
if (!folder.holdsStoreFolder)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChainFolder } from "./windows-acl.js";
|
|
2
|
+
/** No Get-Acl provider, compiled helper, or temporary file participates in these reads. */
|
|
3
|
+
export declare function readWindowsChain(executable: string, paths: readonly string[]): ChainFolder[];
|
|
4
|
+
/**
|
|
5
|
+
* The name of the mutex that serialises the creators of one store: one per account and store
|
|
6
|
+
* folder, compared as Windows compares paths, without case. A long and a short (8.3) spelling
|
|
7
|
+
* of one folder take two names, so two such creators refuse each other, never both create.
|
|
8
|
+
*/
|
|
9
|
+
export declare function storeLockName(ownerSid: string, folder: string): string;
|
|
10
|
+
/** The mutex's own descriptor: full control (0x1f0001) for the accounts, nothing else. */
|
|
11
|
+
export declare function storeLockSddl(ownerSid: string, accounts: readonly string[]): string;
|
|
12
|
+
/**
|
|
13
|
+
* The store folder's descriptor: protected, so nothing is inherited into it, and auto-inherited
|
|
14
|
+
* (`AI`), so what is made in it carries its grants as inherited entries, as a folder set with
|
|
15
|
+
* icacls does. Without `AI` Windows copies the grants into new files unmarked.
|
|
16
|
+
*/
|
|
17
|
+
export declare function storeFolderSddl(ownerSid: string, accounts: readonly string[]): string;
|
|
18
|
+
/**
|
|
19
|
+
* Creates the absent folders with protected inheritable grants before they become visible, then
|
|
20
|
+
* the empty store file in the last, all under the store's lock. Returns `exists` when another
|
|
21
|
+
* creator finished the same store under that lock meanwhile.
|
|
22
|
+
*/
|
|
23
|
+
export declare function createStoreUnderLock(executable: string, store: string, folder: string, paths: readonly string[], chain: readonly ChainFolder[], ownerSid: string, accounts: readonly string[]): "created" | "exists";
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* Windows PowerShell uses .NET Framework: this overload supplies the descriptor to mkdir, and a
|
|
5
|
+
* named mutex serialises the creators of one store, so two first opens both succeed.
|
|
6
|
+
*/
|
|
7
|
+
const PROGRAM = String.raw `
|
|
8
|
+
$ErrorActionPreference = 'Stop'
|
|
9
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
10
|
+
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
|
|
11
|
+
[void][Reflection.Assembly]::Load('System.Web.Extensions, Version=4.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35')
|
|
12
|
+
$serializer = [System.Web.Script.Serialization.JavaScriptSerializer]::new()
|
|
13
|
+
function Read-Folder([string] $path) {
|
|
14
|
+
$attributes = [IO.File]::GetAttributes($path)
|
|
15
|
+
if (($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "$path is a link or reparse point" }
|
|
16
|
+
if (($attributes -band [IO.FileAttributes]::Directory) -eq 0) { throw "$path is not a directory" }
|
|
17
|
+
$sections = [Security.AccessControl.AccessControlSections]::Owner -bor [Security.AccessControl.AccessControlSections]::Access
|
|
18
|
+
$acl = [IO.Directory]::GetAccessControl($path, $sections)
|
|
19
|
+
@{
|
|
20
|
+
path = $path
|
|
21
|
+
ownerSid = $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value
|
|
22
|
+
dacl = $acl.GetSecurityDescriptorSddlForm([Security.AccessControl.AccessControlSections]::Access)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function Test-Entry([string] $path) {
|
|
26
|
+
[IO.Directory]::Exists($path) -or [IO.File]::Exists($path)
|
|
27
|
+
}
|
|
28
|
+
# Exactly one full-control allow per account, nothing else: a folder's own entries, a file's
|
|
29
|
+
# inherited ones. A folder is owned by the user, whose SID its descriptor names; a file by the
|
|
30
|
+
# token's default owner, which is Administrators for an administrator, so any of the accounts.
|
|
31
|
+
function Confirm-Rules($acl, [string] $what, [string[]] $owners) {
|
|
32
|
+
$rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
|
|
33
|
+
if ($owners -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -or $rules.Count -ne $request.accounts.Count) {
|
|
34
|
+
throw "$what is not private"
|
|
35
|
+
}
|
|
36
|
+
$seen = @{}
|
|
37
|
+
foreach ($rule in $rules) {
|
|
38
|
+
$sid = $rule.IdentityReference.Value
|
|
39
|
+
if ($request.accounts -notcontains $sid -or $seen.ContainsKey($sid) -or
|
|
40
|
+
$rule.AccessControlType -ne 'Allow' -or
|
|
41
|
+
$rule.FileSystemRights -ne [Security.AccessControl.FileSystemRights]::FullControl) {
|
|
42
|
+
throw "$what has unexpected access rules"
|
|
43
|
+
}
|
|
44
|
+
$seen[$sid] = $true
|
|
45
|
+
}
|
|
46
|
+
$rules
|
|
47
|
+
}
|
|
48
|
+
# With $model, the folder must also carry the auto-inherit model (AI), under which the files made
|
|
49
|
+
# in it carry its grants as inherited entries.
|
|
50
|
+
function Confirm-Folder([string] $path, [bool] $model) {
|
|
51
|
+
$folder = Read-Folder $path
|
|
52
|
+
$acl = [IO.Directory]::GetAccessControl($path)
|
|
53
|
+
if (-not $acl.AreAccessRulesProtected) { throw "$path was not created private" }
|
|
54
|
+
foreach ($rule in (Confirm-Rules $acl $path @($request.ownerSid))) {
|
|
55
|
+
if ($rule.IsInherited -or
|
|
56
|
+
$rule.InheritanceFlags -ne ([Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit) -or
|
|
57
|
+
$rule.PropagationFlags -ne [Security.AccessControl.PropagationFlags]::None) {
|
|
58
|
+
throw "$path has unexpected access rules"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if ($model -and -not $folder.dacl.StartsWith('D:PAI')) { throw "$path did not take the auto-inherit model" }
|
|
62
|
+
}
|
|
63
|
+
function Confirm-File([string] $path) {
|
|
64
|
+
$attributes = [IO.File]::GetAttributes($path)
|
|
65
|
+
if (($attributes -band ([IO.FileAttributes]::ReparsePoint -bor [IO.FileAttributes]::Directory)) -ne 0) { throw "$path is not a regular file" }
|
|
66
|
+
[void](Confirm-Rules ([IO.File]::GetAccessControl($path)) $path $request.accounts)
|
|
67
|
+
}
|
|
68
|
+
# The lock is ours only when its owner and every grant are: a squatted name is refused, never
|
|
69
|
+
# re-permissioned.
|
|
70
|
+
function Confirm-Lock($lock) {
|
|
71
|
+
$acl = $lock.GetAccessControl()
|
|
72
|
+
if ($acl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $request.ownerSid) { throw 'the store creation lock is owned by another account' }
|
|
73
|
+
$rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
|
|
74
|
+
if ($rules.Count -ne $request.accounts.Count) { throw 'the store creation lock grants another account access' }
|
|
75
|
+
foreach ($rule in $rules) {
|
|
76
|
+
if ($request.accounts -notcontains $rule.IdentityReference.Value -or $rule.AccessControlType -ne 'Allow' -or
|
|
77
|
+
$rule.MutexRights -ne [System.Security.AccessControl.MutexRights]::FullControl) {
|
|
78
|
+
throw 'the store creation lock grants another account access'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
$request = $serializer.DeserializeObject([Console]::In.ReadToEnd())
|
|
84
|
+
if ($request.mode -eq 'read') {
|
|
85
|
+
$result = @(foreach ($path in $request.paths) { Read-Folder $path })
|
|
86
|
+
[Console]::Write($serializer.Serialize([object[]]$result))
|
|
87
|
+
} elseif ($request.mode -eq 'create') {
|
|
88
|
+
if ($request.lockName -notmatch '^Global\\relembra-[0-9a-f]{64}$') { throw 'invalid store creation lock name' }
|
|
89
|
+
$lockSecurity = [System.Security.AccessControl.MutexSecurity]::new()
|
|
90
|
+
$lockSecurity.SetSecurityDescriptorSddlForm($request.lockSddl)
|
|
91
|
+
$createdNew = $false
|
|
92
|
+
$lock = [System.Threading.Mutex]::new($false, $request.lockName, [ref] $createdNew, $lockSecurity)
|
|
93
|
+
$held = $false
|
|
94
|
+
try {
|
|
95
|
+
Confirm-Lock $lock
|
|
96
|
+
try { $held = $lock.WaitOne(10000) } catch [System.Threading.AbandonedMutexException] { $held = $true }
|
|
97
|
+
if (-not $held) { throw 'another process is still creating the store folder' }
|
|
98
|
+
if (Test-Entry $request.store) {
|
|
99
|
+
# Another creator finished under this lock: its folder must be exactly what this one would
|
|
100
|
+
# have made, model included. An abandoned lock never adopts an unfinished folder: the
|
|
101
|
+
# store is absent then.
|
|
102
|
+
Confirm-File $request.store
|
|
103
|
+
Confirm-Folder $request.folder $true
|
|
104
|
+
$result = 'exists'
|
|
105
|
+
} else {
|
|
106
|
+
foreach ($expected in $request.chain) {
|
|
107
|
+
$actual = Read-Folder $expected.path
|
|
108
|
+
if ($actual.ownerSid -ne $expected.ownerSid -or $actual.dacl -cne $expected.dacl) {
|
|
109
|
+
throw 'the store folder ancestry changed during verification'
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
foreach ($path in $request.paths) {
|
|
113
|
+
if (Test-Entry $path) { throw "$path already exists; a new Windows store requires a new folder" }
|
|
114
|
+
}
|
|
115
|
+
# What this run made is removed when a later step fails, so a retry finds the leaf absent;
|
|
116
|
+
# nothing that existed before is ever removed.
|
|
117
|
+
$created = @()
|
|
118
|
+
$fileCreated = $false
|
|
119
|
+
try {
|
|
120
|
+
foreach ($path in $request.paths) {
|
|
121
|
+
# A fresh descriptor per call: Persist clears what a reused one would apply again.
|
|
122
|
+
$security = [Security.AccessControl.DirectorySecurity]::new()
|
|
123
|
+
$security.SetSecurityDescriptorSddlForm($request.sddl)
|
|
124
|
+
[void][IO.Directory]::CreateDirectory($path, $security)
|
|
125
|
+
$created += $path
|
|
126
|
+
Confirm-Folder $path $false
|
|
127
|
+
# Files made in the folder carry its grants as inherited entries only under the
|
|
128
|
+
# auto-inherit model (AI). Creation may hand the kernel a descriptor without it; setting
|
|
129
|
+
# the same descriptor again goes through SetNamedSecurityInfo, which adopts the model.
|
|
130
|
+
if (-not (Read-Folder $path).dacl.StartsWith('D:PAI')) {
|
|
131
|
+
$again = [Security.AccessControl.DirectorySecurity]::new()
|
|
132
|
+
$again.SetSecurityDescriptorSddlForm($request.sddl)
|
|
133
|
+
[IO.Directory]::SetAccessControl($path, $again)
|
|
134
|
+
}
|
|
135
|
+
Confirm-Folder $path $true
|
|
136
|
+
}
|
|
137
|
+
foreach ($sidecar in $request.sidecars) {
|
|
138
|
+
if (Test-Entry $sidecar) { throw "$sidecar exists without its store; no store is created beside it" }
|
|
139
|
+
}
|
|
140
|
+
[IO.FileStream]::new($request.store, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None).Dispose()
|
|
141
|
+
$fileCreated = $true
|
|
142
|
+
Confirm-File $request.store
|
|
143
|
+
} catch {
|
|
144
|
+
$failure = $_.Exception
|
|
145
|
+
if ($fileCreated) { try { [IO.File]::Delete($request.store) } catch { } }
|
|
146
|
+
for ($i = $created.Count - 1; $i -ge 0; $i--) { try { [IO.Directory]::Delete($created[$i]) } catch { } }
|
|
147
|
+
throw $failure
|
|
148
|
+
}
|
|
149
|
+
$result = 'created'
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
if ($held) { $lock.ReleaseMutex() }
|
|
153
|
+
$lock.Dispose()
|
|
154
|
+
}
|
|
155
|
+
[Console]::Write($serializer.Serialize($result))
|
|
156
|
+
} else { throw 'unknown security operation' }
|
|
157
|
+
} catch {
|
|
158
|
+
[Console]::Error.WriteLine($_.Exception.Message)
|
|
159
|
+
exit 1
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
function run(executable, request) {
|
|
163
|
+
const child = spawnSync(executable, [
|
|
164
|
+
"-NoLogo",
|
|
165
|
+
"-NoProfile",
|
|
166
|
+
"-NonInteractive",
|
|
167
|
+
"-EncodedCommand",
|
|
168
|
+
Buffer.from(PROGRAM, "utf16le").toString("base64"),
|
|
169
|
+
], {
|
|
170
|
+
input: JSON.stringify(request),
|
|
171
|
+
encoding: "utf8",
|
|
172
|
+
shell: false,
|
|
173
|
+
windowsHide: true,
|
|
174
|
+
timeout: 30_000,
|
|
175
|
+
});
|
|
176
|
+
if (child.error !== undefined || child.status !== 0) {
|
|
177
|
+
throw new Error(`Windows store security failed: ${child.error?.message ?? child.stderr.trim()}; no store is created`);
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(child.stdout.replace(/^/, ""));
|
|
181
|
+
}
|
|
182
|
+
catch (cause) {
|
|
183
|
+
throw new Error("Windows store security returned invalid JSON; no store is created", { cause });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** No Get-Acl provider, compiled helper, or temporary file participates in these reads. */
|
|
187
|
+
export function readWindowsChain(executable, paths) {
|
|
188
|
+
const result = run(executable, { mode: "read", paths });
|
|
189
|
+
if (!Array.isArray(result) || result.length !== paths.length) {
|
|
190
|
+
throw new Error("Windows store security did not return the complete folder chain");
|
|
191
|
+
}
|
|
192
|
+
return result.map((value, index) => {
|
|
193
|
+
if (typeof value !== "object" ||
|
|
194
|
+
value === null ||
|
|
195
|
+
!("path" in value) ||
|
|
196
|
+
typeof value.path !== "string" ||
|
|
197
|
+
value.path !== paths[index] ||
|
|
198
|
+
!("ownerSid" in value) ||
|
|
199
|
+
typeof value.ownerSid !== "string" ||
|
|
200
|
+
!("dacl" in value) ||
|
|
201
|
+
typeof value.dacl !== "string") {
|
|
202
|
+
throw new Error("Windows store security returned an invalid folder descriptor");
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
path: value.path,
|
|
206
|
+
ownerSid: value.ownerSid,
|
|
207
|
+
dacl: value.dacl,
|
|
208
|
+
holdsStoreFolder: index === paths.length - 1,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The name of the mutex that serialises the creators of one store: one per account and store
|
|
214
|
+
* folder, compared as Windows compares paths, without case. A long and a short (8.3) spelling
|
|
215
|
+
* of one folder take two names, so two such creators refuse each other, never both create.
|
|
216
|
+
*/
|
|
217
|
+
export function storeLockName(ownerSid, folder) {
|
|
218
|
+
const digest = createHash("sha256").update(`${ownerSid}\n${folder.toLowerCase()}`).digest("hex");
|
|
219
|
+
return `Global\\relembra-${digest}`;
|
|
220
|
+
}
|
|
221
|
+
/** The mutex's own descriptor: full control (0x1f0001) for the accounts, nothing else. */
|
|
222
|
+
export function storeLockSddl(ownerSid, accounts) {
|
|
223
|
+
return `O:${ownerSid}D:P${accounts.map((sid) => `(A;;0x1f0001;;;${sid})`).join("")}`;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The store folder's descriptor: protected, so nothing is inherited into it, and auto-inherited
|
|
227
|
+
* (`AI`), so what is made in it carries its grants as inherited entries, as a folder set with
|
|
228
|
+
* icacls does. Without `AI` Windows copies the grants into new files unmarked.
|
|
229
|
+
*/
|
|
230
|
+
export function storeFolderSddl(ownerSid, accounts) {
|
|
231
|
+
return `O:${ownerSid}D:PAI${accounts.map((sid) => `(A;OICI;FA;;;${sid})`).join("")}`;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Creates the absent folders with protected inheritable grants before they become visible, then
|
|
235
|
+
* the empty store file in the last, all under the store's lock. Returns `exists` when another
|
|
236
|
+
* creator finished the same store under that lock meanwhile.
|
|
237
|
+
*/
|
|
238
|
+
export function createStoreUnderLock(executable, store, folder, paths, chain, ownerSid, accounts) {
|
|
239
|
+
const result = run(executable, {
|
|
240
|
+
mode: "create",
|
|
241
|
+
store,
|
|
242
|
+
folder,
|
|
243
|
+
paths,
|
|
244
|
+
sidecars: [`${store}-wal`, `${store}-shm`, `${store}-journal`],
|
|
245
|
+
chain,
|
|
246
|
+
ownerSid,
|
|
247
|
+
accounts,
|
|
248
|
+
sddl: storeFolderSddl(ownerSid, accounts),
|
|
249
|
+
lockName: storeLockName(ownerSid, folder),
|
|
250
|
+
lockSddl: storeLockSddl(ownerSid, accounts),
|
|
251
|
+
});
|
|
252
|
+
if (result !== "created" && result !== "exists") {
|
|
253
|
+
throw new Error("Windows store security did not confirm private store creation");
|
|
254
|
+
}
|
|
255
|
+
return result;
|
|
256
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This relembra's version, as package.json gives it. An export's header carries it. */
|
|
2
|
-
export declare const RELEMBRA_VERSION = "1.0.0-beta.
|
|
2
|
+
export declare const RELEMBRA_VERSION = "1.0.0-beta.5";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** This relembra's version, as package.json gives it. An export's header carries it. */
|
|
2
|
-
export const RELEMBRA_VERSION = "1.0.0-beta.
|
|
2
|
+
export const RELEMBRA_VERSION = "1.0.0-beta.5";
|
package/docs/FORMAT.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# relembra storage format
|
|
2
2
|
|
|
3
|
-
Status: event formats 1 and 2, SQLite schema version 6, Postgres schema version 3. Release 1.0.0-beta.
|
|
3
|
+
Status: event formats 1 and 2, SQLite schema version 6, Postgres schema version 3. Release 1.0.0-beta.5.
|
|
4
4
|
|
|
5
5
|
This document is the contract for what relembra stores. A reader that follows it needs no
|
|
6
6
|
relembra code.
|
|
@@ -175,7 +175,7 @@ the events above, so a reader needs only this document.
|
|
|
175
175
|
|---|---|---|
|
|
176
176
|
| `export` | string | Always `relembra`. What makes the file an export. |
|
|
177
177
|
| `format` | integer | The newest event format of the lines after it: `1` when none is a `promote` line, so a format 1 reader still reads the file, and `2` when one is. |
|
|
178
|
-
| `relembra` | string | The relembra version that wrote the file, such as `1.0.0-beta.
|
|
178
|
+
| `relembra` | string | The relembra version that wrote the file, such as `1.0.0-beta.5`. Informational: a reader decides by `format` alone. |
|
|
179
179
|
| `scopes` | array of strings | The scopes exported. Every event's `scope` is one of them. |
|
|
180
180
|
|
|
181
181
|
### Events (lines 2 onward)
|