aamio 0.5.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -1
- package/bin/aamio.js +247 -0
- package/package.json +12 -3
- package/src/aamio.d.ts +39 -0
- package/src/aamio.js +294 -0
- package/src/node-identity.js +162 -0
- package/src/node-lock.js +203 -0
- package/src/node-mcp.js +667 -0
- package/src/node-runtime.js +1103 -0
package/src/aamio.js
CHANGED
|
@@ -198,6 +198,300 @@ export function hashPrefix(key, length = 8) {
|
|
|
198
198
|
return keyHash(key).slice(0, length);
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// -------------------------------------------------------------- domain keys
|
|
202
|
+
//
|
|
203
|
+
// Which public aamio keys a website says are its own. A site can publish them
|
|
204
|
+
// at https://<domain>/.well-known/aamio-keys.json as
|
|
205
|
+
// {"aamio_keys": 1, "keys": [{"key": "<43 characters>", "name": "support"}]}.
|
|
206
|
+
// This is the format and nothing else: a strict parser, the match, a generator
|
|
207
|
+
// for the file, and the syntax of a domain. It opens no connection and reads no
|
|
208
|
+
// address book. A browser cannot see which address a name resolves to, and a
|
|
209
|
+
// library has no list of websites its user agreed to, so reading a list is for
|
|
210
|
+
// the caller, who also says which key is compared: the result says subject_source
|
|
211
|
+
// supplied_key, and aamio-python and aamio-php have the whole check.
|
|
212
|
+
//
|
|
213
|
+
// Take the domain from your own records and never from the message being
|
|
214
|
+
// checked: anyone can list their own key on their own site. A listed key says
|
|
215
|
+
// that this exact site listed this key when it was read. It is not an identity,
|
|
216
|
+
// an authority to act or proof that a message is true.
|
|
217
|
+
//
|
|
218
|
+
// JSON.parse keeps the last of two members of the same name without a word, so
|
|
219
|
+
// it cannot hold the format's rule about duplicates, and the reader here is its
|
|
220
|
+
// own. test/domain_keys_vectors.json holds what every client answers.
|
|
221
|
+
|
|
222
|
+
export const DOMAIN_KEYS_VERSION = 1;
|
|
223
|
+
export const DOMAIN_KEYS_PATH = "/.well-known/aamio-keys.json";
|
|
224
|
+
export const DOMAIN_KEYS_MAX_BYTES = 16384;
|
|
225
|
+
export const DOMAIN_KEYS_MAX_KEYS = 64;
|
|
226
|
+
const DOMAIN_MAX_DEPTH = 16;
|
|
227
|
+
const DOMAIN_MAX_INTEGER = 9007199254740991n;
|
|
228
|
+
|
|
229
|
+
/** A list that is not version 1. reason says why, without echoing the content. */
|
|
230
|
+
export class InvalidDomainKeys extends Error {
|
|
231
|
+
constructor(reason) {
|
|
232
|
+
super("invalid domain key list: " + reason);
|
|
233
|
+
this.name = "InvalidDomainKeys";
|
|
234
|
+
this.reason = reason;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** A public key as aamio writes it: 32 bytes, 43 characters, one spelling only. */
|
|
239
|
+
export function canonicalKey(value) {
|
|
240
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(value)) return false;
|
|
241
|
+
// A last character carrying bits past the 32 bytes decodes to the same bytes
|
|
242
|
+
// as another string; only the one an encoder writes is the key.
|
|
243
|
+
return b64url(unb64url(value)) === value;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A name a list can carry: 1 to 64 characters of a-z, 0-9, dot, underscore and dash. */
|
|
247
|
+
export function validDomainKeyName(value) {
|
|
248
|
+
return typeof value === "string" && /^[a-z0-9._-]{1,64}$/.test(value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The exact host a list is read from, in lowercase, or null. Plain ASCII names of
|
|
253
|
+
* two labels or more. No port, path, IP literal, trailing dot, underscore or
|
|
254
|
+
* space is trimmed into shape. Internationalised names, xn-- labels, wait until
|
|
255
|
+
* every client converts them the same way. www.example.org and example.org are
|
|
256
|
+
* different places, and no parent domain lends its list to a child.
|
|
257
|
+
*/
|
|
258
|
+
export function normaliseDomain(value) {
|
|
259
|
+
// ASCII first: toLowerCase makes a k of the Kelvin sign.
|
|
260
|
+
if (typeof value !== "string" || !/^[\x00-\x7f]*$/.test(value)) return null;
|
|
261
|
+
const domain = value.toLowerCase();
|
|
262
|
+
const labels = domain.split(".");
|
|
263
|
+
if (domain.length < 1 || domain.length > 253 || labels.length < 2) return null;
|
|
264
|
+
for (const label of labels) {
|
|
265
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label) || label.startsWith("xn--")) return null;
|
|
266
|
+
}
|
|
267
|
+
// A last label of digits or hex is an old spelling of an IPv4 address.
|
|
268
|
+
return /^(?:[0-9]+|0x[0-9a-f]+)$/.test(labels[labels.length - 1]) ? null : domain;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Where the list of this exact domain is, over HTTPS on the default port, or null. */
|
|
272
|
+
export function domainKeysUrl(domain) {
|
|
273
|
+
const host = normaliseDomain(domain);
|
|
274
|
+
return host === null ? null : "https://" + host + DOMAIN_KEYS_PATH;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
class DomainReader {
|
|
278
|
+
// JSON with the format's limits. Objects become a Map, so __proto__ is a member
|
|
279
|
+
// like any other, lists an Array, integers a BigInt and other numbers a number.
|
|
280
|
+
// A string holds UTF-16, so a lone surrogate stays what it is and a pair is the
|
|
281
|
+
// character it spells.
|
|
282
|
+
constructor(text) {
|
|
283
|
+
this.text = text;
|
|
284
|
+
this.at = 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
skip() {
|
|
288
|
+
while (this.at < this.text.length && " \t\r\n".includes(this.text[this.at])) this.at++;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
bad(reason = "invalid_json") {
|
|
292
|
+
return new InvalidDomainKeys(reason);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
value(level) {
|
|
296
|
+
this.skip();
|
|
297
|
+
const c = this.text[this.at];
|
|
298
|
+
if (c === "{") return this.members(level);
|
|
299
|
+
if (c === "[") return this.rows(level);
|
|
300
|
+
if (c === '"') return this.string();
|
|
301
|
+
for (const [word, meaning] of [["true", true], ["false", false], ["null", null]]) {
|
|
302
|
+
if (this.text.startsWith(word, this.at)) {
|
|
303
|
+
this.at += word.length;
|
|
304
|
+
return meaning;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return this.number();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
members(level) {
|
|
311
|
+
if (level > DOMAIN_MAX_DEPTH) throw this.bad("depth_limit");
|
|
312
|
+
this.at++;
|
|
313
|
+
const found = new Map();
|
|
314
|
+
this.skip();
|
|
315
|
+
if (this.text[this.at] === "}") {
|
|
316
|
+
this.at++;
|
|
317
|
+
return found;
|
|
318
|
+
}
|
|
319
|
+
for (;;) {
|
|
320
|
+
this.skip();
|
|
321
|
+
if (this.text[this.at] !== '"') throw this.bad();
|
|
322
|
+
const name = this.string();
|
|
323
|
+
if (found.has(name)) throw this.bad("duplicate_member");
|
|
324
|
+
this.skip();
|
|
325
|
+
if (this.text[this.at] !== ":") throw this.bad();
|
|
326
|
+
this.at++;
|
|
327
|
+
found.set(name, this.value(level + 1));
|
|
328
|
+
this.skip();
|
|
329
|
+
const next = this.text[this.at++];
|
|
330
|
+
if (next === "}") return found;
|
|
331
|
+
if (next !== ",") throw this.bad();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
rows(level) {
|
|
336
|
+
if (level > DOMAIN_MAX_DEPTH) throw this.bad("depth_limit");
|
|
337
|
+
this.at++;
|
|
338
|
+
const found = [];
|
|
339
|
+
this.skip();
|
|
340
|
+
if (this.text[this.at] === "]") {
|
|
341
|
+
this.at++;
|
|
342
|
+
return found;
|
|
343
|
+
}
|
|
344
|
+
for (;;) {
|
|
345
|
+
found.push(this.value(level + 1));
|
|
346
|
+
this.skip();
|
|
347
|
+
const next = this.text[this.at++];
|
|
348
|
+
if (next === "]") return found;
|
|
349
|
+
if (next !== ",") throw this.bad();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
string() {
|
|
354
|
+
const simple = { '"': '"', "\\": "\\", "/": "/", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t" };
|
|
355
|
+
this.at++;
|
|
356
|
+
let out = "";
|
|
357
|
+
for (;;) {
|
|
358
|
+
if (this.at >= this.text.length) throw this.bad();
|
|
359
|
+
const c = this.text[this.at++];
|
|
360
|
+
if (c === '"') return out;
|
|
361
|
+
if (c < " ") throw this.bad();
|
|
362
|
+
if (c !== "\\") {
|
|
363
|
+
out += c;
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const escape = this.text[this.at++];
|
|
367
|
+
if (escape === "u") {
|
|
368
|
+
const digits = this.text.slice(this.at, this.at + 4);
|
|
369
|
+
if (!/^[0-9a-fA-F]{4}$/.test(digits)) throw this.bad();
|
|
370
|
+
this.at += 4;
|
|
371
|
+
out += String.fromCharCode(parseInt(digits, 16));
|
|
372
|
+
} else if (Object.hasOwn(simple, escape ?? "")) {
|
|
373
|
+
out += simple[escape];
|
|
374
|
+
} else {
|
|
375
|
+
throw this.bad();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
number() {
|
|
381
|
+
const rest = this.text.slice(this.at);
|
|
382
|
+
const found = /^-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?/.exec(rest);
|
|
383
|
+
if (found === null) throw this.bad();
|
|
384
|
+
this.at += found[0].length;
|
|
385
|
+
if (found[1] !== undefined || found[2] !== undefined) {
|
|
386
|
+
if (!Number.isFinite(Number(found[0]))) throw this.bad("non_finite_number");
|
|
387
|
+
return Number(found[0]);
|
|
388
|
+
}
|
|
389
|
+
const value = BigInt(found[0]);
|
|
390
|
+
if (value > DOMAIN_MAX_INTEGER || value < -DOMAIN_MAX_INTEGER) throw this.bad("integer_range");
|
|
391
|
+
return value;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The entries of a version 1 list, as { key } or { key, name }, from the exact
|
|
397
|
+
* bytes. A duplicate key, a duplicate member name in any object or one bad entry
|
|
398
|
+
* makes the whole document invalid: a list is never valid with the bad part left
|
|
399
|
+
* out. Throws InvalidDomainKeys.
|
|
400
|
+
*/
|
|
401
|
+
export function parseDomainKeys(raw) {
|
|
402
|
+
if (!(raw instanceof Uint8Array)) throw new InvalidDomainKeys("not_bytes");
|
|
403
|
+
if (raw.length > DOMAIN_KEYS_MAX_BYTES) throw new InvalidDomainKeys("body_limit");
|
|
404
|
+
let text;
|
|
405
|
+
try {
|
|
406
|
+
text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(raw);
|
|
407
|
+
} catch {
|
|
408
|
+
throw new InvalidDomainKeys("not_utf8");
|
|
409
|
+
}
|
|
410
|
+
if (text.startsWith("\ufeff")) throw new InvalidDomainKeys("byte_order_mark");
|
|
411
|
+
const reader = new DomainReader(text);
|
|
412
|
+
const value = reader.value(1);
|
|
413
|
+
reader.skip();
|
|
414
|
+
if (reader.at !== text.length) throw new InvalidDomainKeys("invalid_json");
|
|
415
|
+
if (!(value instanceof Map)) throw new InvalidDomainKeys("object_required");
|
|
416
|
+
// A BigInt and nothing looser: 1.0, 1e0 and true are not the integer 1.
|
|
417
|
+
if (value.get("aamio_keys") !== BigInt(DOMAIN_KEYS_VERSION)) throw new InvalidDomainKeys("unsupported_version");
|
|
418
|
+
const rows = value.get("keys");
|
|
419
|
+
if (!Array.isArray(rows) || rows.length > DOMAIN_KEYS_MAX_KEYS) throw new InvalidDomainKeys("invalid_keys");
|
|
420
|
+
const seen = new Set();
|
|
421
|
+
const entries = [];
|
|
422
|
+
for (const row of rows) {
|
|
423
|
+
if (!(row instanceof Map) || !canonicalKey(row.get("key"))) throw new InvalidDomainKeys("invalid_key");
|
|
424
|
+
const key = row.get("key");
|
|
425
|
+
if (seen.has(key)) throw new InvalidDomainKeys("duplicate_key");
|
|
426
|
+
seen.add(key);
|
|
427
|
+
const entry = { key };
|
|
428
|
+
// has, since a name of null is a bad name and not a missing one.
|
|
429
|
+
if (row.has("name")) {
|
|
430
|
+
if (!validDomainKeyName(row.get("name"))) throw new InvalidDomainKeys("invalid_name");
|
|
431
|
+
entry.name = row.get("name");
|
|
432
|
+
}
|
|
433
|
+
entries.push(entry);
|
|
434
|
+
}
|
|
435
|
+
return entries;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Whether this exact document lists key, and under name when one is given.
|
|
440
|
+
*
|
|
441
|
+
* status is listed, not_listed or invalid. not_listed is no evidence of fraud: a
|
|
442
|
+
* key rotated out, the wrong name or the wrong domain look the same. The document
|
|
443
|
+
* hash is of the exact bytes compared, for comparison and fault finding; the
|
|
444
|
+
* domain did not sign it. The key is the caller's to choose and to answer for,
|
|
445
|
+
* which the result says as subject_source.
|
|
446
|
+
*/
|
|
447
|
+
export function matchDomainKey(raw, key, name = null) {
|
|
448
|
+
const subject_source = "supplied_key";
|
|
449
|
+
if (!canonicalKey(key)) return { status: "invalid", reason: "invalid_subject_key", subject_source };
|
|
450
|
+
if (name !== null && !validDomainKeyName(name)) return { status: "invalid", reason: "invalid_requested_name", subject_source };
|
|
451
|
+
let entries;
|
|
452
|
+
try {
|
|
453
|
+
entries = parseDomainKeys(raw);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (!(error instanceof InvalidDomainKeys)) throw error;
|
|
456
|
+
return { status: "invalid", reason: "invalid_document", detail: error.reason, subject_source };
|
|
457
|
+
}
|
|
458
|
+
const document_sha256 = sha256hex(raw);
|
|
459
|
+
for (const entry of entries) {
|
|
460
|
+
if (entry.key === key && (name === null || entry.name === name)) {
|
|
461
|
+
return { status: "listed", reason: "key_listed", matched_name: entry.name ?? null, document_sha256, subject_source };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return { status: "not_listed", reason: "key_or_name_not_listed", document_sha256, subject_source };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* The file for a site to publish, as bytes. They are fixed, so every generator
|
|
469
|
+
* gives the same file for the same list: two spaces of indentation, aamio_keys
|
|
470
|
+
* before keys, key before name, ASCII only, and a line break at the end. An empty
|
|
471
|
+
* list is allowed, since that is how a site says it lists no key, so callers ask
|
|
472
|
+
* for it on purpose. A list this would refuse to read is refused here as well.
|
|
473
|
+
*/
|
|
474
|
+
export function makeDomainKeys(entries) {
|
|
475
|
+
const rows = [];
|
|
476
|
+
const seen = new Set();
|
|
477
|
+
for (const entry of entries) {
|
|
478
|
+
if (!canonicalKey(entry?.key)) throw new InvalidDomainKeys("invalid_key");
|
|
479
|
+
if (seen.has(entry.key)) throw new InvalidDomainKeys("duplicate_key");
|
|
480
|
+
seen.add(entry.key);
|
|
481
|
+
let row = ' {\n "key": "' + entry.key + '"';
|
|
482
|
+
if (entry.name !== undefined && entry.name !== null) {
|
|
483
|
+
if (!validDomainKeyName(entry.name)) throw new InvalidDomainKeys("invalid_name");
|
|
484
|
+
row += ',\n "name": "' + entry.name + '"';
|
|
485
|
+
}
|
|
486
|
+
rows.push(row + "\n }");
|
|
487
|
+
}
|
|
488
|
+
if (rows.length > DOMAIN_KEYS_MAX_KEYS) throw new InvalidDomainKeys("invalid_keys");
|
|
489
|
+
const list = rows.length === 0 ? "[]" : "[\n" + rows.join(",\n") + "\n ]";
|
|
490
|
+
const raw = utf8('{\n "aamio_keys": ' + DOMAIN_KEYS_VERSION + ',\n "keys": ' + list + "\n}\n");
|
|
491
|
+
parseDomainKeys(raw);
|
|
492
|
+
return raw;
|
|
493
|
+
}
|
|
494
|
+
|
|
201
495
|
export const threadSigningInput = (w, bodyText) => "aamio-v1\n" + w + "\n" + sha256hex(bodyText);
|
|
202
496
|
export const presenceSigningInput = (key, bodyText) => "aamio-presence-v1\n" + key + "\n" + sha256hex(bodyText);
|
|
203
497
|
export const presenceDeleteSigningInput = (key, bodyText) => "aamio-presence-delete-v1\n" + key + "\n" + sha256hex(bodyText);
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdtemp, open, rename, rmdir, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { Keys } from "./aamio.js";
|
|
7
|
+
|
|
8
|
+
function identityError(code, message) {
|
|
9
|
+
return Object.assign(new Error(message), { code });
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const missingHome = () => identityError("home_not_initialized", "No Aamio home exists at the selected path.");
|
|
13
|
+
const badKey = () => identityError("key_unreadable", "The Aamio home must contain a readable key file with a 64-character lowercase hexadecimal seed.");
|
|
14
|
+
const initFailed = () => identityError("home_init_failed", "Could not create a private Aamio home. Choose a new directory under an existing writable parent on a local disk.");
|
|
15
|
+
|
|
16
|
+
function publicIdentity(keys) {
|
|
17
|
+
return { key: keys.public, hash: keys.hash, hash_prefix: keys.hash.slice(0, 8) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Read only the public identity of a local Aamio home. No runtime or owner lock is opened. */
|
|
21
|
+
export async function readIdentity(home) {
|
|
22
|
+
return publicIdentity(await readKeys(home));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The keys of a local Aamio home, for a runtime that holds the home. The seed stays in memory. */
|
|
26
|
+
export async function readKeys(home) {
|
|
27
|
+
if (typeof home !== "string" || !home) throw missingHome();
|
|
28
|
+
try {
|
|
29
|
+
await lstat(home);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code === "ENOENT") throw missingHome();
|
|
32
|
+
throw badKey();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const file = join(home, "key");
|
|
36
|
+
let handle;
|
|
37
|
+
try {
|
|
38
|
+
if (!(await lstat(file)).isFile()) throw badKey();
|
|
39
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
40
|
+
const stat = await handle.stat();
|
|
41
|
+
if (!stat.isFile() || stat.size < 64 || stat.size > 4096) throw badKey();
|
|
42
|
+
// Bound the read even if a different process changes the file after stat().
|
|
43
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
44
|
+
let used = 0;
|
|
45
|
+
while (used < bytes.length) {
|
|
46
|
+
const { bytesRead } = await handle.read(bytes, used, bytes.length - used, used);
|
|
47
|
+
if (bytesRead === 0) break;
|
|
48
|
+
used += bytesRead;
|
|
49
|
+
}
|
|
50
|
+
if (used !== stat.size) throw badKey();
|
|
51
|
+
const match = /^[ \t\v\r\n]*([0-9a-f]{64})[ \t\v\r\n]*$/.exec(bytes.toString("utf8", 0, used));
|
|
52
|
+
if (!match) throw badKey();
|
|
53
|
+
return Keys.fromSeedHex(match[1]);
|
|
54
|
+
} catch {
|
|
55
|
+
throw badKey();
|
|
56
|
+
} finally {
|
|
57
|
+
if (handle) await handle.close();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// This empty folder receives the seed only after its DACL is protected and verified.
|
|
62
|
+
// The key inherits the current user's rule. No path or key is interpolated into a command.
|
|
63
|
+
const windowsAcl = `
|
|
64
|
+
$ErrorActionPreference = 'Stop'
|
|
65
|
+
$path = $env:AAMIO_INIT_STAGE
|
|
66
|
+
$me = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
|
|
67
|
+
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
68
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
69
|
+
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
70
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($me, 'FullControl', $inherit, 'None', 'Allow')
|
|
71
|
+
$acl.AddAccessRule($rule)
|
|
72
|
+
[System.IO.Directory]::SetAccessControl($path, $acl)
|
|
73
|
+
$actual = [System.IO.Directory]::GetAccessControl($path)
|
|
74
|
+
$rules = $actual.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])
|
|
75
|
+
if (-not $actual.AreAccessRulesProtected -or $rules.Count -ne 1 -or
|
|
76
|
+
$rules[0].IdentityReference.Value -ne $me.Value -or $rules[0].AccessControlType -ne 'Allow' -or
|
|
77
|
+
$rules[0].InheritanceFlags -ne $inherit -or $rules[0].FileSystemRights -ne 'FullControl') { exit 1 }
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
async function protectWindowsFolder(folder) {
|
|
81
|
+
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
|
82
|
+
const powershell = join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
|
83
|
+
await promisify(execFile)(powershell, ["-NoProfile", "-NoLogo", "-NonInteractive", "-Command", windowsAcl], {
|
|
84
|
+
windowsHide: true, timeout: 15000,
|
|
85
|
+
env: { SystemRoot: systemRoot, AAMIO_INIT_STAGE: folder },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function syncFolder(folder) {
|
|
90
|
+
if (process.platform === "win32") return;
|
|
91
|
+
const directory = await open(folder, constants.O_RDONLY);
|
|
92
|
+
try {
|
|
93
|
+
await directory.sync();
|
|
94
|
+
} finally {
|
|
95
|
+
await directory.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function discardStage(folder) {
|
|
100
|
+
// These are the only two paths this invocation created. Never walk a home or remove its locks.
|
|
101
|
+
try {
|
|
102
|
+
await unlink(join(folder, "key"));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (error.code !== "ENOENT") throw initFailed();
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
await rmdir(folder);
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (error.code !== "ENOENT") throw initFailed();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Initialize an absent home, or read its existing identity. No other runtime state is written. */
|
|
114
|
+
export async function initIdentity(home) {
|
|
115
|
+
if (typeof home !== "string" || !home) throw missingHome();
|
|
116
|
+
const destination = resolve(home);
|
|
117
|
+
try {
|
|
118
|
+
return await readIdentity(destination);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error.code !== "home_not_initialized") throw error;
|
|
121
|
+
}
|
|
122
|
+
const parent = dirname(destination);
|
|
123
|
+
let stage;
|
|
124
|
+
try {
|
|
125
|
+
stage = await mkdtemp(join(parent, ".aamio-init-"));
|
|
126
|
+
if (process.platform === "win32") await protectWindowsFolder(stage);
|
|
127
|
+
const keys = Keys.generate();
|
|
128
|
+
const keyFile = await open(join(stage, "key"), "wx", 0o600);
|
|
129
|
+
try {
|
|
130
|
+
await keyFile.writeFile(Buffer.from(keys.seed).toString("hex") + "\n", "utf8");
|
|
131
|
+
await keyFile.sync();
|
|
132
|
+
} finally {
|
|
133
|
+
await keyFile.close();
|
|
134
|
+
}
|
|
135
|
+
await syncFolder(stage);
|
|
136
|
+
try {
|
|
137
|
+
return await readIdentity(destination);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error.code !== "home_not_initialized") throw error;
|
|
140
|
+
}
|
|
141
|
+
// POSIX rename can replace a completely empty directory created in this last instant.
|
|
142
|
+
// It cannot replace a directory containing a key, an owner lock, or any runtime state.
|
|
143
|
+
try {
|
|
144
|
+
await rename(stage, destination);
|
|
145
|
+
} catch (moveError) {
|
|
146
|
+
try {
|
|
147
|
+
return await readIdentity(destination);
|
|
148
|
+
} catch (readError) {
|
|
149
|
+
if (readError.code === "key_unreadable") throw readError;
|
|
150
|
+
throw moveError;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
stage = undefined;
|
|
154
|
+
await syncFolder(parent);
|
|
155
|
+
return publicIdentity(keys);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (error.code === "key_unreadable") throw error;
|
|
158
|
+
throw initFailed();
|
|
159
|
+
} finally {
|
|
160
|
+
if (stage) await discardStage(stage);
|
|
161
|
+
}
|
|
162
|
+
}
|
package/src/node-lock.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { open, readFile, realpath, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { once } from "node:events";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { DEFAULT_BASE } from "./aamio.js";
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
* One live runtime per home, whichever client it is. aamio-python and
|
|
11
|
+
* aamio-php take the operating system's lock on owner.lock for as long as they
|
|
12
|
+
* run: flock() on Linux and macOS, a lock on its first byte on Windows, which
|
|
13
|
+
* PHP's flock() covers by locking the whole file. The system lets go of it when
|
|
14
|
+
* the process ends, however it ends, so no lock outlives its owner and none is
|
|
15
|
+
* ever taken by age.
|
|
16
|
+
*
|
|
17
|
+
* Node has no flock(). Each system gets the nearest thing the other clients'
|
|
18
|
+
* lock sees, so that a home has one owner whichever client starts first:
|
|
19
|
+
*
|
|
20
|
+
* Windows owner.lock opened with no sharing: libuv's UV_FS_O_EXLOCK, which
|
|
21
|
+
* Node passes through but does not name. While Python or PHP holds
|
|
22
|
+
* the file open and locked, the open fails with EBUSY. While Node
|
|
23
|
+
* holds it, they cannot open it, and refuse the home.
|
|
24
|
+
* Linux util-linux flock locks a duplicate of Node's open file descriptor
|
|
25
|
+
* and exits. The lock stays on the shared open file description
|
|
26
|
+
* until Node closes its handle or exits.
|
|
27
|
+
* BSD O_EXLOCK with the open, which is flock()'s lock, on macOS and the
|
|
28
|
+
* BSDs. Not yet tried on a Mac.
|
|
29
|
+
*
|
|
30
|
+
* Where none of these can be had, the home is not taken: held by nobody and
|
|
31
|
+
* not by this one either, nothing proves it free.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export const OWNER_LOCK = "owner.lock";
|
|
35
|
+
// The pid file the other clients write beside owner.lock. It names the owner
|
|
36
|
+
// in the message a second runtime gives, and os_lock says its writer held the
|
|
37
|
+
// lock, so a runtime that holds the lock now knows that writer is gone.
|
|
38
|
+
export const PID_FILE = "lock";
|
|
39
|
+
|
|
40
|
+
// libuv opens the file with no sharing for this flag on Windows.
|
|
41
|
+
const WINDOWS_NO_SHARING = 0x10000000;
|
|
42
|
+
// BSD's O_EXLOCK, the same value on macOS, FreeBSD and OpenBSD.
|
|
43
|
+
const BSD_EXLOCK = 0x20;
|
|
44
|
+
const BSD = ["darwin", "freebsd", "openbsd"];
|
|
45
|
+
|
|
46
|
+
// Homes this process holds, for a plainer message when it asks twice.
|
|
47
|
+
const held = new Set();
|
|
48
|
+
|
|
49
|
+
function lockError(code, message) {
|
|
50
|
+
return Object.assign(new Error(message), { code });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function unavailable(home, path, why) {
|
|
54
|
+
return lockError("lock_unavailable",
|
|
55
|
+
`cannot establish exclusive ownership of ${home}: ${path} could not be opened or locked (${why}). ` +
|
|
56
|
+
"Restore file access or locking support, or use a different AAMIO_HOME on a local disk.");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Opens owner.lock in a way that only one process can hold it. The holder, or null when another holds it. */
|
|
60
|
+
async function holdWithOpen(home, path, flags, busy) {
|
|
61
|
+
let handle;
|
|
62
|
+
try {
|
|
63
|
+
handle = await open(path, constants.O_RDWR | constants.O_CREAT | flags, 0o600);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (busy.includes(error.code)) return null;
|
|
66
|
+
throw unavailable(home, path, error.code || error.message);
|
|
67
|
+
}
|
|
68
|
+
return { release: () => handle.close() };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Pass Node's descriptor as fd 3. flock(1) exits before the caller starts serving. */
|
|
72
|
+
async function holdWithFlock(home, path, command) {
|
|
73
|
+
const conflict = 42; // Outside util-linux's sysexits range and distinct from generic failure (1).
|
|
74
|
+
let handle;
|
|
75
|
+
try {
|
|
76
|
+
handle = await open(path, constants.O_RDWR | constants.O_CREAT, 0o600);
|
|
77
|
+
const child = spawn(command, ["-x", "-n", "-E", String(conflict), "3"], {
|
|
78
|
+
stdio: ["ignore", "ignore", "pipe", handle.fd],
|
|
79
|
+
});
|
|
80
|
+
let diagnostic = "";
|
|
81
|
+
child.stderr.setEncoding("utf8");
|
|
82
|
+
child.stderr.on("data", (chunk) => { diagnostic += chunk; });
|
|
83
|
+
const [code, signal] = await once(child, "close");
|
|
84
|
+
if (code === 0) return { release: () => handle.close() };
|
|
85
|
+
if (code !== conflict) {
|
|
86
|
+
throw new Error(diagnostic.trim() || `flock ended with ${signal || code}`);
|
|
87
|
+
}
|
|
88
|
+
} catch (error) {
|
|
89
|
+
await handle?.close();
|
|
90
|
+
const reason = handle && error.code === "ENOENT"
|
|
91
|
+
? "the flock command was not found: install util-linux"
|
|
92
|
+
: error.code || error.message;
|
|
93
|
+
throw unavailable(home, path, reason);
|
|
94
|
+
}
|
|
95
|
+
await handle.close();
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The operating system's lock on owner.lock. The holder, or null when another process holds it. */
|
|
100
|
+
export async function holdOwnerLock(home, { flock = "flock", platform = process.platform } = {}) {
|
|
101
|
+
const path = join(home, OWNER_LOCK);
|
|
102
|
+
if (platform === "win32") return holdWithOpen(home, path, WINDOWS_NO_SHARING, ["EBUSY"]);
|
|
103
|
+
if (BSD.includes(platform)) return holdWithOpen(home, path, BSD_EXLOCK | constants.O_NONBLOCK, ["EAGAIN", "EWOULDBLOCK"]);
|
|
104
|
+
if (platform === "linux") return holdWithFlock(home, path, flock);
|
|
105
|
+
throw unavailable(home, path, `no lock the other clients see is known for ${platform}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readPidFile(home) {
|
|
109
|
+
try {
|
|
110
|
+
const value = JSON.parse(await readFile(join(home, PID_FILE), "utf8"));
|
|
111
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function writePidFile(home, value) {
|
|
118
|
+
const path = join(home, PID_FILE);
|
|
119
|
+
const temporary = join(home, `lock.${randomUUID()}.tmp`);
|
|
120
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
121
|
+
try {
|
|
122
|
+
try {
|
|
123
|
+
await handle.writeFile(JSON.stringify(value, null, 2), "utf8");
|
|
124
|
+
await handle.sync();
|
|
125
|
+
} finally {
|
|
126
|
+
await handle.close();
|
|
127
|
+
}
|
|
128
|
+
await rename(temporary, path);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
await unlink(temporary).catch(() => {});
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** True, false, or null when it cannot be told: a pid that answers signal 0 is alive, and one that is not there is not. */
|
|
136
|
+
function pidAlive(pid) {
|
|
137
|
+
try {
|
|
138
|
+
process.kill(pid, 0);
|
|
139
|
+
return true;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error.code === "ESRCH") return false;
|
|
142
|
+
if (error.code === "EPERM") return true;
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* An owner the lock cannot see: a client from before owner.lock, which wrote
|
|
149
|
+
* the pid file alone. One that held the lock says so with os_lock, and is gone,
|
|
150
|
+
* since this process holds the lock now. Not known to be gone is not gone.
|
|
151
|
+
*/
|
|
152
|
+
async function checkOlderOwner(home) {
|
|
153
|
+
const last = await readPidFile(home);
|
|
154
|
+
if (!last || !Number.isSafeInteger(last.pid) || last.pid === process.pid || last.os_lock === true) return;
|
|
155
|
+
const alive = pidAlive(last.pid);
|
|
156
|
+
if (alive === null) {
|
|
157
|
+
throw lockError("home_in_use",
|
|
158
|
+
`could not determine whether aamio (pid ${last.pid}) is still using ${home}; the lock is left untouched. ` +
|
|
159
|
+
"Stop the other runtime, or use a different AAMIO_HOME. Do not remove the lock unless you have confirmed its owner has stopped.");
|
|
160
|
+
}
|
|
161
|
+
if (alive) {
|
|
162
|
+
throw lockError("home_in_use",
|
|
163
|
+
`another aamio (pid ${last.pid}) is using ${home}. Stop it, or use a different AAMIO_HOME. If no aamio is running, ` +
|
|
164
|
+
`the one that took the lock stopped without letting go of it: delete ${join(home, PID_FILE)} and start again.`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Takes a home for this process, as the other clients do: owner.lock held,
|
|
170
|
+
* and the pid file written with os_lock. Throws home_in_use when another
|
|
171
|
+
* runtime has it, and lock_unavailable when the lock cannot be had at all.
|
|
172
|
+
* release() lets go of both, the pid file first, so a runtime that takes the
|
|
173
|
+
* lock the moment it is free finds none of this one's to wonder about.
|
|
174
|
+
*/
|
|
175
|
+
export async function takeHome(home, { host = process.env.AAMIO_HOST || DEFAULT_BASE, ...options } = {}) {
|
|
176
|
+
const real = await realpath(home);
|
|
177
|
+
if (held.has(real)) throw lockError("home_in_use", `another aamio in this process is already using ${home}`);
|
|
178
|
+
const holder = await holdOwnerLock(home, options);
|
|
179
|
+
if (holder === null) {
|
|
180
|
+
const last = await readPidFile(home);
|
|
181
|
+
const named = Number.isSafeInteger(last?.pid) ? ` (pid ${last.pid})` : "";
|
|
182
|
+
throw lockError("home_in_use", `another aamio${named} is using ${home}. Stop it, or use a different AAMIO_HOME.`);
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await checkOlderOwner(home);
|
|
186
|
+
await writePidFile(home, { pid: process.pid, at: Math.floor(Date.now() / 1000), host, os_lock: true });
|
|
187
|
+
} catch (error) {
|
|
188
|
+
await holder.release();
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
held.add(real);
|
|
192
|
+
let released = false;
|
|
193
|
+
return {
|
|
194
|
+
async release() {
|
|
195
|
+
if (released) return;
|
|
196
|
+
released = true;
|
|
197
|
+
const last = await readPidFile(home);
|
|
198
|
+
if (last?.pid === process.pid) await unlink(join(home, PID_FILE)).catch(() => {});
|
|
199
|
+
await holder.release();
|
|
200
|
+
held.delete(real);
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|