@intentic/keyring 0.0.0-stage → 1.338.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/LICENSE +21 -0
- package/README.md +136 -2
- package/dist/address.d.ts +14 -0
- package/dist/address.d.ts.map +1 -0
- package/dist/address.js +17 -0
- package/dist/address.js.map +1 -0
- package/dist/app.d.ts +3 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +100 -0
- package/dist/app.js.map +1 -0
- package/dist/askpass/askpass-run.d.ts +7 -0
- package/dist/askpass/askpass-run.d.ts.map +1 -0
- package/dist/askpass/askpass-run.js +25 -0
- package/dist/askpass/askpass-run.js.map +1 -0
- package/dist/askpass/askpass.d.ts +9 -0
- package/dist/askpass/askpass.d.ts.map +1 -0
- package/dist/askpass/askpass.js +70 -0
- package/dist/askpass/askpass.js.map +1 -0
- package/dist/askpass-cli.d.ts +3 -0
- package/dist/askpass-cli.d.ts.map +1 -0
- package/dist/askpass-cli.js +4 -0
- package/dist/askpass-cli.js.map +1 -0
- package/dist/audit.d.ts +18 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +14 -0
- package/dist/audit.js.map +1 -0
- package/dist/backends.d.ts +19 -0
- package/dist/backends.d.ts.map +1 -0
- package/dist/backends.js +97 -0
- package/dist/backends.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +11 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +10 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +59 -0
- package/dist/client.js.map +1 -0
- package/dist/consent/consent-posix.d.ts +6 -0
- package/dist/consent/consent-posix.d.ts.map +1 -0
- package/dist/consent/consent-posix.js +57 -0
- package/dist/consent/consent-posix.js.map +1 -0
- package/dist/consent/consent-windows.d.ts +5 -0
- package/dist/consent/consent-windows.d.ts.map +1 -0
- package/dist/consent/consent-windows.js +244 -0
- package/dist/consent/consent-windows.js.map +1 -0
- package/dist/consent/consent.d.ts +67 -0
- package/dist/consent/consent.d.ts.map +1 -0
- package/dist/consent/consent.js +89 -0
- package/dist/consent/consent.js.map +1 -0
- package/dist/core.d.ts +61 -0
- package/dist/core.d.ts.map +1 -0
- package/dist/core.js +882 -0
- package/dist/core.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/keyring-fetch.d.ts +4 -0
- package/dist/keyring-fetch.d.ts.map +1 -0
- package/dist/keyring-fetch.js +20 -0
- package/dist/keyring-fetch.js.map +1 -0
- package/dist/keys.d.ts +27 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +55 -0
- package/dist/keys.js.map +1 -0
- package/dist/policy.d.ts +71 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +170 -0
- package/dist/policy.js.map +1 -0
- package/dist/powershell.d.ts +22 -0
- package/dist/powershell.d.ts.map +1 -0
- package/dist/powershell.js +53 -0
- package/dist/powershell.js.map +1 -0
- package/dist/protocol.d.ts +923 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +125 -0
- package/dist/protocol.js.map +1 -0
- package/dist/run.d.ts +20 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +34 -0
- package/dist/run.js.map +1 -0
- package/dist/seal.d.ts +3 -0
- package/dist/seal.d.ts.map +1 -0
- package/dist/seal.js +3 -0
- package/dist/seal.js.map +1 -0
- package/dist/serve.d.ts +17 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +65 -0
- package/dist/serve.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +77 -0
- package/dist/server.js.map +1 -0
- package/dist/service/acl.d.ts +9 -0
- package/dist/service/acl.d.ts.map +1 -0
- package/dist/service/acl.js +25 -0
- package/dist/service/acl.js.map +1 -0
- package/dist/service/channel.d.ts +17 -0
- package/dist/service/channel.d.ts.map +1 -0
- package/dist/service/channel.js +64 -0
- package/dist/service/channel.js.map +1 -0
- package/dist/service/door.d.ts +89 -0
- package/dist/service/door.d.ts.map +1 -0
- package/dist/service/door.js +28 -0
- package/dist/service/door.js.map +1 -0
- package/dist/service/hello-verify.d.ts +2 -0
- package/dist/service/hello-verify.d.ts.map +1 -0
- package/dist/service/hello-verify.js +10 -0
- package/dist/service/hello-verify.js.map +1 -0
- package/dist/service/helper.d.ts +3 -0
- package/dist/service/helper.d.ts.map +1 -0
- package/dist/service/helper.js +117 -0
- package/dist/service/helper.js.map +1 -0
- package/dist/service/install.d.ts +10 -0
- package/dist/service/install.d.ts.map +1 -0
- package/dist/service/install.js +77 -0
- package/dist/service/install.js.map +1 -0
- package/dist/service/lines.d.ts +13 -0
- package/dist/service/lines.d.ts.map +1 -0
- package/dist/service/lines.js +89 -0
- package/dist/service/lines.js.map +1 -0
- package/dist/service/paths.d.ts +19 -0
- package/dist/service/paths.d.ts.map +1 -0
- package/dist/service/paths.js +27 -0
- package/dist/service/paths.js.map +1 -0
- package/dist/service/remote.d.ts +21 -0
- package/dist/service/remote.d.ts.map +1 -0
- package/dist/service/remote.js +83 -0
- package/dist/service/remote.js.map +1 -0
- package/dist/service/service-run.d.ts +3 -0
- package/dist/service/service-run.d.ts.map +1 -0
- package/dist/service/service-run.js +77 -0
- package/dist/service/service-run.js.map +1 -0
- package/dist/service/sid.d.ts +3 -0
- package/dist/service/sid.d.ts.map +1 -0
- package/dist/service/sid.js +10 -0
- package/dist/service/sid.js.map +1 -0
- package/dist/service/task.d.ts +11 -0
- package/dist/service/task.d.ts.map +1 -0
- package/dist/service/task.js +38 -0
- package/dist/service/task.js.map +1 -0
- package/dist/service/wire.d.ts +53 -0
- package/dist/service/wire.d.ts.map +1 -0
- package/dist/service/wire.js +17 -0
- package/dist/service/wire.js.map +1 -0
- package/dist/typing/android.d.ts +14 -0
- package/dist/typing/android.d.ts.map +1 -0
- package/dist/typing/android.js +117 -0
- package/dist/typing/android.js.map +1 -0
- package/dist/typing/index.d.ts +9 -0
- package/dist/typing/index.d.ts.map +1 -0
- package/dist/typing/index.js +21 -0
- package/dist/typing/index.js.map +1 -0
- package/dist/typing/linux.d.ts +6 -0
- package/dist/typing/linux.d.ts.map +1 -0
- package/dist/typing/linux.js +72 -0
- package/dist/typing/linux.js.map +1 -0
- package/dist/typing/mac.d.ts +5 -0
- package/dist/typing/mac.d.ts.map +1 -0
- package/dist/typing/mac.js +40 -0
- package/dist/typing/mac.js.map +1 -0
- package/dist/typing/typist.d.ts +32 -0
- package/dist/typing/typist.d.ts.map +1 -0
- package/dist/typing/typist.js +9 -0
- package/dist/typing/typist.js.map +1 -0
- package/dist/typing/windows.d.ts +4 -0
- package/dist/typing/windows.d.ts.map +1 -0
- package/dist/typing/windows.js +200 -0
- package/dist/typing/windows.js.map +1 -0
- package/dist/vault.d.ts +530 -0
- package/dist/vault.d.ts.map +1 -0
- package/dist/vault.js +188 -0
- package/dist/vault.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/package.json +56 -3
package/dist/core.js
ADDED
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { serialLock } from "@intentic/base/async";
|
|
2
|
+
import { KeyringSandboxKeysSchema, } from "@intentic/sandbox-contract/schemas/keyring";
|
|
3
|
+
import { keyFingerprint, newSealKeyPair, seal, unseal } from "@intentic/sandbox-contract/keyring-seal";
|
|
4
|
+
import { askpassMatch, parseAskpassPrompt } from "./askpass/askpass.js";
|
|
5
|
+
import { pinOf, samePin } from "./keys.js";
|
|
6
|
+
import { acceptGrant, applyPatch, hostOf, isUnlocked, MINUTE_MS, modeNeedsPerson, NonceBook, rememberedFresh, releaseTargetOf, sandboxKey, shareWith, typeTargetOf, typingDecision, wideningOf, } from "./policy.js";
|
|
7
|
+
import { emptyOpenPart, helloChallenge, helloWrapOf, newVaultKey, openDocument, PENDING_INBOX_MAX, PENDING_SYNC_MAX, SCRYPT_COST, sealDocument, StoredEntrySchema, unwrapHello, unwrapPassphrase, VALUE_MAX_BYTES, wrapWithHello, wrapWithPassphrase, } from "./vault.js";
|
|
8
|
+
const RELAYED_OPS = ["shares", "release", "type", "inbox", "copies", "sync-state", "sync-pair", "sync-outbox", "sync-deliver"];
|
|
9
|
+
const RELAYED = new Set(RELAYED_OPS);
|
|
10
|
+
const isRelayed = (request) => RELAYED.has(request.op);
|
|
11
|
+
const refuse = (code, refusal) => ({ ok: false, code, refusal });
|
|
12
|
+
const fromConsent = (answer) => refuse(answer.code, answer.reason);
|
|
13
|
+
const NOT_SET_UP = "The keyring on this computer is not set up yet: `intentic-machine keyring setup` sets it up.";
|
|
14
|
+
const SETUP_UNLOCK_MINUTES = 15;
|
|
15
|
+
const FOCUS_SETTLE_MS = 400;
|
|
16
|
+
const SyncEntrySchema = StoredEntrySchema.omit({ shares: true, revision: true });
|
|
17
|
+
const entryInfoOf = (entry) => {
|
|
18
|
+
const info = { name: entry.name, ...entry.rules, source: entry.source, shares: entry.shares, createdAt: entry.createdAt, updatedAt: entry.updatedAt };
|
|
19
|
+
if (entry.backend !== undefined) {
|
|
20
|
+
info.backend = entry.backend;
|
|
21
|
+
}
|
|
22
|
+
return info;
|
|
23
|
+
};
|
|
24
|
+
const syncEntryOf = (entry) => {
|
|
25
|
+
const sent = { name: entry.name, rules: entry.rules, source: entry.source, createdAt: entry.createdAt, updatedAt: entry.updatedAt };
|
|
26
|
+
if (entry.backend !== undefined) {
|
|
27
|
+
sent.backend = entry.backend;
|
|
28
|
+
}
|
|
29
|
+
if (entry.value !== undefined) {
|
|
30
|
+
sent.value = entry.value;
|
|
31
|
+
}
|
|
32
|
+
if (entry.deleted === true) {
|
|
33
|
+
sent.deleted = true;
|
|
34
|
+
}
|
|
35
|
+
return sent;
|
|
36
|
+
};
|
|
37
|
+
const pointerOf = (entry, share) => ({
|
|
38
|
+
name: entry.name,
|
|
39
|
+
mode: share.mode,
|
|
40
|
+
kind: entry.kind,
|
|
41
|
+
programs: entry.programs,
|
|
42
|
+
packages: entry.packages,
|
|
43
|
+
hosts: entry.hosts,
|
|
44
|
+
anyField: entry.anyField,
|
|
45
|
+
source: entry.source,
|
|
46
|
+
updatedAt: entry.updatedAt,
|
|
47
|
+
});
|
|
48
|
+
const modeWords = (mode) => mode === "each-use" ? "it asks you each time" : mode === "while-here" ? "it goes unasked while the keyring is unlocked" : "a copy is kept in that sandbox";
|
|
49
|
+
const rulesWords = (rules) => {
|
|
50
|
+
const programs = rules.programs.length === 0 ? "no program named" : `programs ${rules.programs.join(", ")}`;
|
|
51
|
+
const field = rules.kind === "password" && !rules.anyField ? "password fields only" : "any field";
|
|
52
|
+
return `${programs}; ${field}`;
|
|
53
|
+
};
|
|
54
|
+
const withEntry = (doc, entry) => ({ ...doc, entries: { ...doc.entries, [entry.name]: entry } });
|
|
55
|
+
export class Keyring {
|
|
56
|
+
deps;
|
|
57
|
+
#file;
|
|
58
|
+
#keys;
|
|
59
|
+
#unlocked;
|
|
60
|
+
#nonces = new NonceBook();
|
|
61
|
+
#remembered = new Map();
|
|
62
|
+
#lock = serialLock();
|
|
63
|
+
#loaded = false;
|
|
64
|
+
constructor(deps) {
|
|
65
|
+
this.deps = deps;
|
|
66
|
+
}
|
|
67
|
+
async #load() {
|
|
68
|
+
if (!this.#loaded || this.#keys === undefined) {
|
|
69
|
+
this.#file = await this.deps.vault.read();
|
|
70
|
+
this.#keys = await this.deps.keys.read();
|
|
71
|
+
this.#loaded = true;
|
|
72
|
+
}
|
|
73
|
+
return this.#keys;
|
|
74
|
+
}
|
|
75
|
+
#held() {
|
|
76
|
+
if (this.#unlocked !== undefined && !isUnlocked(this.#unlocked.until, this.deps.now())) {
|
|
77
|
+
this.#forget();
|
|
78
|
+
}
|
|
79
|
+
return this.#unlocked;
|
|
80
|
+
}
|
|
81
|
+
#forget() {
|
|
82
|
+
this.#unlocked?.key.fill(0);
|
|
83
|
+
this.#unlocked = undefined;
|
|
84
|
+
this.#remembered.clear();
|
|
85
|
+
this.deps.backends.forget();
|
|
86
|
+
}
|
|
87
|
+
#opener(file) {
|
|
88
|
+
const withPassphrase = async (passphrase) => await unwrapPassphrase(file.wraps, passphrase);
|
|
89
|
+
const hello = helloWrapOf(file.wraps);
|
|
90
|
+
return hello === undefined
|
|
91
|
+
? { withPassphrase }
|
|
92
|
+
: { withPassphrase, hello: { credential: hello.credential, challenge: hello.challenge, withSignature: (signature) => unwrapHello(hello, signature) } };
|
|
93
|
+
}
|
|
94
|
+
async #open(file, request) {
|
|
95
|
+
const held = this.#held();
|
|
96
|
+
if (held !== undefined && !request.person) {
|
|
97
|
+
return { doc: held.doc, key: held.key, asked: false, how: undefined };
|
|
98
|
+
}
|
|
99
|
+
const answer = await this.deps.consent.ask({ title: request.title, message: request.message, unlock: held === undefined, opener: this.#opener(file) });
|
|
100
|
+
if (!answer.ok) {
|
|
101
|
+
return fromConsent(answer);
|
|
102
|
+
}
|
|
103
|
+
if (held !== undefined) {
|
|
104
|
+
return { doc: held.doc, key: held.key, asked: true, how: answer.how };
|
|
105
|
+
}
|
|
106
|
+
if (answer.vaultKey === undefined) {
|
|
107
|
+
return refuse("locked", "The keyring could not be opened with that answer: unlock it with its passphrase.");
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
return { doc: openDocument(answer.vaultKey, file.sealed), key: answer.vaultKey, asked: true, how: answer.how };
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return refuse("unavailable", error instanceof Error ? error.message : "The vault could not be opened.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
#copiesOf(doc, entries) {
|
|
117
|
+
const copies = new Map();
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
for (const share of entry.shares) {
|
|
120
|
+
const pin = doc.pins[share.sandbox];
|
|
121
|
+
if (share.mode === "copy" && entry.value !== undefined && pin !== undefined) {
|
|
122
|
+
const sealed = seal(pin.seal, entry.value, { kind: "copy", sandbox: share.sandbox, secret: entry.name, updatedAt: entry.updatedAt });
|
|
123
|
+
copies.set(share.sandbox, [...(copies.get(share.sandbox) ?? []), { name: entry.name, sealed, updatedAt: entry.updatedAt }]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return Object.fromEntries(copies);
|
|
128
|
+
}
|
|
129
|
+
async #commit(opened, pending) {
|
|
130
|
+
const file = this.#file;
|
|
131
|
+
if (file === undefined) {
|
|
132
|
+
throw new Error("committing a vault that was never set up");
|
|
133
|
+
}
|
|
134
|
+
const doc = { ...opened.doc, revision: opened.doc.revision + 1 };
|
|
135
|
+
const entries = Object.values(doc.entries).filter((entry) => entry.deleted !== true);
|
|
136
|
+
const open = {
|
|
137
|
+
entries: entries.map(entryInfoOf),
|
|
138
|
+
backends: doc.backends.map((backend) => backend.backend),
|
|
139
|
+
peers: doc.peers,
|
|
140
|
+
revision: doc.revision,
|
|
141
|
+
copies: this.#copiesOf(doc, entries),
|
|
142
|
+
pendingInbox: pending?.pendingInbox ?? file.open.pendingInbox,
|
|
143
|
+
pendingSync: pending?.pendingSync ?? file.open.pendingSync,
|
|
144
|
+
};
|
|
145
|
+
const next = { ...file, sealed: sealDocument(opened.key, doc), open };
|
|
146
|
+
await this.deps.vault.write(next);
|
|
147
|
+
this.#file = next;
|
|
148
|
+
const keys = await this.#load();
|
|
149
|
+
this.#keys = { ...keys, inbox: doc.inbox.publicKey, sync: doc.sync.publicKey, sandboxes: doc.pins };
|
|
150
|
+
await this.deps.keys.write(this.#keys);
|
|
151
|
+
if (this.#unlocked !== undefined) {
|
|
152
|
+
this.#unlocked = { ...this.#unlocked, doc };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async #writeOpen(open) {
|
|
156
|
+
const file = this.#file;
|
|
157
|
+
if (file !== undefined) {
|
|
158
|
+
const next = { ...file, open };
|
|
159
|
+
await this.deps.vault.write(next);
|
|
160
|
+
this.#file = next;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
async #record(record) {
|
|
164
|
+
try {
|
|
165
|
+
await this.deps.audit(record);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async #valueOf(doc, entry) {
|
|
171
|
+
if (entry.backend !== undefined) {
|
|
172
|
+
const read = await this.deps.backends.read(entry.backend, doc.backends, (name) => Promise.resolve(doc.entries[name]?.value));
|
|
173
|
+
return read.ok ? read : refuse(read.code, read.reason);
|
|
174
|
+
}
|
|
175
|
+
return entry.value === undefined ? refuse("unknown", `${entry.name} holds no value.`) : { ok: true, value: entry.value };
|
|
176
|
+
}
|
|
177
|
+
async #fetchKeys(sandbox) {
|
|
178
|
+
try {
|
|
179
|
+
return KeyringSandboxKeysSchema.parse(await this.deps.fetchKeys(sandbox));
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
const why = error instanceof Error ? `: ${error.message}` : "";
|
|
183
|
+
return refuse("unavailable", `${hostOf(sandbox)} did not hand over its keys${why}. Is the sandbox running and reachable from this computer?`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
#shown(name) {
|
|
187
|
+
return this.#file?.open.entries.find((entry) => entry.name === name);
|
|
188
|
+
}
|
|
189
|
+
async handle(request) {
|
|
190
|
+
await this.#load();
|
|
191
|
+
return isRelayed(request) ? await this.#relayed(request) : await this.#local(request);
|
|
192
|
+
}
|
|
193
|
+
async #local(request) {
|
|
194
|
+
switch (request.op) {
|
|
195
|
+
case "state":
|
|
196
|
+
return await this.state();
|
|
197
|
+
case "setup":
|
|
198
|
+
return await this.#lock(async () => await this.setup(request.passphrase, request.hello));
|
|
199
|
+
case "unlock":
|
|
200
|
+
return await this.#lock(async () => await this.unlock(request.minutes));
|
|
201
|
+
case "lock":
|
|
202
|
+
this.#forget();
|
|
203
|
+
await this.#record({ op: "lock", outcome: "done" });
|
|
204
|
+
return { ok: true };
|
|
205
|
+
case "list":
|
|
206
|
+
return this.list();
|
|
207
|
+
case "add":
|
|
208
|
+
return await this.#lock(async () => await this.add(request));
|
|
209
|
+
case "edit":
|
|
210
|
+
return await this.#lock(async () => await this.edit(request));
|
|
211
|
+
case "remove":
|
|
212
|
+
return await this.#lock(async () => await this.remove(request.name));
|
|
213
|
+
case "share":
|
|
214
|
+
return await this.#lock(async () => await this.share(request));
|
|
215
|
+
case "unshare":
|
|
216
|
+
return await this.#lock(async () => await this.unshare(request.name, request.sandbox));
|
|
217
|
+
case "backend":
|
|
218
|
+
return await this.#lock(async () => await this.backend(request.config));
|
|
219
|
+
case "askpass":
|
|
220
|
+
return await this.askpass(request.prompt, request.parent);
|
|
221
|
+
case "consent-upgrade":
|
|
222
|
+
return await this.consentUpgrade(request.version);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async #relayed(request) {
|
|
226
|
+
switch (request.op) {
|
|
227
|
+
case "shares":
|
|
228
|
+
return this.shares(request.sandbox);
|
|
229
|
+
case "release":
|
|
230
|
+
return await this.release(request.grant);
|
|
231
|
+
case "type":
|
|
232
|
+
return await this.type(request.input.grant, request.input.sealed);
|
|
233
|
+
case "inbox":
|
|
234
|
+
return await this.#lock(async () => await this.inbox(request.item));
|
|
235
|
+
case "copies":
|
|
236
|
+
return { ok: true, copies: this.#file?.open.copies[sandboxKey(request.sandbox)] ?? [] };
|
|
237
|
+
case "sync-state":
|
|
238
|
+
return await this.syncState();
|
|
239
|
+
case "sync-pair":
|
|
240
|
+
return await this.#lock(async () => await this.syncPair(request.pair));
|
|
241
|
+
case "sync-outbox":
|
|
242
|
+
return await this.#lock(async () => await this.syncOutbox());
|
|
243
|
+
case "sync-deliver":
|
|
244
|
+
return await this.#lock(async () => await this.syncDeliver(request.deliver.items));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async state() {
|
|
248
|
+
const file = this.#file;
|
|
249
|
+
const keys = await this.#load();
|
|
250
|
+
const held = this.#held();
|
|
251
|
+
const hello = this.deps.consent.hello;
|
|
252
|
+
const helloReady = hello !== undefined && (file === undefined || helloWrapOf(file.wraps) !== undefined) && (await hello.available());
|
|
253
|
+
const state = {
|
|
254
|
+
version: this.deps.version,
|
|
255
|
+
locked: held === undefined,
|
|
256
|
+
unlock: helloReady ? ["hello", "passphrase"] : ["passphrase"],
|
|
257
|
+
pendingInbox: file?.open.pendingInbox.length ?? 0,
|
|
258
|
+
};
|
|
259
|
+
if (this.#unlocked !== undefined && held !== undefined) {
|
|
260
|
+
state.unlockedUntil = this.#unlocked.until;
|
|
261
|
+
}
|
|
262
|
+
if (file !== undefined && keys.inbox !== undefined) {
|
|
263
|
+
state.keys = { seal: keys.seal.publicKey, inbox: keys.inbox };
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, state, setUp: file !== undefined };
|
|
266
|
+
}
|
|
267
|
+
async setup(passphrase, withHello) {
|
|
268
|
+
if (this.#file !== undefined) {
|
|
269
|
+
return refuse("declined", "This keyring is set up already. To start over, move ~/.intentic/keyring/vault.json aside first: it is the only copy of what it holds.");
|
|
270
|
+
}
|
|
271
|
+
const key = newVaultKey();
|
|
272
|
+
const doc = { v: 1, revision: 0, entries: {}, inbox: newSealKeyPair(), sync: newSealKeyPair(), peers: [], backends: [], pins: {} };
|
|
273
|
+
const wraps = [await wrapWithPassphrase(key, passphrase, this.deps.scrypt ?? SCRYPT_COST)];
|
|
274
|
+
const hello = this.deps.consent.hello;
|
|
275
|
+
let how = "passphrase";
|
|
276
|
+
if (withHello && hello !== undefined && (await hello.available())) {
|
|
277
|
+
const challenge = helloChallenge();
|
|
278
|
+
const enrolled = await hello.enrol(this.deps.helloCredential, challenge, {
|
|
279
|
+
title: "Set up the Intentic keyring",
|
|
280
|
+
message: "Windows Hello will unlock the keyring on this computer. The passphrase you chose still opens it if Hello is ever reset.",
|
|
281
|
+
});
|
|
282
|
+
if (enrolled.ok) {
|
|
283
|
+
wraps.push(wrapWithHello(key, { credential: this.deps.helloCredential, challenge, signature: enrolled.value.signature, publicKey: enrolled.value.publicKey }));
|
|
284
|
+
how = "hello";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
this.#file = { v: 1, wraps, sealed: sealDocument(key, doc), open: emptyOpenPart() };
|
|
288
|
+
this.#unlocked = { key, doc, until: this.deps.now() + SETUP_UNLOCK_MINUTES * MINUTE_MS };
|
|
289
|
+
await this.#commit({ doc, key });
|
|
290
|
+
await this.#record({ op: "setup", outcome: "done", how });
|
|
291
|
+
return { ok: true };
|
|
292
|
+
}
|
|
293
|
+
async unlock(minutes) {
|
|
294
|
+
const file = this.#file;
|
|
295
|
+
if (file === undefined) {
|
|
296
|
+
return refuse("locked", NOT_SET_UP);
|
|
297
|
+
}
|
|
298
|
+
const pending = file.open.pendingInbox;
|
|
299
|
+
const waiting = pending.length === 0 ? "" : ` Waiting to be taken in: ${pending.map((item) => `${item.item.name} from ${hostOf(item.item.sandbox)}`).join(", ")}.`;
|
|
300
|
+
const opened = await this.#open(file, {
|
|
301
|
+
title: "Unlock the Intentic keyring",
|
|
302
|
+
message: `For ${String(minutes)} minutes, entries shared "while you're here" go to their sandboxes without asking.${waiting}`,
|
|
303
|
+
person: true,
|
|
304
|
+
});
|
|
305
|
+
if ("ok" in opened) {
|
|
306
|
+
await this.#record({ op: "unlock", outcome: "refused", code: opened.code });
|
|
307
|
+
return opened;
|
|
308
|
+
}
|
|
309
|
+
const until = this.deps.now() + minutes * MINUTE_MS;
|
|
310
|
+
this.#unlocked = { key: opened.key, doc: opened.doc, until };
|
|
311
|
+
await this.#takeInPending(opened);
|
|
312
|
+
await this.#record({ op: "unlock", outcome: "done", how: opened.how });
|
|
313
|
+
return { ok: true, unlockedUntil: until };
|
|
314
|
+
}
|
|
315
|
+
async #takeInPending(opened) {
|
|
316
|
+
const file = this.#file;
|
|
317
|
+
if (file === undefined || (file.open.pendingInbox.length === 0 && file.open.pendingSync.length === 0)) {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
let doc = opened.doc;
|
|
321
|
+
for (const { item } of file.open.pendingInbox) {
|
|
322
|
+
const taken = await this.#inboxInto(doc, item);
|
|
323
|
+
if (taken.ok) {
|
|
324
|
+
doc = taken.doc;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
await this.#record({ op: "inbox", outcome: "refused", code: taken.refusal.code, secret: item.name, sandbox: item.sandbox, reason: taken.refusal.refusal });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
doc = this.#mergeSync(doc, file.open.pendingSync).doc;
|
|
331
|
+
await this.#commit({ doc, key: opened.key }, { pendingInbox: [], pendingSync: [] });
|
|
332
|
+
}
|
|
333
|
+
list() {
|
|
334
|
+
const held = this.#held();
|
|
335
|
+
const open = this.#file?.open;
|
|
336
|
+
const entries = held === undefined ? (open?.entries ?? []) : Object.values(held.doc.entries).filter((entry) => entry.deleted !== true).map(entryInfoOf);
|
|
337
|
+
return { ok: true, entries, backends: open?.backends ?? [] };
|
|
338
|
+
}
|
|
339
|
+
#sentValue(doc, name, sealed) {
|
|
340
|
+
let value;
|
|
341
|
+
try {
|
|
342
|
+
value = unseal(doc.inbox, sealed, { kind: "inbox", sandbox: "local", secret: name });
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
return refuse("bad-grant", "The value was not sealed to this keyring's inbox: `intentic-machine keyring status` names the right key.");
|
|
346
|
+
}
|
|
347
|
+
return Buffer.byteLength(value, "utf8") > VALUE_MAX_BYTES ? refuse("bad-grant", `A value holds at most ${String(VALUE_MAX_BYTES / 1024)} KiB.`) : { ok: true, value };
|
|
348
|
+
}
|
|
349
|
+
#addedEntry(request, before, value, revision) {
|
|
350
|
+
const now = this.deps.now();
|
|
351
|
+
const fresh = before === undefined || before.deleted === true;
|
|
352
|
+
const entry = {
|
|
353
|
+
name: request.name,
|
|
354
|
+
rules: request.rules,
|
|
355
|
+
source: request.backend?.backend ?? "keyring",
|
|
356
|
+
shares: fresh ? [] : before.shares,
|
|
357
|
+
createdAt: fresh ? now : before.createdAt,
|
|
358
|
+
updatedAt: now,
|
|
359
|
+
revision,
|
|
360
|
+
};
|
|
361
|
+
if (request.backend !== undefined) {
|
|
362
|
+
entry.backend = request.backend;
|
|
363
|
+
}
|
|
364
|
+
if (value !== undefined) {
|
|
365
|
+
entry.value = value;
|
|
366
|
+
}
|
|
367
|
+
return entry;
|
|
368
|
+
}
|
|
369
|
+
async add(request) {
|
|
370
|
+
const file = this.#file;
|
|
371
|
+
if (file === undefined) {
|
|
372
|
+
return refuse("locked", NOT_SET_UP);
|
|
373
|
+
}
|
|
374
|
+
if ((request.sealed === undefined) === (request.backend === undefined)) {
|
|
375
|
+
return refuse("bad-grant", "An entry takes either a value or a password manager to read it from, not both and not neither.");
|
|
376
|
+
}
|
|
377
|
+
const existing = this.#shown(request.name);
|
|
378
|
+
if (existing !== undefined && !request.replace) {
|
|
379
|
+
return refuse("declined", `The keyring has an entry named ${request.name} already: replace it with --replace, or change it with \`keyring edit\`.`);
|
|
380
|
+
}
|
|
381
|
+
const source = request.backend === undefined ? "the value you typed" : `${request.backend.backend} (${request.backend.ref})`;
|
|
382
|
+
const opened = await this.#open(file, {
|
|
383
|
+
title: existing === undefined ? `Add ${request.name} to the keyring?` : `Replace ${request.name} in the keyring?`,
|
|
384
|
+
message: `${request.name}: ${source}. ${rulesWords(request.rules)}.`,
|
|
385
|
+
person: true,
|
|
386
|
+
});
|
|
387
|
+
if ("ok" in opened) {
|
|
388
|
+
return opened;
|
|
389
|
+
}
|
|
390
|
+
const value = request.sealed === undefined ? undefined : this.#sentValue(opened.doc, request.name, request.sealed);
|
|
391
|
+
if (value !== undefined && !value.ok) {
|
|
392
|
+
return value;
|
|
393
|
+
}
|
|
394
|
+
const entry = this.#addedEntry(request, opened.doc.entries[request.name], value?.value, opened.doc.revision + 1);
|
|
395
|
+
await this.#commit({ doc: withEntry(opened.doc, entry), key: opened.key });
|
|
396
|
+
await this.#record({ op: "add", outcome: "done", secret: request.name, asked: opened.asked });
|
|
397
|
+
return { ok: true };
|
|
398
|
+
}
|
|
399
|
+
async edit(request) {
|
|
400
|
+
const file = this.#file;
|
|
401
|
+
const shown = this.#shown(request.name);
|
|
402
|
+
if (file === undefined || shown === undefined) {
|
|
403
|
+
return refuse("unknown", `The keyring has no entry named ${request.name}.`);
|
|
404
|
+
}
|
|
405
|
+
const widening = wideningOf(shown, request.patch, request.sealed !== undefined);
|
|
406
|
+
const opened = await this.#open(file, {
|
|
407
|
+
title: `Change ${request.name}?`,
|
|
408
|
+
message: widening.length === 0 ? `This change only narrows where ${request.name} may go.` : `This change ${widening.join("; ")}.`,
|
|
409
|
+
person: widening.length > 0,
|
|
410
|
+
});
|
|
411
|
+
if ("ok" in opened) {
|
|
412
|
+
return opened;
|
|
413
|
+
}
|
|
414
|
+
const entry = opened.doc.entries[request.name];
|
|
415
|
+
if (entry === undefined || entry.deleted === true) {
|
|
416
|
+
return refuse("unknown", `The keyring has no entry named ${request.name}.`);
|
|
417
|
+
}
|
|
418
|
+
if (!opened.asked && wideningOf(entry.rules, request.patch, request.sealed !== undefined).length > 0) {
|
|
419
|
+
return refuse("declined", "The entry on file differs from what this change was judged against: ask again.");
|
|
420
|
+
}
|
|
421
|
+
const value = request.sealed === undefined ? undefined : this.#sentValue(opened.doc, request.name, request.sealed);
|
|
422
|
+
if (value !== undefined && !value.ok) {
|
|
423
|
+
return value;
|
|
424
|
+
}
|
|
425
|
+
const changed = { ...entry, rules: applyPatch(entry.rules, request.patch), updatedAt: this.deps.now(), revision: opened.doc.revision + 1 };
|
|
426
|
+
if (value !== undefined) {
|
|
427
|
+
changed.value = value.value;
|
|
428
|
+
}
|
|
429
|
+
await this.#commit({ doc: withEntry(opened.doc, changed), key: opened.key });
|
|
430
|
+
await this.#record({ op: "edit", outcome: "done", secret: request.name, asked: opened.asked, reason: widening.join("; ") });
|
|
431
|
+
return { ok: true };
|
|
432
|
+
}
|
|
433
|
+
async remove(name) {
|
|
434
|
+
const file = this.#file;
|
|
435
|
+
if (file === undefined || this.#shown(name) === undefined) {
|
|
436
|
+
return refuse("unknown", `The keyring has no entry named ${name}.`);
|
|
437
|
+
}
|
|
438
|
+
const opened = await this.#open(file, { title: `Remove ${name} from the keyring?`, message: "Every sandbox it is shared with loses it, and a paired keyring removes it too.", person: true });
|
|
439
|
+
if ("ok" in opened) {
|
|
440
|
+
return opened;
|
|
441
|
+
}
|
|
442
|
+
const entry = opened.doc.entries[name];
|
|
443
|
+
if (entry === undefined) {
|
|
444
|
+
return refuse("unknown", `The keyring has no entry named ${name}.`);
|
|
445
|
+
}
|
|
446
|
+
const tombstone = { name, rules: entry.rules, source: entry.source, shares: [], createdAt: entry.createdAt, updatedAt: this.deps.now(), revision: opened.doc.revision + 1, deleted: true };
|
|
447
|
+
await this.#commit({ doc: withEntry(opened.doc, tombstone), key: opened.key });
|
|
448
|
+
await this.#record({ op: "remove", outcome: "done", secret: name });
|
|
449
|
+
return { ok: true };
|
|
450
|
+
}
|
|
451
|
+
async share(asked) {
|
|
452
|
+
const request = { ...asked, sandbox: sandboxKey(asked.sandbox) };
|
|
453
|
+
const file = this.#file;
|
|
454
|
+
const shown = this.#shown(request.name);
|
|
455
|
+
if (file === undefined || shown === undefined) {
|
|
456
|
+
return refuse("unknown", `The keyring has no entry named ${request.name}.`);
|
|
457
|
+
}
|
|
458
|
+
if (request.mode === "copy" && shown.backend !== undefined) {
|
|
459
|
+
return refuse("declined", `${request.name} is read from ${shown.backend.backend} at each use, so no copy of it can be kept in a sandbox.`);
|
|
460
|
+
}
|
|
461
|
+
const keys = await this.#fetchKeys(request.sandbox);
|
|
462
|
+
if ("ok" in keys) {
|
|
463
|
+
return keys;
|
|
464
|
+
}
|
|
465
|
+
const fingerprint = keyFingerprint(keys.sign);
|
|
466
|
+
const known = (await this.#load()).sandboxes[request.sandbox];
|
|
467
|
+
const changed = known !== undefined && (known.sign !== keys.sign || known.seal !== keys.seal);
|
|
468
|
+
const warning = changed ? " Its keys have CHANGED since this keyring last saw it: say no unless you know why." : "";
|
|
469
|
+
const opened = await this.#open(file, {
|
|
470
|
+
title: `Share ${request.name} with ${hostOf(request.sandbox)}?`,
|
|
471
|
+
message: `${request.name} goes to the sandbox at ${hostOf(request.sandbox)} (this computer is "${request.machine}" there): ${modeWords(request.mode)}. The sandbox's key is ${fingerprint}.${warning}`,
|
|
472
|
+
person: true,
|
|
473
|
+
});
|
|
474
|
+
if ("ok" in opened) {
|
|
475
|
+
return opened;
|
|
476
|
+
}
|
|
477
|
+
const entry = opened.doc.entries[request.name];
|
|
478
|
+
if (entry === undefined || entry.deleted === true) {
|
|
479
|
+
return refuse("unknown", `The keyring has no entry named ${request.name}.`);
|
|
480
|
+
}
|
|
481
|
+
const now = this.deps.now();
|
|
482
|
+
const share = { sandbox: request.sandbox, machine: request.machine, mode: request.mode, sharedAt: now };
|
|
483
|
+
const shared = { ...entry, shares: [...entry.shares.filter((other) => other.sandbox !== request.sandbox), share], updatedAt: now, revision: opened.doc.revision + 1 };
|
|
484
|
+
const doc = withEntry({ ...opened.doc, pins: { ...opened.doc.pins, [request.sandbox]: { sign: keys.sign, seal: keys.seal, fetchedAt: now } } }, shared);
|
|
485
|
+
await this.#commit({ doc, key: opened.key });
|
|
486
|
+
await this.#record({ op: "share", outcome: "done", secret: request.name, sandbox: request.sandbox, target: request.mode });
|
|
487
|
+
return { ok: true, fingerprint };
|
|
488
|
+
}
|
|
489
|
+
async unshare(name, asked) {
|
|
490
|
+
const sandbox = sandboxKey(asked);
|
|
491
|
+
const file = this.#file;
|
|
492
|
+
if (file === undefined || this.#shown(name) === undefined) {
|
|
493
|
+
return refuse("unknown", `The keyring has no entry named ${name}.`);
|
|
494
|
+
}
|
|
495
|
+
const opened = await this.#open(file, { title: `Stop sharing ${name} with ${hostOf(sandbox)}?`, message: "The sandbox loses it, and a copy kept there is dropped at its next refresh.", person: false });
|
|
496
|
+
if ("ok" in opened) {
|
|
497
|
+
return opened;
|
|
498
|
+
}
|
|
499
|
+
const entry = opened.doc.entries[name];
|
|
500
|
+
if (entry === undefined) {
|
|
501
|
+
return refuse("unknown", `The keyring has no entry named ${name}.`);
|
|
502
|
+
}
|
|
503
|
+
const unshared = { ...entry, shares: entry.shares.filter((share) => share.sandbox !== sandbox), revision: opened.doc.revision + 1 };
|
|
504
|
+
await this.#commit({ doc: withEntry(opened.doc, unshared), key: opened.key });
|
|
505
|
+
await this.#record({ op: "unshare", outcome: "done", secret: name, sandbox });
|
|
506
|
+
return { ok: true };
|
|
507
|
+
}
|
|
508
|
+
async backend(config) {
|
|
509
|
+
const file = this.#file;
|
|
510
|
+
if (file === undefined) {
|
|
511
|
+
return refuse("locked", NOT_SET_UP);
|
|
512
|
+
}
|
|
513
|
+
const opened = await this.#open(file, { title: `Read entries from ${config.backend}?`, message: `The keyring will run ${config.cli} to read the entries that name ${config.backend}.`, person: true });
|
|
514
|
+
if ("ok" in opened) {
|
|
515
|
+
return opened;
|
|
516
|
+
}
|
|
517
|
+
const backends = [...opened.doc.backends.filter((other) => other.backend !== config.backend), config];
|
|
518
|
+
await this.#commit({ doc: { ...opened.doc, backends }, key: opened.key });
|
|
519
|
+
await this.#record({ op: "backend", outcome: "done", target: config.backend });
|
|
520
|
+
return { ok: true };
|
|
521
|
+
}
|
|
522
|
+
shares(sandbox) {
|
|
523
|
+
const entries = this.#file?.open.entries ?? [];
|
|
524
|
+
const pointers = entries.flatMap((entry) => entry.shares.filter((share) => share.sandbox === sandboxKey(sandbox)).map((share) => pointerOf(entry, share)));
|
|
525
|
+
return { ok: true, pointers };
|
|
526
|
+
}
|
|
527
|
+
async #grantedEntry(grant, prompt, extraPerson) {
|
|
528
|
+
const file = this.#file;
|
|
529
|
+
const shownShare = this.#shown(grant.secret)?.shares.find((share) => share.sandbox === sandboxKey(grant.sandbox));
|
|
530
|
+
if (file === undefined || shownShare === undefined) {
|
|
531
|
+
return refuse("not-shared", `${grant.secret} is not shared with ${hostOf(grant.sandbox)} here.`);
|
|
532
|
+
}
|
|
533
|
+
const unlocked = this.#held() !== undefined;
|
|
534
|
+
const opened = await this.#open(file, { ...prompt, person: modeNeedsPerson(shownShare.mode, unlocked) || extraPerson });
|
|
535
|
+
if ("ok" in opened) {
|
|
536
|
+
return opened;
|
|
537
|
+
}
|
|
538
|
+
const entry = opened.doc.entries[grant.secret];
|
|
539
|
+
const share = shareWith(entry, grant.sandbox);
|
|
540
|
+
if (entry === undefined || share === undefined) {
|
|
541
|
+
return refuse("not-shared", `${grant.secret} is not shared with ${hostOf(grant.sandbox)} here.`);
|
|
542
|
+
}
|
|
543
|
+
if (!samePin(pinOf(await this.#load(), sandboxKey(grant.sandbox)), opened.doc.pins[sandboxKey(grant.sandbox)])) {
|
|
544
|
+
return refuse("bad-grant", `The keys on file for ${hostOf(grant.sandbox)} were changed outside the keyring: share ${grant.secret} with it again.`);
|
|
545
|
+
}
|
|
546
|
+
if (!opened.asked && modeNeedsPerson(share.mode, unlocked)) {
|
|
547
|
+
return refuse("declined", "The share on file differs from what this use was judged against: the sandbox asks again.");
|
|
548
|
+
}
|
|
549
|
+
return { opened, entry };
|
|
550
|
+
}
|
|
551
|
+
async release(signed) {
|
|
552
|
+
const keys = await this.#load();
|
|
553
|
+
const accepted = acceptGrant(signed, { pinOf: (sandbox) => pinOf(keys, sandbox), nonces: this.#nonces, now: this.deps.now() });
|
|
554
|
+
if (!accepted.ok) {
|
|
555
|
+
await this.#record({ op: "release", outcome: "refused", code: accepted.code, reason: accepted.reason });
|
|
556
|
+
return refuse(accepted.code, accepted.reason);
|
|
557
|
+
}
|
|
558
|
+
const grant = accepted.grant;
|
|
559
|
+
const target = releaseTargetOf(grant.target);
|
|
560
|
+
if (!target.ok) {
|
|
561
|
+
return refuse(target.code, target.reason);
|
|
562
|
+
}
|
|
563
|
+
const where = target.target.kind === "relay" ? `to be typed on ${target.target.toMachine}` : `for ${grant.detail}`;
|
|
564
|
+
const conversation = grant.conversation === undefined ? "" : ` (conversation ${grant.conversation})`;
|
|
565
|
+
const granted = await this.#grantedEntry(grant, { title: `Release ${grant.secret} to ${hostOf(grant.sandbox)}?`, message: `${hostOf(grant.sandbox)} asks for ${grant.secret} ${where}.${conversation}` }, false);
|
|
566
|
+
if ("ok" in granted) {
|
|
567
|
+
await this.#record({ op: "release", outcome: "refused", code: granted.code, secret: grant.secret, sandbox: grant.sandbox, reason: granted.refusal });
|
|
568
|
+
return granted;
|
|
569
|
+
}
|
|
570
|
+
const value = await this.#valueOf(granted.opened.doc, granted.entry);
|
|
571
|
+
const pin = granted.opened.doc.pins[sandboxKey(grant.sandbox)];
|
|
572
|
+
if (!value.ok || pin === undefined) {
|
|
573
|
+
return value.ok ? refuse("not-shared", `${grant.secret} is not shared with ${hostOf(grant.sandbox)} here.`) : value;
|
|
574
|
+
}
|
|
575
|
+
const sealed = target.target.kind === "relay"
|
|
576
|
+
? seal(target.target.sealTo, value.value, { kind: "type", sandbox: grant.sandbox, secret: grant.secret, nonce: grant.nonce })
|
|
577
|
+
: seal(pin.seal, value.value, { kind: "release", sandbox: grant.sandbox, secret: grant.secret, nonce: grant.nonce });
|
|
578
|
+
await this.#record({
|
|
579
|
+
op: "release",
|
|
580
|
+
outcome: "granted",
|
|
581
|
+
secret: grant.secret,
|
|
582
|
+
sandbox: grant.sandbox,
|
|
583
|
+
target: target.target.kind === "relay" ? `relay:${target.target.toMachine}` : target.target.lane,
|
|
584
|
+
asked: granted.opened.asked,
|
|
585
|
+
how: granted.opened.how,
|
|
586
|
+
detail: grant.detail,
|
|
587
|
+
conversation: grant.conversation,
|
|
588
|
+
});
|
|
589
|
+
return { ok: true, sealed, asked: granted.opened.asked };
|
|
590
|
+
}
|
|
591
|
+
async type(signed, box) {
|
|
592
|
+
const keys = await this.#load();
|
|
593
|
+
const accepted = acceptGrant(signed, { pinOf: (sandbox) => pinOf(keys, sandbox), nonces: this.#nonces, now: this.deps.now() });
|
|
594
|
+
if (!accepted.ok) {
|
|
595
|
+
await this.#record({ op: "type", outcome: "refused", code: accepted.code, reason: accepted.reason });
|
|
596
|
+
return refuse(accepted.code, accepted.reason);
|
|
597
|
+
}
|
|
598
|
+
const grant = accepted.grant;
|
|
599
|
+
const target = typeTargetOf(grant.target);
|
|
600
|
+
if (!target.ok) {
|
|
601
|
+
return refuse(target.code, target.reason);
|
|
602
|
+
}
|
|
603
|
+
const place = target.target.kind === "window" ? { kind: "window" } : { kind: "android", serial: target.target.serial };
|
|
604
|
+
const countdown = target.target.kind === "window" ? (target.target.countdownSeconds ?? 0) : 0;
|
|
605
|
+
if (countdown > 0) {
|
|
606
|
+
await this.deps.sleep(countdown * 1000);
|
|
607
|
+
}
|
|
608
|
+
const looked = await this.deps.typist.inspect(place);
|
|
609
|
+
if (!looked.ok) {
|
|
610
|
+
return refuse("unavailable", looked.reason);
|
|
611
|
+
}
|
|
612
|
+
const typed = box === undefined ? await this.#typeOwn(grant, target.target, place, looked.seen) : await this.#typeSent(grant, target.target, place, looked.seen, box, keys);
|
|
613
|
+
await this.#record({
|
|
614
|
+
op: "type",
|
|
615
|
+
outcome: typed.ok ? "granted" : "refused",
|
|
616
|
+
secret: grant.secret,
|
|
617
|
+
sandbox: grant.sandbox,
|
|
618
|
+
program: looked.seen.focus.program,
|
|
619
|
+
asked: typed.ok ? typed.asked : undefined,
|
|
620
|
+
code: typed.ok ? undefined : typed.code,
|
|
621
|
+
reason: typed.ok ? undefined : typed.refusal,
|
|
622
|
+
detail: grant.detail,
|
|
623
|
+
conversation: grant.conversation,
|
|
624
|
+
});
|
|
625
|
+
return typed;
|
|
626
|
+
}
|
|
627
|
+
async #typeSent(grant, target, place, seen, box, keys) {
|
|
628
|
+
const verdict = typingDecision({ target, focus: seen.focus });
|
|
629
|
+
if (verdict.verdict !== "type") {
|
|
630
|
+
return refuse("guard", verdict.verdict === "refuse" ? verdict.reason : "The sandbox's guard does not cover this window.");
|
|
631
|
+
}
|
|
632
|
+
let value;
|
|
633
|
+
try {
|
|
634
|
+
value = unseal(keys.seal, box, { kind: "type", sandbox: grant.sandbox, secret: grant.secret, nonce: grant.nonce });
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
return refuse("bad-grant", "The value was not sealed to this keyring for this grant.");
|
|
638
|
+
}
|
|
639
|
+
return await this.#typeInto(place, seen, value, false);
|
|
640
|
+
}
|
|
641
|
+
async #typeOwn(grant, target, place, seen) {
|
|
642
|
+
const shown = this.#shown(grant.secret);
|
|
643
|
+
if (shown === undefined) {
|
|
644
|
+
return refuse("not-shared", `${grant.secret} is not shared with ${hostOf(grant.sandbox)} here.`);
|
|
645
|
+
}
|
|
646
|
+
const first = typingDecision({ target, focus: seen.focus, rules: shown });
|
|
647
|
+
if (first.verdict === "refuse") {
|
|
648
|
+
return refuse(first.code, first.reason);
|
|
649
|
+
}
|
|
650
|
+
const reasons = first.verdict === "ask" ? ` It asks because ${first.reasons.join(" and ")}.` : "";
|
|
651
|
+
const granted = await this.#grantedEntry(grant, { title: `Type ${grant.secret} into ${seen.focus.program}?`, message: `${hostOf(grant.sandbox)} asks to type ${grant.secret} into ${seen.focus.program} (${grant.detail}).${reasons}` }, first.verdict === "ask");
|
|
652
|
+
if ("ok" in granted) {
|
|
653
|
+
return granted;
|
|
654
|
+
}
|
|
655
|
+
const onFile = typingDecision({ target, focus: seen.focus, rules: granted.entry.rules });
|
|
656
|
+
if (onFile.verdict === "refuse" || (onFile.verdict === "ask" && !granted.opened.asked)) {
|
|
657
|
+
return refuse("guard", onFile.verdict === "refuse" ? onFile.reason : "The entry on file differs from what this use was judged against: the sandbox asks again.");
|
|
658
|
+
}
|
|
659
|
+
const value = await this.#valueOf(granted.opened.doc, granted.entry);
|
|
660
|
+
return value.ok ? await this.#typeInto(place, seen, value.value, granted.opened.asked) : value;
|
|
661
|
+
}
|
|
662
|
+
async #typeInto(place, seen, value, asked) {
|
|
663
|
+
let now = seen;
|
|
664
|
+
if (asked) {
|
|
665
|
+
await this.deps.sleep(FOCUS_SETTLE_MS);
|
|
666
|
+
const again = await this.deps.typist.inspect(place);
|
|
667
|
+
if (!again.ok || again.seen.focus.program !== seen.focus.program || again.seen.handle !== seen.handle) {
|
|
668
|
+
return refuse("guard", `The focus left ${seen.focus.program} while you answered: put the cursor back where it goes and ask again.`);
|
|
669
|
+
}
|
|
670
|
+
now = again.seen;
|
|
671
|
+
}
|
|
672
|
+
const typed = await this.deps.typist.type(place, now, value);
|
|
673
|
+
if (!typed.ok) {
|
|
674
|
+
return refuse("guard", typed.partly ? `${typed.reason} Part of it was typed: clear that field before trying again.` : typed.reason);
|
|
675
|
+
}
|
|
676
|
+
return { ok: true, program: now.focus.program, field: now.focus.field === "password" ? "password" : "text", asked };
|
|
677
|
+
}
|
|
678
|
+
async inbox(item) {
|
|
679
|
+
const file = this.#file;
|
|
680
|
+
if (file === undefined) {
|
|
681
|
+
return refuse("locked", NOT_SET_UP);
|
|
682
|
+
}
|
|
683
|
+
if (this.#held() === undefined) {
|
|
684
|
+
const others = file.open.pendingInbox.filter((other) => other.item.name !== item.name || other.item.sandbox !== item.sandbox);
|
|
685
|
+
await this.#writeOpen({ ...file.open, pendingInbox: [...others, { item, receivedAt: this.deps.now() }].slice(-PENDING_INBOX_MAX) });
|
|
686
|
+
await this.#record({ op: "inbox", outcome: "pending", secret: item.name, sandbox: item.sandbox });
|
|
687
|
+
return { ok: true, pending: true };
|
|
688
|
+
}
|
|
689
|
+
const opened = await this.#open(file, {
|
|
690
|
+
title: `Keep ${item.name} from ${hostOf(item.sandbox)}?`,
|
|
691
|
+
message: `${hostOf(item.sandbox)} sends ${item.name} to be kept in this keyring, shared back with it: ${modeWords(item.mode)}.`,
|
|
692
|
+
person: true,
|
|
693
|
+
});
|
|
694
|
+
if ("ok" in opened) {
|
|
695
|
+
return opened;
|
|
696
|
+
}
|
|
697
|
+
const taken = await this.#inboxInto(opened.doc, item);
|
|
698
|
+
if (!taken.ok) {
|
|
699
|
+
return taken.refusal;
|
|
700
|
+
}
|
|
701
|
+
await this.#commit({ doc: taken.doc, key: opened.key });
|
|
702
|
+
await this.#record({ op: "inbox", outcome: "done", secret: item.name, sandbox: item.sandbox, asked: true });
|
|
703
|
+
return { ok: true, pending: false };
|
|
704
|
+
}
|
|
705
|
+
async #inboxInto(doc, item) {
|
|
706
|
+
let value;
|
|
707
|
+
try {
|
|
708
|
+
value = unseal(doc.inbox, item.sealed, { kind: "inbox", sandbox: item.sandbox, secret: item.name });
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
return { ok: false, refusal: refuse("bad-grant", `${item.name} was not sealed to this keyring's inbox.`) };
|
|
712
|
+
}
|
|
713
|
+
const now = this.deps.now();
|
|
714
|
+
const sandbox = sandboxKey(item.sandbox);
|
|
715
|
+
let pin = doc.pins[sandbox];
|
|
716
|
+
if (pin === undefined) {
|
|
717
|
+
const fetched = await this.#fetchKeys(item.sandbox);
|
|
718
|
+
if ("ok" in fetched) {
|
|
719
|
+
return { ok: false, refusal: fetched };
|
|
720
|
+
}
|
|
721
|
+
pin = { sign: fetched.sign, seal: fetched.seal, fetchedAt: now };
|
|
722
|
+
}
|
|
723
|
+
const before = doc.entries[item.name];
|
|
724
|
+
const kept = before === undefined || before.deleted === true ? [] : before.shares.filter((share) => share.sandbox !== sandbox);
|
|
725
|
+
const entry = {
|
|
726
|
+
name: item.name,
|
|
727
|
+
rules: { kind: item.kind, programs: item.programs, packages: [], hosts: item.hosts, anyField: false, askpass: [], askpassRememberMinutes: 0 },
|
|
728
|
+
source: "keyring",
|
|
729
|
+
value,
|
|
730
|
+
shares: [...kept, { sandbox, machine: hostOf(sandbox), mode: item.mode, sharedAt: now }],
|
|
731
|
+
createdAt: before === undefined || before.deleted === true ? now : before.createdAt,
|
|
732
|
+
updatedAt: now,
|
|
733
|
+
revision: doc.revision + 1,
|
|
734
|
+
};
|
|
735
|
+
return { ok: true, doc: withEntry({ ...doc, pins: { ...doc.pins, [sandbox]: pin } }, entry) };
|
|
736
|
+
}
|
|
737
|
+
async syncState() {
|
|
738
|
+
const keys = await this.#load();
|
|
739
|
+
const file = this.#file;
|
|
740
|
+
if (file === undefined || keys.sync === undefined) {
|
|
741
|
+
return refuse("locked", NOT_SET_UP);
|
|
742
|
+
}
|
|
743
|
+
return { ok: true, sync: { key: keys.sync, peers: file.open.peers, revision: file.open.revision } };
|
|
744
|
+
}
|
|
745
|
+
async syncPair(pair) {
|
|
746
|
+
const file = this.#file;
|
|
747
|
+
const keys = await this.#load();
|
|
748
|
+
if (file === undefined || keys.sync === undefined) {
|
|
749
|
+
return refuse("locked", NOT_SET_UP);
|
|
750
|
+
}
|
|
751
|
+
const opened = await this.#open(file, {
|
|
752
|
+
title: `Pair with the keyring on ${pair.peer.device}?`,
|
|
753
|
+
message: `This keyring (${pair.self}) is ${keyFingerprint(keys.sync)}; the one on ${pair.peer.device} is ${keyFingerprint(pair.peer.key)}. Say yes only if ${pair.peer.device} shows these same two. Paired, each keeps the other's entries in step.`,
|
|
754
|
+
person: true,
|
|
755
|
+
});
|
|
756
|
+
if ("ok" in opened) {
|
|
757
|
+
return opened;
|
|
758
|
+
}
|
|
759
|
+
const peer = { device: pair.peer.device, key: pair.peer.key, pairedAt: this.deps.now() };
|
|
760
|
+
const peers = [...opened.doc.peers.filter((other) => other.key !== peer.key && other.device !== peer.device), peer];
|
|
761
|
+
await this.#commit({ doc: { ...opened.doc, peers, syncSent: { ...opened.doc.syncSent, [peer.key]: -1 } }, key: opened.key });
|
|
762
|
+
await this.#record({ op: "sync-pair", outcome: "done", target: peer.device, asked: true });
|
|
763
|
+
return { ok: true };
|
|
764
|
+
}
|
|
765
|
+
#outboxFor(doc, peer) {
|
|
766
|
+
const since = doc.syncSent?.[peer.key] ?? -1;
|
|
767
|
+
return Object.values(doc.entries)
|
|
768
|
+
.filter((entry) => entry.revision > since)
|
|
769
|
+
.map((entry) => ({
|
|
770
|
+
from: doc.sync.publicKey,
|
|
771
|
+
to: peer.key,
|
|
772
|
+
revision: entry.revision,
|
|
773
|
+
sealed: seal(peer.key, JSON.stringify(syncEntryOf(entry)), { kind: "sync", account: doc.sync.publicKey, device: peer.key, revision: entry.revision }),
|
|
774
|
+
}));
|
|
775
|
+
}
|
|
776
|
+
async syncOutbox() {
|
|
777
|
+
const held = this.#held();
|
|
778
|
+
if (held === undefined || held.doc.peers.length === 0) {
|
|
779
|
+
return { ok: true, items: [] };
|
|
780
|
+
}
|
|
781
|
+
const items = held.doc.peers.flatMap((peer) => this.#outboxFor(held.doc, peer));
|
|
782
|
+
if (items.length > 0) {
|
|
783
|
+
const syncSent = Object.fromEntries(held.doc.peers.map((peer) => [peer.key, held.doc.revision]));
|
|
784
|
+
await this.#commit({ doc: { ...held.doc, syncSent: { ...held.doc.syncSent, ...syncSent } }, key: held.key });
|
|
785
|
+
}
|
|
786
|
+
return { ok: true, items };
|
|
787
|
+
}
|
|
788
|
+
async syncDeliver(items) {
|
|
789
|
+
const file = this.#file;
|
|
790
|
+
const keys = await this.#load();
|
|
791
|
+
if (file === undefined || keys.sync === undefined) {
|
|
792
|
+
return { ok: true, accepted: 0 };
|
|
793
|
+
}
|
|
794
|
+
const peers = new Set(file.open.peers.map((peer) => peer.key));
|
|
795
|
+
const ours = items.filter((item) => item.to === keys.sync && peers.has(item.from));
|
|
796
|
+
const held = this.#held();
|
|
797
|
+
if (held === undefined) {
|
|
798
|
+
await this.#writeOpen({ ...file.open, pendingSync: [...file.open.pendingSync, ...ours].slice(-PENDING_SYNC_MAX) });
|
|
799
|
+
return { ok: true, accepted: ours.length };
|
|
800
|
+
}
|
|
801
|
+
const merged = this.#mergeSync(held.doc, ours);
|
|
802
|
+
if (merged.accepted > 0) {
|
|
803
|
+
await this.#commit({ doc: merged.doc, key: held.key });
|
|
804
|
+
}
|
|
805
|
+
return { ok: true, accepted: merged.accepted };
|
|
806
|
+
}
|
|
807
|
+
#syncedEntry(doc, item) {
|
|
808
|
+
try {
|
|
809
|
+
const incoming = SyncEntrySchema.parse(JSON.parse(unseal(doc.sync, item.sealed, { kind: "sync", account: item.from, device: item.to, revision: item.revision })));
|
|
810
|
+
const local = doc.entries[incoming.name];
|
|
811
|
+
const shares = incoming.deleted === true ? [] : (local?.shares ?? []);
|
|
812
|
+
return StoredEntrySchema.parse({ ...incoming, shares, revision: doc.revision + 1 });
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
return undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
#mergeSync(doc, items) {
|
|
819
|
+
const peers = new Set(doc.peers.map((peer) => peer.key));
|
|
820
|
+
const entries = { ...doc.entries };
|
|
821
|
+
let accepted = 0;
|
|
822
|
+
for (const item of items) {
|
|
823
|
+
const incoming = peers.has(item.from) && item.to === doc.sync.publicKey ? this.#syncedEntry(doc, item) : undefined;
|
|
824
|
+
if (incoming !== undefined) {
|
|
825
|
+
const local = entries[incoming.name];
|
|
826
|
+
if (local === undefined || local.updatedAt < incoming.updatedAt) {
|
|
827
|
+
entries[incoming.name] = incoming;
|
|
828
|
+
}
|
|
829
|
+
accepted++;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
return { doc: { ...doc, entries }, accepted };
|
|
833
|
+
}
|
|
834
|
+
async askpass(prompt, parent) {
|
|
835
|
+
const file = this.#file;
|
|
836
|
+
if (file === undefined) {
|
|
837
|
+
return refuse("locked", NOT_SET_UP);
|
|
838
|
+
}
|
|
839
|
+
const question = parseAskpassPrompt(prompt);
|
|
840
|
+
const shown = file.open.entries.find((entry) => askpassMatch(question, entry.askpass) !== undefined);
|
|
841
|
+
const matcher = shown === undefined ? undefined : askpassMatch(question, shown.askpass);
|
|
842
|
+
if (shown === undefined || matcher === undefined) {
|
|
843
|
+
return refuse("unknown", `No keyring entry answers "${question.prompt}": \`intentic-machine keyring edit NAME --askpass ${question.matchers[0] ?? "prompt:…"}\` adds one.`);
|
|
844
|
+
}
|
|
845
|
+
if (question.wants === "username") {
|
|
846
|
+
return shown.username === undefined ? refuse("unknown", `${shown.name} has no user name to answer with.`) : { ok: true, value: shown.username };
|
|
847
|
+
}
|
|
848
|
+
const now = this.deps.now();
|
|
849
|
+
const remembered = this.#held() !== undefined && rememberedFresh(this.#remembered.get(matcher), shown.askpassRememberMinutes, now);
|
|
850
|
+
const asker = parent ?? "A program";
|
|
851
|
+
const opened = await this.#open(file, { title: `Answer ${asker}'s prompt?`, message: `${asker} asks: "${question.prompt}". The keyring answers with ${shown.name}.`, person: !remembered });
|
|
852
|
+
if ("ok" in opened) {
|
|
853
|
+
await this.#record({ op: "askpass", outcome: "refused", code: opened.code, secret: shown.name, target: matcher });
|
|
854
|
+
return opened;
|
|
855
|
+
}
|
|
856
|
+
const entry = opened.doc.entries[shown.name];
|
|
857
|
+
if (entry === undefined || entry.deleted === true || askpassMatch(question, entry.rules.askpass) === undefined) {
|
|
858
|
+
return refuse("unknown", `No keyring entry answers "${question.prompt}".`);
|
|
859
|
+
}
|
|
860
|
+
const value = await this.#valueOf(opened.doc, entry);
|
|
861
|
+
if (!value.ok) {
|
|
862
|
+
return value;
|
|
863
|
+
}
|
|
864
|
+
if (entry.rules.askpassRememberMinutes > 0) {
|
|
865
|
+
this.#remembered.set(matcher, now);
|
|
866
|
+
}
|
|
867
|
+
await this.#record({ op: "askpass", outcome: "granted", secret: entry.name, target: matcher, asked: opened.asked, program: parent });
|
|
868
|
+
return { ok: true, value: value.value };
|
|
869
|
+
}
|
|
870
|
+
async consentUpgrade(version) {
|
|
871
|
+
const file = this.#file;
|
|
872
|
+
const answer = await this.deps.consent.ask({
|
|
873
|
+
title: `Replace the keyring with version ${version}?`,
|
|
874
|
+
message: `intentic-machine asks to replace the keyring program on this computer with release ${version}. Say yes if you started this update.`,
|
|
875
|
+
unlock: false,
|
|
876
|
+
opener: file === undefined ? undefined : this.#opener(file),
|
|
877
|
+
});
|
|
878
|
+
await this.#record({ op: "consent-upgrade", outcome: answer.ok ? "done" : "refused", target: version });
|
|
879
|
+
return answer.ok ? { ok: true } : fromConsent(answer);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
//# sourceMappingURL=core.js.map
|