@openlfcp/storage-node 0.2.2 → 0.3.1
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 +45 -1
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +9 -0
- package/dist/identity-document.d.ts +4 -0
- package/dist/identity-document.js +28 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +5 -1
- package/dist/installation.d.ts +5 -0
- package/dist/installation.js +57 -0
- package/dist/pair.d.ts +51 -0
- package/dist/pair.js +260 -0
- package/package.json +11 -4
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@ Durable Node.js storage for LFCP clients (LFCP-035):
|
|
|
5
5
|
- `SqliteLfcpStorage`, the `LfcpStorage` interface of `@openlfcp/storage` on
|
|
6
6
|
SQLite through [better-sqlite3](https://github.com/WiseLibs/better-sqlite3)
|
|
7
7
|
13.0.3;
|
|
8
|
-
- `FileSecretStore`, a `SecretStore` in a local directory
|
|
8
|
+
- `FileSecretStore`, a `SecretStore` in a local directory;
|
|
9
|
+
- `lfcp-node pair`, the headless pairing of a service device (a bridge or a
|
|
10
|
+
bot), on that storage and `@openlfcp/client`.
|
|
9
11
|
|
|
10
12
|
**This package is Node only.** It is meant for headless Node clients, the CLI,
|
|
11
13
|
the examples and tests. It is the only sdk-ts package allowed to use `node:*`
|
|
@@ -58,6 +60,48 @@ The driver ships prebuilt Node-API binaries for macOS, Linux (glibc and musl)
|
|
|
58
60
|
and Windows on x64 and arm64. Installing needs no compiler and no install
|
|
59
61
|
script, and the same binary loads in Electron's main process.
|
|
60
62
|
|
|
63
|
+
## Headless pairing: `lfcp-node pair`
|
|
64
|
+
|
|
65
|
+
A bridge or bot joins its human as a **service device** (IDENTITY-PROFILE-01
|
|
66
|
+
§5.3 "Headless installation", §10): its own Principal and secret store,
|
|
67
|
+
certificate kind 1, mask `{1, 2}` (the Service preset: read and write), only
|
|
68
|
+
in the sections its human chose and the managing device approved. It never
|
|
69
|
+
reads the Identity Resource's roster or device list and cannot invite,
|
|
70
|
+
revoke, rotate, publish Snapshots, add devices or create Resources.
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
# The managing device shows a link ("Add my other device…"); pipe it in, so it
|
|
74
|
+
# stays out of the shell history and the process list.
|
|
75
|
+
lfcp-node pair - --state /var/lib/notes-bridge --label "notes bridge" \
|
|
76
|
+
--section "Notes" --section "Tasks" < link.txt
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```text
|
|
80
|
+
Fingerprint: bicr-ikmc-oec2-llzz
|
|
81
|
+
Compare it with the fingerprint the approving device shows before approving.
|
|
82
|
+
Requested: "notes bridge" (service) asks to join Notes, Tasks; access: read and write.
|
|
83
|
+
Waiting for the approval on the managing device…
|
|
84
|
+
Approved: Service (service device).
|
|
85
|
+
Notes: ready
|
|
86
|
+
Tasks: not granted
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `--state <dir>` holds this installation: `lfcp.sqlite`, `secrets/` and
|
|
90
|
+
`installation.json` (its Principal ID; the keys are in `secrets/`, created on
|
|
91
|
+
the first run). One directory is one installation: never copy it (W §7.1).
|
|
92
|
+
- `--section <name>` (repeatable) names a section by its roster name; a name
|
|
93
|
+
that is not in the roster, or names several Resources, writes no request
|
|
94
|
+
(exit 4, with the names it may ask for). `--personal` pairs a personal
|
|
95
|
+
device instead (the approver picks its preset).
|
|
96
|
+
- The approver compares the fingerprint, may remove sections (never add any),
|
|
97
|
+
and approves. The result comes from the chain alone: `--wait <seconds>`
|
|
98
|
+
(default 600) bounds how long the command waits, and a timeout is never a
|
|
99
|
+
decision. Run the same command again to resume: the request is journaled,
|
|
100
|
+
and after the approval the link is no longer needed.
|
|
101
|
+
- Exit codes: 0 approved and every section ready or not granted; 1 the link
|
|
102
|
+
is rejected or the request refused; 2 rejected by the managing device; 3
|
|
103
|
+
still waiting; 4 unknown sections; 64 usage.
|
|
104
|
+
|
|
61
105
|
## Secrets
|
|
62
106
|
|
|
63
107
|
```ts
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// lfcp-node: the headless commands of @openlfcp/storage-node. `pair` pairs
|
|
3
|
+
// this installation as a service device (IDENTITY-PROFILE-01 §5.3).
|
|
4
|
+
import { main, readStdin } from "./pair.js";
|
|
5
|
+
process.exitCode = await main(process.argv.slice(2), {
|
|
6
|
+
out: (line) => process.stdout.write(`${line}\n`),
|
|
7
|
+
stdin: readStdin,
|
|
8
|
+
});
|
|
9
|
+
//# sourceMappingURL=cli.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The Identity Resource document (IDENTITY-PROFILE-01 §§2–4) for the
|
|
2
|
+
// headless identity flows: @openlfcp/client's IdentityDocumentPort over
|
|
3
|
+
// @openlfcp/shared-objects' IdentityReplica, the same document every
|
|
4
|
+
// identity-aware client reads and writes.
|
|
5
|
+
import { IdentityReplica, identityCodec, } from "@openlfcp/shared-objects/identity";
|
|
6
|
+
/** A fresh document of Identity Resource `resource` owned by `root`, for one flow. */
|
|
7
|
+
export const identityDocument = (resource, root) => {
|
|
8
|
+
const replica = IdentityReplica.empty({ resource, root });
|
|
9
|
+
const writer = (a) => ({
|
|
10
|
+
signer: a.signer,
|
|
11
|
+
actorSeq: a.actorSeq,
|
|
12
|
+
writerClass: a.writerClass,
|
|
13
|
+
});
|
|
14
|
+
const map = (o) => new Map(Object.entries(o));
|
|
15
|
+
return {
|
|
16
|
+
write: (author, intents) => identityCodec.encode(replica.write(writer(author), intents)),
|
|
17
|
+
receive: (plaintext, author) => {
|
|
18
|
+
const r = replica.receive(plaintext, writer(author));
|
|
19
|
+
return r.admitted.length + r.duplicates.length > 0;
|
|
20
|
+
},
|
|
21
|
+
initialized: () => replica.validate().usable && replica.heads().length > 0,
|
|
22
|
+
roster: () => map(replica.validate().roster),
|
|
23
|
+
devices: () => map(replica.validate().devices),
|
|
24
|
+
requests: () => map(replica.validate().requests),
|
|
25
|
+
origins: (table, key) => replica.origins(table, key),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=identity-document.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Durable Node.js storage for LFCP clients (LFCP-035): LfcpStorage on
|
|
3
3
|
* SQLite and a file SecretStore. Node only: for headless Node, the CLI,
|
|
4
4
|
* examples and tests. Obsidian uses its own adapter (LFCP-059), which runs
|
|
5
|
-
* the same contract suite.
|
|
5
|
+
* the same contract suite. Also the headless `lfcp-node pair` command of a
|
|
6
|
+
* service device (IDENTITY-PROFILE-01 §5.3, LFCP-04-412).
|
|
6
7
|
*/
|
|
7
8
|
export declare const PACKAGE = "@openlfcp/storage-node";
|
|
9
|
+
export { identityDocument } from "./identity-document.js";
|
|
10
|
+
export { openInstallation } from "./installation.js";
|
|
11
|
+
export { main, PAIR_EXIT, PAIR_USAGE, type PairArguments, type PairEnvironment, pair, parsePairArguments, } from "./pair.js";
|
|
8
12
|
export { MIGRATIONS, migrate, SCHEMA_VERSION, schemaVersion } from "./schema.js";
|
|
9
13
|
export { FileSecretStore } from "./secrets.js";
|
|
10
14
|
export { SqliteLfcpStorage, type SqliteLocalState, type SqliteStorageOptions, } from "./sqlite.js";
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,13 @@
|
|
|
2
2
|
* Durable Node.js storage for LFCP clients (LFCP-035): LfcpStorage on
|
|
3
3
|
* SQLite and a file SecretStore. Node only: for headless Node, the CLI,
|
|
4
4
|
* examples and tests. Obsidian uses its own adapter (LFCP-059), which runs
|
|
5
|
-
* the same contract suite.
|
|
5
|
+
* the same contract suite. Also the headless `lfcp-node pair` command of a
|
|
6
|
+
* service device (IDENTITY-PROFILE-01 §5.3, LFCP-04-412).
|
|
6
7
|
*/
|
|
7
8
|
export const PACKAGE = "@openlfcp/storage-node";
|
|
9
|
+
export { identityDocument } from "./identity-document.js";
|
|
10
|
+
export { openInstallation } from "./installation.js";
|
|
11
|
+
export { main, PAIR_EXIT, PAIR_USAGE, pair, parsePairArguments, } from "./pair.js";
|
|
8
12
|
export { MIGRATIONS, migrate, SCHEMA_VERSION, schemaVersion } from "./schema.js";
|
|
9
13
|
export { FileSecretStore } from "./secrets.js";
|
|
10
14
|
export { SqliteLfcpStorage, } from "./sqlite.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Installation } from "@openlfcp/client";
|
|
2
|
+
import { type SecretStore } from "@openlfcp/storage";
|
|
3
|
+
/** This state directory's installation, created on first use. */
|
|
4
|
+
export declare function openInstallation(dir: string, secrets: SecretStore): Promise<Installation>;
|
|
5
|
+
//# sourceMappingURL=installation.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The installation's own Principal for a headless client (LFCP-04-412): its
|
|
2
|
+
// Ed25519 and X25519 keys in the SecretStore (principalKeySecretRef), its
|
|
3
|
+
// public Principal ID in <state>/installation.json. Created once, on the
|
|
4
|
+
// first run; a copied state directory is a misconfiguration (a Principal
|
|
5
|
+
// has exactly one writing installation, W §7.1).
|
|
6
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { fromHex, principalId, toHex } from "@openlfcp/core";
|
|
9
|
+
import { exportSecretKeyBytes, generateAgreementKeyPair, generateSigningKeyPair, importAgreementKey, importSigningKey, } from "@openlfcp/crypto";
|
|
10
|
+
import { principalKeySecretRef } from "@openlfcp/storage";
|
|
11
|
+
import { principalDescriptorFromKeys } from "@openlfcp/wire";
|
|
12
|
+
const FILE = "installation.json";
|
|
13
|
+
/** This state directory's installation, created on first use. */
|
|
14
|
+
export async function openInstallation(dir, secrets) {
|
|
15
|
+
const path = join(dir, FILE);
|
|
16
|
+
if (existsSync(path)) {
|
|
17
|
+
const { principal } = JSON.parse(readFileSync(path, "utf8"));
|
|
18
|
+
const id = principalId(fromHex(principal));
|
|
19
|
+
const seed = await secrets.get(principalKeySecretRef(id, "signing"));
|
|
20
|
+
const scalar = await secrets.get(principalKeySecretRef(id, "agreement"));
|
|
21
|
+
if (seed === undefined || scalar === undefined)
|
|
22
|
+
throw new Error(`the keys of installation ${principal} are missing`);
|
|
23
|
+
try {
|
|
24
|
+
const key = importSigningKey(seed);
|
|
25
|
+
const agreement = importAgreementKey(scalar);
|
|
26
|
+
const descriptor = principalDescriptorFromKeys(key, agreement);
|
|
27
|
+
if (toHex(descriptor.principalId) !== principal)
|
|
28
|
+
throw new Error(`the keys do not derive installation ${principal}`);
|
|
29
|
+
return { signer: { key, descriptor }, agreement };
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
seed.fill(0);
|
|
33
|
+
scalar.fill(0);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const key = generateSigningKeyPair();
|
|
37
|
+
const agreement = generateAgreementKeyPair();
|
|
38
|
+
const descriptor = principalDescriptorFromKeys(key, agreement);
|
|
39
|
+
const id = descriptor.principalId;
|
|
40
|
+
for (const [which, k] of [
|
|
41
|
+
["signing", key],
|
|
42
|
+
["agreement", agreement],
|
|
43
|
+
]) {
|
|
44
|
+
const bytes = exportSecretKeyBytes(k);
|
|
45
|
+
try {
|
|
46
|
+
await secrets.put(principalKeySecretRef(id, which), bytes);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
bytes.fill(0);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// The keys are durable before the ID names them.
|
|
53
|
+
writeFileSync(`${path}.tmp`, `${JSON.stringify({ principal: toHex(id) })}\n`, { mode: 0o600 });
|
|
54
|
+
renameSync(`${path}.tmp`, path);
|
|
55
|
+
return { signer: { key, descriptor }, agreement };
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=installation.js.map
|
package/dist/pair.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type IdentityNetwork } from "@openlfcp/client";
|
|
2
|
+
/** Exit codes of `lfcp-node pair`. */
|
|
3
|
+
export declare const PAIR_EXIT: Readonly<{
|
|
4
|
+
/** Approved, and every requested section is ready or not granted. */
|
|
5
|
+
readonly DONE: 0;
|
|
6
|
+
/** An error: the link is rejected, the request refused, the state unusable. */
|
|
7
|
+
readonly FAILED: 1;
|
|
8
|
+
/** The session was cancelled by the managing device: rejected (final). */
|
|
9
|
+
readonly REJECTED: 2;
|
|
10
|
+
/** Still waiting (approval, registration or keys) when --wait ran out: run again to resume. */
|
|
11
|
+
readonly WAITING: 3;
|
|
12
|
+
/** A requested section is not in the roster, or names several Resources. */
|
|
13
|
+
readonly UNKNOWN_SECTIONS: 4;
|
|
14
|
+
/** Bad arguments. */
|
|
15
|
+
readonly USAGE: 64;
|
|
16
|
+
}>;
|
|
17
|
+
export interface PairArguments {
|
|
18
|
+
/** The pairing link, or "-" to read it from standard input (never from the shell history). */
|
|
19
|
+
readonly link: string;
|
|
20
|
+
/** The state directory: SQLite storage, secrets and the installation's Principal. */
|
|
21
|
+
readonly state: string;
|
|
22
|
+
readonly label: string;
|
|
23
|
+
/** 1 service (the default), 0 personal. */
|
|
24
|
+
readonly kind: 0 | 1;
|
|
25
|
+
/** Service: the requested sections by roster name. */
|
|
26
|
+
readonly sections: readonly string[];
|
|
27
|
+
readonly platform: string;
|
|
28
|
+
/** Seconds to wait for the result before exiting with WAITING; 0 sends the request only. */
|
|
29
|
+
readonly wait: number;
|
|
30
|
+
/** Seconds between two checks of the result. */
|
|
31
|
+
readonly poll: number;
|
|
32
|
+
}
|
|
33
|
+
export declare const PAIR_USAGE = "usage: lfcp-node pair <link|-> --state <dir> --label <label>\n [--section <roster name>]... [--personal]\n [--platform <name>] [--wait <seconds>] [--poll <seconds>]\n\nPairs this installation as a service device of the human whose managing\ndevice shows the link (IDENTITY-PROFILE-01 \u00A75.3). Prints the fingerprint to\ncompare with that device's screen, then waits for the approval. \"-\" reads the\nlink from standard input. Run the same command again to resume.";
|
|
34
|
+
/** Parses the arguments after "pair"; a string is the usage error. */
|
|
35
|
+
export declare function parsePairArguments(argv: readonly string[]): PairArguments | string;
|
|
36
|
+
export interface PairEnvironment {
|
|
37
|
+
/** One line of output for the human (never the link). */
|
|
38
|
+
readonly out: (line: string) => void;
|
|
39
|
+
/** Standard input, for a link given as "-". */
|
|
40
|
+
readonly stdin?: () => string;
|
|
41
|
+
readonly network?: IdentityNetwork;
|
|
42
|
+
readonly now?: () => number;
|
|
43
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
/** Runs `lfcp-node pair` with parsed arguments; returns its exit code. */
|
|
46
|
+
export declare function pair(args: PairArguments, env: PairEnvironment): Promise<number>;
|
|
47
|
+
/** `lfcp-node <command> ...`: the entry point of the bin; returns the exit code. */
|
|
48
|
+
export declare function main(argv: readonly string[], env: PairEnvironment): Promise<number>;
|
|
49
|
+
/** Standard input as text (for "-"). */
|
|
50
|
+
export declare const readStdin: () => string;
|
|
51
|
+
//# sourceMappingURL=pair.d.ts.map
|
package/dist/pair.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// `lfcp-node pair`: headless pairing of a new installation, the service
|
|
2
|
+
// device of a bridge or bot (IDENTITY-PROFILE-01 §5.3 "Headless
|
|
3
|
+
// installation", §10 service devices, decision I6). The label and the
|
|
4
|
+
// requested sections (by roster name) are arguments, the fingerprint is
|
|
5
|
+
// printed for the human to compare with the approving device's screen,
|
|
6
|
+
// and the result is learned from the chain alone (§5.3 step 8); a timeout
|
|
7
|
+
// is never a decision, and a rerun resumes from the journal.
|
|
8
|
+
//
|
|
9
|
+
// Public @openlfcp/client API only: requestPairing, completePairing and
|
|
10
|
+
// the operation journal, over SQLite storage and a file SecretStore.
|
|
11
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { completePairing, connectionNetwork, MASK_PRESETS, OperationJournal, PairingLinkText, requestPairing, } from "@openlfcp/client";
|
|
14
|
+
import { toBase64url } from "@openlfcp/core";
|
|
15
|
+
import { DEVICE_KIND, deviceFingerprint } from "@openlfcp/wire";
|
|
16
|
+
import { identityDocument } from "./identity-document.js";
|
|
17
|
+
import { openInstallation } from "./installation.js";
|
|
18
|
+
import { FileSecretStore } from "./secrets.js";
|
|
19
|
+
import { SqliteLfcpStorage } from "./sqlite.js";
|
|
20
|
+
/** Exit codes of `lfcp-node pair`. */
|
|
21
|
+
export const PAIR_EXIT = Object.freeze({
|
|
22
|
+
/** Approved, and every requested section is ready or not granted. */
|
|
23
|
+
DONE: 0,
|
|
24
|
+
/** An error: the link is rejected, the request refused, the state unusable. */
|
|
25
|
+
FAILED: 1,
|
|
26
|
+
/** The session was cancelled by the managing device: rejected (final). */
|
|
27
|
+
REJECTED: 2,
|
|
28
|
+
/** Still waiting (approval, registration or keys) when --wait ran out: run again to resume. */
|
|
29
|
+
WAITING: 3,
|
|
30
|
+
/** A requested section is not in the roster, or names several Resources. */
|
|
31
|
+
UNKNOWN_SECTIONS: 4,
|
|
32
|
+
/** Bad arguments. */
|
|
33
|
+
USAGE: 64,
|
|
34
|
+
});
|
|
35
|
+
export const PAIR_USAGE = `usage: lfcp-node pair <link|-> --state <dir> --label <label>
|
|
36
|
+
[--section <roster name>]... [--personal]
|
|
37
|
+
[--platform <name>] [--wait <seconds>] [--poll <seconds>]
|
|
38
|
+
|
|
39
|
+
Pairs this installation as a service device of the human whose managing
|
|
40
|
+
device shows the link (IDENTITY-PROFILE-01 §5.3). Prints the fingerprint to
|
|
41
|
+
compare with that device's screen, then waits for the approval. "-" reads the
|
|
42
|
+
link from standard input. Run the same command again to resume.`;
|
|
43
|
+
/** Parses the arguments after "pair"; a string is the usage error. */
|
|
44
|
+
export function parsePairArguments(argv) {
|
|
45
|
+
let link;
|
|
46
|
+
let state;
|
|
47
|
+
let label;
|
|
48
|
+
let kind = 1;
|
|
49
|
+
let platform = "server";
|
|
50
|
+
let wait = 600;
|
|
51
|
+
let poll = 5;
|
|
52
|
+
const sections = [];
|
|
53
|
+
const seconds = (name, v) => {
|
|
54
|
+
const n = Number(v);
|
|
55
|
+
return v !== undefined && Number.isInteger(n) && n >= 0 ? n : `${name} needs whole seconds`;
|
|
56
|
+
};
|
|
57
|
+
for (let i = 0; i < argv.length; i++) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
const value = () => argv[++i];
|
|
60
|
+
switch (a) {
|
|
61
|
+
case "--state":
|
|
62
|
+
state = value();
|
|
63
|
+
break;
|
|
64
|
+
case "--label":
|
|
65
|
+
label = value();
|
|
66
|
+
break;
|
|
67
|
+
case "--section": {
|
|
68
|
+
const s = value();
|
|
69
|
+
if (s === undefined || s === "")
|
|
70
|
+
return "--section needs a roster name";
|
|
71
|
+
sections.push(s);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
case "--personal":
|
|
75
|
+
kind = 0;
|
|
76
|
+
break;
|
|
77
|
+
case "--platform": {
|
|
78
|
+
const p = value();
|
|
79
|
+
if (p === undefined || p === "")
|
|
80
|
+
return "--platform needs a name";
|
|
81
|
+
platform = p;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "--wait": {
|
|
85
|
+
const n = seconds("--wait", value());
|
|
86
|
+
if (typeof n === "string")
|
|
87
|
+
return n;
|
|
88
|
+
wait = n;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
case "--poll": {
|
|
92
|
+
const n = seconds("--poll", value());
|
|
93
|
+
if (typeof n === "string")
|
|
94
|
+
return n;
|
|
95
|
+
if (n === 0)
|
|
96
|
+
return "--poll needs at least one second";
|
|
97
|
+
poll = n;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
default:
|
|
101
|
+
if (a.startsWith("--"))
|
|
102
|
+
return `unknown option ${a}`;
|
|
103
|
+
if (link !== undefined)
|
|
104
|
+
return "one link only";
|
|
105
|
+
link = a;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (link === undefined)
|
|
109
|
+
return "the pairing link is missing";
|
|
110
|
+
if (state === undefined || state === "")
|
|
111
|
+
return "--state is missing";
|
|
112
|
+
if (label === undefined || label === "")
|
|
113
|
+
return "--label is missing";
|
|
114
|
+
if (kind === 1 && sections.length === 0)
|
|
115
|
+
return "a service device needs at least one --section (or --personal)";
|
|
116
|
+
if (kind === 0 && sections.length > 0)
|
|
117
|
+
return "--section is for a service device only";
|
|
118
|
+
return { link, state, label, kind, sections, platform, wait, poll };
|
|
119
|
+
}
|
|
120
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
121
|
+
/** A reply timeout that does not keep the process alive once the command is done. */
|
|
122
|
+
const replyTimeout = (ms) => new Promise((r) => {
|
|
123
|
+
setTimeout(r, ms).unref();
|
|
124
|
+
});
|
|
125
|
+
/** The preset a certificate's kind and mask are, by IDENTITY-PROFILE-01 §5.4. */
|
|
126
|
+
function presetOf(kind, mask) {
|
|
127
|
+
const same = (a) => a.length === mask.length && a.every((c) => mask.includes(c));
|
|
128
|
+
for (const [name, p] of Object.entries(MASK_PRESETS))
|
|
129
|
+
if (p.kind === kind && same(p.mask))
|
|
130
|
+
return `${name[0]?.toUpperCase()}${name.slice(1)}`;
|
|
131
|
+
return `kind ${kind}, mask {${[...mask].sort((a, b) => (a < b ? -1 : 1)).join(", ")}}`;
|
|
132
|
+
}
|
|
133
|
+
const STATUS = {
|
|
134
|
+
ready: "ready",
|
|
135
|
+
"waiting-for-keys": "waiting for keys",
|
|
136
|
+
"pending-registration": "pending registration",
|
|
137
|
+
"not-granted": "not granted",
|
|
138
|
+
unproven: "approval not provable here",
|
|
139
|
+
unreachable: "unreachable, retrying",
|
|
140
|
+
unsupported: "on a server without device support",
|
|
141
|
+
};
|
|
142
|
+
const FINAL = new Set(["ready", "not-granted"]);
|
|
143
|
+
/** Runs `lfcp-node pair` with parsed arguments; returns its exit code. */
|
|
144
|
+
export async function pair(args, env) {
|
|
145
|
+
const out = env.out;
|
|
146
|
+
const now = env.now ?? (() => Date.now());
|
|
147
|
+
const sleep = env.sleep ?? defaultSleep;
|
|
148
|
+
const network = env.network ?? connectionNetwork({ now, timeout: () => replyTimeout(30_000) });
|
|
149
|
+
mkdirSync(args.state, { recursive: true, mode: 0o700 });
|
|
150
|
+
const secrets = new FileSecretStore(join(args.state, "secrets"));
|
|
151
|
+
const storage = SqliteLfcpStorage.open(join(args.state, "lfcp.sqlite"));
|
|
152
|
+
try {
|
|
153
|
+
const installation = await openInstallation(args.state, secrets);
|
|
154
|
+
const flow = { installation, storage, secrets, network, now, document: identityDocument };
|
|
155
|
+
const fingerprint = deviceFingerprint(installation.signer.descriptor.principalId);
|
|
156
|
+
const journal = new OperationJournal(storage);
|
|
157
|
+
// A request already made by this installation is resumed from the journal, link or not.
|
|
158
|
+
let operation = (await journal.list({ kind: "pairing-request", status: "open" }))[0]?.id;
|
|
159
|
+
if (operation === undefined) {
|
|
160
|
+
const text = args.link === "-" ? (env.stdin?.() ?? "").trim() : args.link;
|
|
161
|
+
const r = await requestPairing({
|
|
162
|
+
...flow,
|
|
163
|
+
link: new PairingLinkText(text),
|
|
164
|
+
label: args.label,
|
|
165
|
+
kind: args.kind,
|
|
166
|
+
...(args.kind === 1 ? { sections: args.sections } : {}),
|
|
167
|
+
platform: args.platform,
|
|
168
|
+
requestedAt: new Date(now()).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
169
|
+
});
|
|
170
|
+
switch (r.kind) {
|
|
171
|
+
case "requested":
|
|
172
|
+
operation = r.operation;
|
|
173
|
+
break;
|
|
174
|
+
case "unknown-sections":
|
|
175
|
+
out(`Not in the roster, or ambiguous: ${r.unknown.join(", ")}`);
|
|
176
|
+
out(`Sections this device may ask for: ${r.available.join(", ") || "(none)"}`);
|
|
177
|
+
return PAIR_EXIT.UNKNOWN_SECTIONS;
|
|
178
|
+
case "invalid-link":
|
|
179
|
+
case "not-a-session":
|
|
180
|
+
out(`The link is rejected: ${r.reason}`);
|
|
181
|
+
return PAIR_EXIT.FAILED;
|
|
182
|
+
case "missing-material":
|
|
183
|
+
out(`The managing device has not made the session usable yet: ${r.missing.join(", ")}`);
|
|
184
|
+
return PAIR_EXIT.FAILED;
|
|
185
|
+
case "refused":
|
|
186
|
+
out(`The request was refused (${r.code}).`);
|
|
187
|
+
return PAIR_EXIT.FAILED;
|
|
188
|
+
case "unavailable":
|
|
189
|
+
out(`The server could not be reached: ${r.reason}. Run again to retry.`);
|
|
190
|
+
return PAIR_EXIT.WAITING;
|
|
191
|
+
}
|
|
192
|
+
out(`Fingerprint: ${fingerprint}`);
|
|
193
|
+
out("Compare it with the fingerprint the approving device shows before approving.");
|
|
194
|
+
out(args.kind === 1
|
|
195
|
+
? `Requested: "${args.label}" (service) asks to join ${args.sections.join(", ")}; access: read and write.`
|
|
196
|
+
: `Requested: "${args.label}" (personal device).`);
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
out(`Fingerprint: ${fingerprint}`);
|
|
200
|
+
out("Resuming the pairing request of this installation.");
|
|
201
|
+
}
|
|
202
|
+
const deadline = now() + args.wait * 1000;
|
|
203
|
+
let last = "";
|
|
204
|
+
const say = (line) => {
|
|
205
|
+
if (line !== last)
|
|
206
|
+
out(line);
|
|
207
|
+
last = line;
|
|
208
|
+
};
|
|
209
|
+
for (;;) {
|
|
210
|
+
const r = await completePairing({ ...flow, operation });
|
|
211
|
+
if (r.kind === "rejected") {
|
|
212
|
+
say("Rejected: the managing device cancelled the session.");
|
|
213
|
+
return PAIR_EXIT.REJECTED;
|
|
214
|
+
}
|
|
215
|
+
if (r.kind === "waiting")
|
|
216
|
+
say("Waiting for the approval on the managing device…");
|
|
217
|
+
else if (r.kind === "unknown" || r.kind === "unavailable")
|
|
218
|
+
say(`Waiting: ${r.reason} (retrying).`);
|
|
219
|
+
else if (r.certificate === null)
|
|
220
|
+
say("Approved, waiting for registration…");
|
|
221
|
+
else {
|
|
222
|
+
const leaf = r.certificate.leaf.payload;
|
|
223
|
+
const lines = [
|
|
224
|
+
`Approved: ${presetOf(leaf.kind, leaf.mask)}${leaf.kind === DEVICE_KIND.SERVICE ? " (service device)" : ""}.`,
|
|
225
|
+
...r.candidates.map((c) => ` ${c.name ?? toBase64url(c.resource)}: ${STATUS[c.status] ?? c.status}`),
|
|
226
|
+
];
|
|
227
|
+
if (r.foreignCertificates > 0)
|
|
228
|
+
lines.push(` ${r.foreignCertificates} certificate(s) with another approval: not used.`);
|
|
229
|
+
say(lines.join("\n"));
|
|
230
|
+
if (r.candidates.length > 0 && r.candidates.every((c) => FINAL.has(c.status)))
|
|
231
|
+
return PAIR_EXIT.DONE;
|
|
232
|
+
}
|
|
233
|
+
if (now() >= deadline) {
|
|
234
|
+
out("Not finished yet; run the same command again to resume.");
|
|
235
|
+
return PAIR_EXIT.WAITING;
|
|
236
|
+
}
|
|
237
|
+
await sleep(args.poll * 1000);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
finally {
|
|
241
|
+
storage.close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/** `lfcp-node <command> ...`: the entry point of the bin; returns the exit code. */
|
|
245
|
+
export async function main(argv, env) {
|
|
246
|
+
const [command, ...rest] = argv;
|
|
247
|
+
if (command !== "pair") {
|
|
248
|
+
env.out(PAIR_USAGE);
|
|
249
|
+
return PAIR_EXIT.USAGE;
|
|
250
|
+
}
|
|
251
|
+
const args = parsePairArguments(rest);
|
|
252
|
+
if (typeof args === "string") {
|
|
253
|
+
env.out(`${args}\n\n${PAIR_USAGE}`);
|
|
254
|
+
return PAIR_EXIT.USAGE;
|
|
255
|
+
}
|
|
256
|
+
return pair(args, env);
|
|
257
|
+
}
|
|
258
|
+
/** Standard input as text (for "-"). */
|
|
259
|
+
export const readStdin = () => readFileSync(0, "utf8");
|
|
260
|
+
//# sourceMappingURL=pair.js.map
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/storage-node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Durable Node.js storage for LFCP clients: SQLite (better-sqlite3) and a file secret store. Node only.",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Durable Node.js storage for LFCP clients: SQLite (better-sqlite3) and a file secret store; the headless `lfcp-node pair` command. Node only.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
7
7
|
"lfcp",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"import": "./dist/index.js"
|
|
33
33
|
}
|
|
34
34
|
},
|
|
35
|
+
"bin": {
|
|
36
|
+
"lfcp-node": "./dist/cli.js"
|
|
37
|
+
},
|
|
35
38
|
"files": [
|
|
36
39
|
"dist",
|
|
37
40
|
"!dist/**/*.map",
|
|
@@ -43,8 +46,12 @@
|
|
|
43
46
|
},
|
|
44
47
|
"dependencies": {
|
|
45
48
|
"better-sqlite3": "13.0.3",
|
|
46
|
-
"@openlfcp/core": "^0.
|
|
47
|
-
"@openlfcp/
|
|
49
|
+
"@openlfcp/core": "^0.3.1",
|
|
50
|
+
"@openlfcp/client": "^0.3.1",
|
|
51
|
+
"@openlfcp/storage": "^0.3.1",
|
|
52
|
+
"@openlfcp/crypto": "^0.3.1",
|
|
53
|
+
"@openlfcp/wire": "^0.3.1",
|
|
54
|
+
"@openlfcp/shared-objects": "^0.3.1"
|
|
48
55
|
},
|
|
49
56
|
"devDependencies": {
|
|
50
57
|
"@types/better-sqlite3": "9.6.0",
|