sliftutils 1.7.128 → 1.7.129

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.
Files changed (51) hide show
  1. package/bin/addmachine.js +9 -0
  2. package/bin/machineclient.js +9 -0
  3. package/bin/machineserver.js +10 -0
  4. package/index.d.ts +15 -0
  5. package/misc/dist/ownIPs.ts.cache +43 -0
  6. package/misc/dist/strings.ts.cache +2 -2
  7. package/misc/https/dist/certs.ts.cache +2 -2
  8. package/misc/https/dist/persistentLocalStorage.ts.cache +2 -2
  9. package/misc/ownIPs.d.ts +11 -0
  10. package/misc/ownIPs.ts +34 -0
  11. package/package.json +9 -3
  12. package/security/authorizedKeys/authorizedKeys.ts +34 -0
  13. package/security/authorizedKeys/daemon/authLog.ts +12 -5
  14. package/security/authorizedKeys/daemon/changes.ts +16 -1
  15. package/security/authorizedKeys/daemon/daemon.ts +43 -34
  16. package/security/authorizedKeys/daemon/dist/changes.ts.cache +40 -0
  17. package/security/authorizedKeys/daemon/dist/git.ts.cache +121 -0
  18. package/security/authorizedKeys/daemon/dist/notify.ts.cache +39 -0
  19. package/security/authorizedKeys/daemon/dist/paths.ts.cache +29 -0
  20. package/security/authorizedKeys/daemon/dist/repoFiles.ts.cache +114 -0
  21. package/security/authorizedKeys/daemon/dist/revocation.ts.cache +0 -0
  22. package/security/authorizedKeys/daemon/dist/sessions.ts.cache +147 -0
  23. package/security/authorizedKeys/daemon/dist/state.ts.cache +74 -0
  24. package/security/authorizedKeys/daemon/dist/trust.ts.cache +295 -0
  25. package/security/authorizedKeys/daemon/notify.ts +8 -2
  26. package/security/authorizedKeys/daemon/repoFiles.ts +118 -0
  27. package/security/authorizedKeys/daemon/revocation.ts +0 -0
  28. package/security/authorizedKeys/daemon/rootKeys.ts +77 -10
  29. package/security/authorizedKeys/daemon/state.ts +25 -7
  30. package/security/authorizedKeys/daemon/trust.ts +37 -16
  31. package/security/authorizedKeys/daemon/userKeys.ts +5 -4
  32. package/security/authorizedKeys/dist/authorizedKeys.ts.cache +37 -3
  33. package/security/authorizedKeys/dist/revokeSource.ts.cache +17 -4
  34. package/security/authorizedKeys/dist/sources.ts.cache +52 -8
  35. package/security/authorizedKeys/dist/unrevoke.ts.cache +113 -17
  36. package/security/authorizedKeys/secureSSH.ts +39 -21
  37. package/security/authorizedKeys/unrevoke.ts +159 -8
  38. package/security/helpers/dist/remoteSSH.ts.cache +19 -9
  39. package/security/machines/addMachine.ts +83 -0
  40. package/security/machines/dist/addMachine.ts.cache +88 -0
  41. package/security/machines/dist/identity.ts.cache +176 -0
  42. package/security/machines/dist/machines.ts.cache +322 -0
  43. package/security/machines/dist/trustClient.ts.cache +48 -0
  44. package/security/machines/dist/trustState.ts.cache +57 -0
  45. package/security/machines/identity.ts +199 -0
  46. package/security/machines/machines.ts +355 -0
  47. package/security/machines/trustClient.ts +51 -0
  48. package/security/machines/trustServer.ts +85 -0
  49. package/security/machines/trustState.ts +53 -0
  50. package/security/notifications/discord.ts +14 -5
  51. package/security/notifications/dist/discord.ts.cache +17 -8
@@ -0,0 +1,199 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs/promises";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { generateTestCA, getMachineId, identityStorageKey, IdentityStorageType, verifyMachineIdForPublicKey } from "../../misc/https/certs";
6
+ import { runOverSSH, writeRemoteFile } from "../helpers/remoteSSH";
7
+
8
+ // A machine's identity is the CA it generated for itself, kept in one file in the home directory
9
+ // of whoever runs it. The name carries the domain it belongs to, so a machine can hold one per
10
+ // domain, and the id itself is a hash of that CA's public key - which is why a machine cannot
11
+ // claim to be another one without holding its key.
12
+ const IDENTITY_PREFIX = "keystore_";
13
+ const IDENTITY_SUFFIX = `_${identityStorageKey}.json`;
14
+ // How stale a signed packet may be. Long enough for clock skew between two machines, short enough
15
+ // that a captured packet is not worth replaying.
16
+ export const PACKET_LIFETIME = 2 * 60 * 1000;
17
+
18
+ export type Identity = {
19
+ machineId: string;
20
+ domain: string;
21
+ fullDomain: string;
22
+ fileName: string;
23
+ };
24
+
25
+ function identityFileName(domain: string) {
26
+ return `${IDENTITY_PREFIX}${domain}${IDENTITY_SUFFIX}`;
27
+ }
28
+
29
+ /** The domain out of an identity file's name, or "" if it is not one. */
30
+ function domainOfFileName(fileName: string) {
31
+ if (!fileName.startsWith(IDENTITY_PREFIX) || !fileName.endsWith(IDENTITY_SUFFIX)) {
32
+ return "";
33
+ }
34
+ return fileName.slice(IDENTITY_PREFIX.length, -IDENTITY_SUFFIX.length);
35
+ }
36
+
37
+ function describeIdentity(config: { domain: string; stored: IdentityStorageType }) {
38
+ let { domain, stored } = config;
39
+ return {
40
+ machineId: getMachineId(stored.domain, domain),
41
+ domain,
42
+ fullDomain: stored.domain,
43
+ fileName: identityFileName(domain),
44
+ };
45
+ }
46
+
47
+ /** Every domain this machine holds an identity under. A machine has one identity per domain, and
48
+ they are different machine ids, so which one is meant is a real question rather than a detail. */
49
+ export async function localDomains() {
50
+ let domains: string[] = [];
51
+ for (let name of (await fs.readdir(os.homedir()).catch(() => [] as string[])).sort()) {
52
+ let domain = domainOfFileName(name);
53
+ if (domain) {
54
+ domains.push(domain);
55
+ }
56
+ }
57
+ return domains;
58
+ }
59
+
60
+ /** This machine's identity under one domain. Undefined when it has none under that one.
61
+
62
+ The domain is required everywhere it appears. A machine has a different identity, and so a
63
+ different machine id, under every domain it runs under, so "this machine" on its own does not
64
+ name anything: answering under the wrong one means answering as a machine we are not. */
65
+ export async function localIdentity(domain: string): Promise<Identity | undefined> {
66
+ if (!domain) {
67
+ throw new Error(`Expected a domain, was ${JSON.stringify(domain)}`);
68
+ }
69
+ let contents = await fs.readFile(path.join(os.homedir(), identityFileName(domain)), "utf8").catch(() => "");
70
+ if (!contents) {
71
+ return undefined;
72
+ }
73
+ return describeIdentity({ domain, stored: JSON.parse(contents) });
74
+ }
75
+
76
+ /** This machine's identity and its keys, for signing. */
77
+ export async function localIdentityKeys(domain: string) {
78
+ let identity = await localIdentity(domain);
79
+ if (!identity) {
80
+ throw new Error(
81
+ `Expected an identity for ${domain} at ${path.join(os.homedir(), identityFileName(domain))},`
82
+ + ` there is none.\n`
83
+ + `This machine has: ${(await localDomains()).join(", ") || "no identities at all"}`
84
+ );
85
+ }
86
+ let stored: IdentityStorageType = JSON.parse(
87
+ await fs.readFile(path.join(os.homedir(), identity.fileName), "utf8")
88
+ );
89
+ return { identity, stored };
90
+ }
91
+
92
+ /** The identity of another machine under one domain, over ssh. Undefined when it has none under
93
+ that domain, whatever else it may have under others. */
94
+ export async function remoteIdentity(host: string, domain: string): Promise<Identity | undefined> {
95
+ if (!domain) {
96
+ throw new Error(`Expected a domain, was ${JSON.stringify(domain)}`);
97
+ }
98
+ let home = (await runOverSSH({ host, script: `echo $HOME` })).stdout.trim();
99
+ if (!home) {
100
+ throw new Error(`Expected ${host} to report a home directory, it reported nothing`);
101
+ }
102
+ let contents = await runOverSSH({
103
+ host,
104
+ script: `cat ${home}/${identityFileName(domain)} 2>/dev/null || true`,
105
+ });
106
+ if (!contents.stdout.trim()) {
107
+ return undefined;
108
+ }
109
+ return describeIdentity({ domain, stored: JSON.parse(contents.stdout) });
110
+ }
111
+
112
+ /** Gives a machine an identity it does not have yet, by generating one here and writing it there.
113
+
114
+ The CA is self signed and never leaves that machine afterwards, so generating it here is only a
115
+ convenience: what matters is that the private key ends up in one place and the id is the hash
116
+ of its public half. */
117
+ export async function createRemoteIdentity(config: { host: string; domain: string }) {
118
+ let { host, domain } = config;
119
+ let generated = generateTestCA(domain);
120
+ let stored: IdentityStorageType = {
121
+ domain: generated.domain,
122
+ certB64: generated.cert.toString("base64"),
123
+ keyB64: generated.key.toString("base64"),
124
+ };
125
+ let home = (await runOverSSH({ host, script: `echo $HOME` })).stdout.trim();
126
+ await writeRemoteFile({
127
+ host,
128
+ filePath: `${home}/${identityFileName(domain)}`,
129
+ contents: JSON.stringify(stored),
130
+ fileMode: "600",
131
+ // The home directory is already there, this is only what it is created with if it is not.
132
+ directoryMode: "700",
133
+ });
134
+ return describeIdentity({ domain, stored });
135
+ }
136
+
137
+ /** The raw ed25519 public key out of a certificate, which is what the machine id is a hash of. */
138
+ function publicKeyBytes(certPEM: string) {
139
+ let spki = new crypto.X509Certificate(certPEM).publicKey.export({ type: "spki", format: "der" });
140
+ // An ed25519 SPKI is a fixed 12 byte header followed by the 32 byte key.
141
+ return spki.subarray(spki.length - 32);
142
+ }
143
+
144
+ export type TrustPacket = {
145
+ machineId: string;
146
+ certB64: string;
147
+ signedAt: number;
148
+ signatureB64: string;
149
+ };
150
+
151
+ /** Proves we hold the private key behind our machine id, right now. The signature covers the id
152
+ and the time, so the packet cannot be replayed later or reused for another id. */
153
+ export async function signTrustPacket(domain: string): Promise<TrustPacket> {
154
+ let { identity, stored } = await localIdentityKeys(domain);
155
+ let signedAt = Date.now();
156
+ let signature = crypto.sign(
157
+ null,
158
+ Buffer.from(`${identity.machineId} ${signedAt}`),
159
+ crypto.createPrivateKey(Buffer.from(stored.keyB64, "base64").toString())
160
+ );
161
+ return {
162
+ machineId: identity.machineId,
163
+ certB64: stored.certB64,
164
+ signedAt,
165
+ signatureB64: signature.toString("base64"),
166
+ };
167
+ }
168
+
169
+ /** Whether a packet really came from the machine it names. Says why when it did not, because
170
+ "this is not that machine" and "this arrived too late" are different problems. */
171
+ export function verifyTrustPacket(packet: TrustPacket) {
172
+ if (!packet || !packet.machineId || !packet.certB64 || !packet.signatureB64) {
173
+ return { valid: false, problem: "the packet is missing fields" };
174
+ }
175
+ if (Math.abs(Date.now() - packet.signedAt) > PACKET_LIFETIME) {
176
+ return { valid: false, problem: `it was signed ${Math.round((Date.now() - packet.signedAt) / 1000)}s ago` };
177
+ }
178
+ let certPEM = Buffer.from(packet.certB64, "base64").toString();
179
+ let publicKey: crypto.KeyObject;
180
+ try {
181
+ publicKey = new crypto.X509Certificate(certPEM).publicKey;
182
+ } catch (e) {
183
+ return { valid: false, problem: `its certificate could not be read, ${e}` };
184
+ }
185
+ // The id is a hash of the public key, so this is what stops a machine claiming another's id.
186
+ if (!verifyMachineIdForPublicKey({ machineId: packet.machineId, publicKey: publicKeyBytes(certPEM) })) {
187
+ return { valid: false, problem: "the machine id does not belong to that certificate" };
188
+ }
189
+ let signed = crypto.verify(
190
+ null,
191
+ Buffer.from(`${packet.machineId} ${packet.signedAt}`),
192
+ publicKey,
193
+ Buffer.from(packet.signatureB64, "base64")
194
+ );
195
+ if (!signed) {
196
+ return { valid: false, problem: "the signature does not check out" };
197
+ }
198
+ return { valid: true, problem: "" };
199
+ }
@@ -0,0 +1,355 @@
1
+ import fs from "fs/promises";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { CONFIG_PATH } from "../authorizedKeys/daemon/paths";
5
+ import { revokeRepo, syncRepoFiles } from "../authorizedKeys/daemon/repoFiles";
6
+ import { listRepoDir, readRepoFile } from "../authorizedKeys/daemon/repoFiles";
7
+ import { newRevocationId, pairKey, readRevocationFiles, readUnrevokes } from "../authorizedKeys/daemon/revocation";
8
+ import { runGit } from "../authorizedKeys/daemon/git";
9
+ import { ensureRevokeKey } from "../authorizedKeys/daemon/repoFiles";
10
+ import { verifyCheckout } from "../authorizedKeys/daemon/trust";
11
+ import { revokeRepoPath, revokeRepoURL } from "../authorizedKeys/revokeSource";
12
+ import { sourceRepoPath } from "../authorizedKeys/sources";
13
+ import { notify } from "../authorizedKeys/daemon/notify";
14
+ import { areDiscordNotificationsConfigured, configureDiscordNotifications, DEFAULT_WEBHOOK_FILE_PATH } from "../notifications/discord";
15
+ import { unrevokeInEffect } from "./trustState";
16
+ import { spawnPromise } from "../helpers/spawn";
17
+
18
+ // Which machines this system talks to, kept in the same repo as the ssh keys. That repo is already
19
+ // signed, already distributed to every machine, and already has somewhere to record a rejection,
20
+ // so a second one would only be a second thing to keep in step.
21
+ export const MACHINES_DIR = "machines";
22
+ const REVOCATIONS_DIR = "revocations";
23
+ const REVOCATION_REASON = "a machine talked to us from an unapproved IP";
24
+ // The revoke repo is pulled at most this often. A check happens per request, and a request must
25
+ // not cost a round trip to github.
26
+ const REVOKE_SYNC_INTERVAL = 60 * 1000;
27
+ // Where a Windows machine is expected to keep the repo, relative to the working directory. There
28
+ // is no daemon there to ask, and no /etc to look in.
29
+ const WINDOWS_REPO_PATH = "../authorized_keys";
30
+
31
+ /** One machine we are willing to talk to, and the addresses it may talk to us from. */
32
+ export type MachineState = {
33
+ machineId: string;
34
+ ips: string[];
35
+ addedAt: string;
36
+ };
37
+
38
+ async function pathExists(filePath: string) {
39
+ try {
40
+ await fs.access(filePath);
41
+ return true;
42
+ } catch (e) {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ function machineFilePath(repoPath: string, machineId: string) {
48
+ return path.join(repoPath, MACHINES_DIR, `${machineId}.json`);
49
+ }
50
+
51
+ /** Reads every machine a checkout lists, or sets them.
52
+
53
+ Passing `machines` makes the repo match it exactly: machines named are written with the
54
+ addresses given, and machines not named are removed. That is why the addresses are part of
55
+ setting rather than a separate step - a machine with no address it may talk from is not a
56
+ machine we would accept anyway, so there is no state where naming one without them means
57
+ anything. Read first, change what you want, write the result.
58
+
59
+ Nothing here signs or commits anything. The signature is what every other machine checks before
60
+ believing any of this, and it takes the hardware key, so `yarn signfiles git` is still yours to
61
+ run afterwards. */
62
+ export async function machineState(config: {
63
+ repoPath: string;
64
+ machines?: { machineId: string; ips: string[] }[];
65
+ }): Promise<MachineState[]> {
66
+ let { repoPath, machines } = config;
67
+ let existing = await readMachines(repoPath);
68
+ if (!machines) {
69
+ return [...existing.values()];
70
+ }
71
+
72
+ let directory = path.join(repoPath, MACHINES_DIR);
73
+ let written: MachineState[] = [];
74
+ for (let machine of machines) {
75
+ if (!machine.machineId) {
76
+ throw new Error(`Expected a machineId, was ${JSON.stringify(machine.machineId)}`);
77
+ }
78
+ if (!machine.ips.length) {
79
+ throw new Error(
80
+ `Expected addresses for ${machine.machineId}, was none. A machine is trusted from`
81
+ + ` the addresses it may talk from, so leave it out of the list to remove it.`
82
+ );
83
+ }
84
+ let ips = machine.ips.filter((ip, index) => ip && machine.ips.indexOf(ip) === index);
85
+ let state: MachineState = {
86
+ machineId: machine.machineId,
87
+ // In the order given, without duplicates, so the file reads the way it was set.
88
+ ips,
89
+ addedAt: existing.get(machine.machineId)?.addedAt || new Date().toISOString(),
90
+ };
91
+ await fs.mkdir(directory, { recursive: true });
92
+ await fs.writeFile(machineFilePath(repoPath, state.machineId), JSON.stringify(state, undefined, 4) + "\n");
93
+ written.push(state);
94
+ }
95
+
96
+ // Whatever the repo had and the caller did not name is no longer trusted.
97
+ for (let machineId of existing.keys()) {
98
+ if (!machines.some(machine => machine.machineId === machineId)) {
99
+ await fs.rm(machineFilePath(repoPath, machineId), { force: true });
100
+ }
101
+ }
102
+ return written;
103
+ }
104
+
105
+ /** Every machine the repo lists. */
106
+ async function readMachines(repoPath: string) {
107
+ let machines = new Map<string, MachineState>();
108
+ let directory = path.join(repoPath, MACHINES_DIR);
109
+ let names = await fs.readdir(directory).catch(() => [] as string[]);
110
+ for (let name of names.sort()) {
111
+ if (!name.endsWith(".json")) {
112
+ continue;
113
+ }
114
+ try {
115
+ let parsed = JSON.parse(await fs.readFile(path.join(directory, name), "utf8"));
116
+ let machineId = parsed.machineId || name.replace(/\.json$/, "");
117
+ machines.set(machineId, { machineId, ips: parsed.ips || [], addedAt: parsed.addedAt || "" });
118
+ } catch (e) {
119
+ console.log(`Ignoring unreadable machine file ${name}. ${e}`);
120
+ }
121
+ }
122
+ return machines;
123
+ }
124
+
125
+ async function isKeysRepo(repoPath: string) {
126
+ return await pathExists(path.join(repoPath, ".git"))
127
+ && await pathExists(path.join(repoPath, "authorized_keys"));
128
+ }
129
+
130
+ async function originOf(repoPath: string) {
131
+ let origin = await spawnPromise({ command: "git", args: ["remote", "get-url", "origin"], cwd: repoPath });
132
+ let url = origin.stdout.trim();
133
+ if (origin.status !== 0 || !url) {
134
+ throw new Error(`Expected ${repoPath} to have an origin remote, it has none`);
135
+ }
136
+ return url;
137
+ }
138
+
139
+ /** The keys repo this machine answers from, and the one anything editing the machine list edits.
140
+
141
+ On a host, that is the checkout the daemon already keeps up to date. On Windows there is no
142
+ daemon and no /etc, so the repo is expected beside the working directory, which is where a
143
+ developer working on both would have it.
144
+
145
+ Never the directory the command happens to be run from. Which repo this machine trusts is a
146
+ property of the machine, not of where somebody was standing when they typed something. */
147
+ export async function resolveKeysRepo() {
148
+ if (os.platform() === "win32") {
149
+ let repoPath = path.resolve(WINDOWS_REPO_PATH);
150
+ if (!await isKeysRepo(repoPath)) {
151
+ throw new Error(
152
+ `Expected ${repoPath} to be an authorized_keys repo, it is not.\n`
153
+ + `On Windows the repo is read from there, so clone it beside this one:\n`
154
+ + ` git clone <your authorized_keys repo> ${repoPath}`
155
+ );
156
+ }
157
+ return { repoPath, sourceURL: await originOf(repoPath) };
158
+ }
159
+
160
+ let config = await fs.readFile(CONFIG_PATH, "utf8").catch(() => "");
161
+ let sourceURL = config && (JSON.parse(config).repoSources || [])[0] || "";
162
+ if (!sourceURL) {
163
+ throw new Error(
164
+ `Expected this machine to be set up with an authorized_keys repo, ${CONFIG_PATH} names none.\n`
165
+ + `Set it up first:\n`
166
+ + ` yarn setupnotify <discord-webhook-url>\n`
167
+ + ` yarn securessh add <repo-private-key> <repo-url>`
168
+ );
169
+ }
170
+ let repoPath = sourceRepoPath(sourceURL);
171
+ if (!await isKeysRepo(repoPath)) {
172
+ throw new Error(
173
+ `Expected a checkout of ${sourceURL} at ${repoPath}, there is none.\n`
174
+ + `Run \`yarn securessh update\` to put it back.`
175
+ );
176
+ }
177
+ return { repoPath, sourceURL };
178
+ }
179
+
180
+ let lastRevokeSync = 0;
181
+
182
+ /** Pulled at most once a REVOKE_SYNC_INTERVAL, because this is asked per request. A sync that
183
+ fails leaves the checkout we already have, which is the safe direction: revocations we know
184
+ about stay known. */
185
+ async function syncRevocations(sourceURL: string) {
186
+ if (Date.now() - lastRevokeSync < REVOKE_SYNC_INTERVAL) {
187
+ return;
188
+ }
189
+ lastRevokeSync = Date.now();
190
+ try {
191
+ await syncRepoFiles(revokeRepo(sourceURL));
192
+ } catch (e) {
193
+ console.log(`Could not read ${revokeRepoURL(sourceURL)}, using the revocations already here. ${e}`);
194
+ }
195
+ }
196
+
197
+ /** Machine revocations, read out of the revoke repo the same way key revocations are. */
198
+ async function readMachineRevocations(sourceURL: string) {
199
+ let repo = revokeRepo(sourceURL);
200
+ let revocations: { revocationId: string; machineId: string; ip: string }[] = [];
201
+ for (let name of await listRepoDir(repo, REVOCATIONS_DIR)) {
202
+ if (!name.endsWith(".json")) {
203
+ continue;
204
+ }
205
+ try {
206
+ let parsed = JSON.parse(await readRepoFile(repo, path.join(REVOCATIONS_DIR, name)) || "");
207
+ if (parsed.machineId) {
208
+ revocations.push({
209
+ revocationId: parsed.revocationId || name.replace(/\.json$/, ""),
210
+ machineId: parsed.machineId,
211
+ ip: parsed.ip || "",
212
+ });
213
+ }
214
+ } catch (e) {
215
+ console.log(`Ignoring unreadable revocation ${name}. ${e}`);
216
+ }
217
+ }
218
+ return revocations;
219
+ }
220
+
221
+ /** Sends the one notification this file is allowed to send, when it can.
222
+
223
+ The daemon configures notifications on startup and would simply send. This can also run in some
224
+ other process, which has not, so the webhook is picked up here if it is readable. A machine
225
+ with no webhook set up still records the revocation - being unable to tell anyone is not a
226
+ reason to keep accepting a machine that is being misused. */
227
+ async function notifyBestEffort(headline: string, body: string) {
228
+ try {
229
+ if (!areDiscordNotificationsConfigured()) {
230
+ // Read first: configureDiscordNotifications exits the process when the file is missing,
231
+ // which is right for the daemon at startup and wrong for a library call.
232
+ await fs.readFile(DEFAULT_WEBHOOK_FILE_PATH, "utf8");
233
+ await configureDiscordNotifications({ filePath: DEFAULT_WEBHOOK_FILE_PATH });
234
+ }
235
+ await notify(headline, body);
236
+ } catch (e) {
237
+ console.log(`Could not send a notification about this, it is only in the log. ${e}`);
238
+ }
239
+ }
240
+
241
+ /** Records that a machine we accept talked to us from an address it is not allowed from. One per
242
+ machine and address, so a second address is a second revocation and an unrevoke of the first
243
+ does not cover it. */
244
+ async function recordMachineRevocation(config: {
245
+ sourceURL: string;
246
+ machineId: string;
247
+ ip: string;
248
+ hostLabel: string;
249
+ }) {
250
+ let { sourceURL, machineId, ip, hostLabel } = config;
251
+ let repoPath = revokeRepoPath(sourceURL);
252
+ let keyPath = await ensureRevokeKey(sourceURL);
253
+ let revocationId = newRevocationId(machineId);
254
+ let directory = path.join(repoPath, REVOCATIONS_DIR);
255
+ await fs.mkdir(directory, { recursive: true });
256
+ await fs.writeFile(path.join(directory, `${revocationId}.json`), JSON.stringify({
257
+ revocationId,
258
+ machineId,
259
+ ip,
260
+ revokedAt: new Date().toISOString(),
261
+ revokedBy: hostLabel,
262
+ reason: REVOCATION_REASON,
263
+ }, undefined, 4) + "\n");
264
+
265
+ await runGit({ args: ["add", "-A"], cwd: repoPath, keyPath });
266
+ await runGit({
267
+ args: ["-c", "user.email=portsecure@localhost", "-c", "user.name=portsecure", "commit", "-m", `revoke ${revocationId}`],
268
+ cwd: repoPath, keyPath,
269
+ });
270
+ let push = await runGit({ args: ["push", "origin", "HEAD"], cwd: repoPath, keyPath, allowFailure: true });
271
+ if (push.status !== 0) {
272
+ // Another machine most likely recorded the same thing first, and the next read picks it up.
273
+ console.log(`Could not push the revocation of ${machineId} from ${ip}. ${(push.stdout + push.stderr).trim()}`);
274
+ return;
275
+ }
276
+ console.log(`Revoked ${machineId} from ${ip}, ${revocationId}`);
277
+
278
+ // Said by whoever wrote the revocation, once, the same as for an ssh key. Machines that only
279
+ // read it later say nothing, or one event would be reported by every machine that saw it.
280
+ await notifyBestEffort(
281
+ `SUSPICIOUS IP ${ip} FROZE MACHINE ${machineId}`,
282
+ `A machine we trust talked to us from ${ip}, which is not an address it is allowed to talk`
283
+ + ` from. It proved it holds that machine's key, so either someone else has a copy of it,`
284
+ + ` or that machine's address changed.`
285
+ + `\n\nIt is frozen everywhere now, and nothing accepts it.`
286
+ + `\n\nIf this was an attack, remove \`machines/${machineId}.json\` from \`${sourceURL}\` now.`
287
+ + `\nIf it was legitimate, run \`yarn unrevoke git\` in that repo. It allows ${ip} for that`
288
+ + ` machine, and takes an hour to reach every machine.`
289
+ + `\n\nmachine: \`${machineId}\``
290
+ + `\nfrozen by: \`${hostLabel}\``
291
+ );
292
+ }
293
+
294
+ /** Whether we will talk to this machine, coming from this address.
295
+
296
+ Both halves are required and neither is a guess: the caller knows the machine id because the
297
+ connection proved it, and knows the address because the packets came from there. A machine we
298
+ do not list is simply not accepted. A machine we do list, arriving from an address it does not
299
+ have, is treated as the same kind of event as a stolen ssh key - it is revoked everywhere, and
300
+ stays revoked until an unrevoke allows that machine from that address.
301
+
302
+ Throws when the repo itself cannot be read or is not signed, rather than answering false: that
303
+ is a broken installation, not a rejected machine, and the two deserve different handling. */
304
+ export async function isMachineAccepted(config: { machineId: string; ip: string }): Promise<boolean> {
305
+ let { machineId, ip } = config;
306
+ if (!machineId || !ip) {
307
+ throw new Error(`Expected a machineId and an ip, was ${JSON.stringify(machineId)} and ${JSON.stringify(ip)}`);
308
+ }
309
+ let { repoPath, sourceURL } = await resolveKeysRepo();
310
+ // Unsigned, or signed over different contents, means the machine list is not evidence of
311
+ // anything. Same gate the ssh keys go through.
312
+ await verifyCheckout(repoPath);
313
+
314
+ let machine = (await readMachines(repoPath)).get(machineId);
315
+ if (!machine) {
316
+ return false;
317
+ }
318
+
319
+ await syncRevocations(sourceURL);
320
+ let unrevokes = await readUnrevokes(sourceURL).catch(() => ({ pairs: new Map(), legacyIds: new Map() }));
321
+ // An unrevoke is only honoured once it has waited out its hour, exactly as an ssh key's is.
322
+ let allowedAgain = async (pair: string) => {
323
+ let unrevokeId = unrevokes.pairs.get(pair);
324
+ return !!unrevokeId && await unrevokeInEffect(unrevokeId);
325
+ };
326
+ let revocations = await readMachineRevocations(sourceURL);
327
+ // Any revocation nothing has undone keeps the machine out, from everywhere, the way a revoked
328
+ // ssh key is out everywhere rather than only from the address it was misused from.
329
+ let revoked = false;
330
+ for (let revocation of revocations) {
331
+ if (revocation.machineId !== machineId) {
332
+ continue;
333
+ }
334
+ if (!await allowedAgain(pairKey({ fingerprint: revocation.machineId, ip: revocation.ip }))) {
335
+ revoked = true;
336
+ }
337
+ }
338
+ if (revoked) {
339
+ return false;
340
+ }
341
+
342
+ if (machine.ips.includes(ip)) {
343
+ return true;
344
+ }
345
+
346
+ // Listed, but talking to us from somewhere it should not be. Recorded once for this machine
347
+ // and address, so being talked to repeatedly does not write repeatedly.
348
+ let pair = pairKey({ fingerprint: machineId, ip });
349
+ let alreadyRecorded = revocations.some(revocation =>
350
+ pairKey({ fingerprint: revocation.machineId, ip: revocation.ip }) === pair);
351
+ if (!alreadyRecorded && !await allowedAgain(pair)) {
352
+ await recordMachineRevocation({ sourceURL, machineId, ip, hostLabel: os.hostname() });
353
+ }
354
+ return false;
355
+ }
@@ -0,0 +1,51 @@
1
+ import { signTrustPacket } from "./identity";
2
+
3
+ // The other half of the demonstration: asks a server whether it trusts this machine. The answer
4
+ // depends on the address this machine reaches the server from, which is why the client cannot
5
+ // work it out alone.
6
+ const TRUST_PATH = "/trusted";
7
+ const USAGE = `Usage: yarn machineclient <url> <domain>
8
+
9
+ Asks that server whether this machine is trusted, proving which machine it is with a signed packet.
10
+ The server decides using the address we reach it from, so the answer is about how we are talking to
11
+ it, not only about who we are.
12
+
13
+ The domain is required: this machine has a different identity under each one, and the answer is
14
+ about the identity we sign with.`;
15
+
16
+ export type TrustAnswer = {
17
+ trusted: boolean;
18
+ machineId?: string;
19
+ ip?: string;
20
+ reason?: string;
21
+ };
22
+
23
+ /** Asks one server whether it trusts this machine, as it is under this domain. */
24
+ export async function askIfTrusted(config: { baseURL: string; domain: string }): Promise<TrustAnswer> {
25
+ let { baseURL, domain } = config;
26
+ let packet = await signTrustPacket(domain);
27
+ let url = `${baseURL.replace(/\/+$/, "")}${TRUST_PATH}`;
28
+ let response = await fetch(url, {
29
+ method: "POST",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify(packet),
32
+ });
33
+ return await response.json() as TrustAnswer;
34
+ }
35
+
36
+ export async function main() {
37
+ let [baseURL, domain] = process.argv.slice(2);
38
+ if (!baseURL || !domain) {
39
+ throw new Error(USAGE);
40
+ }
41
+ let answer = await askIfTrusted({ baseURL, domain });
42
+ console.log(`${answer.trusted && "TRUSTED" || "NOT TRUSTED"} by ${baseURL}`);
43
+ console.log(` machine ${answer.machineId || "(not established)"}`);
44
+ console.log(` seen coming from ${answer.ip || "(unknown)"}`);
45
+ if (answer.reason) {
46
+ console.log(` ${answer.reason}`);
47
+ }
48
+ if (!answer.trusted) {
49
+ process.exitCode = 1;
50
+ }
51
+ }