sliftutils 1.7.132 → 1.7.133
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/index.d.ts +6 -1
- package/misc/https/certs.d.ts +2 -1
- package/misc/https/certs.ts +9 -8
- package/misc/https/persistentLocalStorage.d.ts +4 -0
- package/misc/https/persistentLocalStorage.ts +7 -1
- package/package.json +3 -7
- package/security/keys/sshKeyFile.ts +3 -1
- package/security/machines/addMachine.ts +38 -34
- package/security/machines/identity2.ts +39 -0
- package/security/machines/machines.ts +6 -2
- package/storage/backblaze.ts +11 -0
- package/bin/machineclient.js +0 -9
- package/bin/machineserver.js +0 -10
- package/security/machines/identity.ts +0 -199
- package/security/machines/trustClient.ts +0 -51
- package/security/machines/trustServer.ts +0 -87
package/index.d.ts
CHANGED
|
@@ -77,6 +77,7 @@ declare module "sliftutils/misc/https/certs" {
|
|
|
77
77
|
certB64: string;
|
|
78
78
|
keyB64: string;
|
|
79
79
|
};
|
|
80
|
+
export declare function DEV_getIdentityFilePath(domain: string): string;
|
|
80
81
|
export interface X509KeyPair {
|
|
81
82
|
domain: string;
|
|
82
83
|
cert: Buffer;
|
|
@@ -105,7 +106,7 @@ declare module "sliftutils/misc/https/certs" {
|
|
|
105
106
|
publicKey: forge.Ed25519PublicKey;
|
|
106
107
|
privateKey: forge.Ed25519PrivateKey;
|
|
107
108
|
};
|
|
108
|
-
export declare function
|
|
109
|
+
export declare function generateCA(domain: string): X509KeyPair;
|
|
109
110
|
export declare function createCertFromCA(config: {
|
|
110
111
|
CAKeyPair: X509KeyPair;
|
|
111
112
|
}): X509KeyPair;
|
|
@@ -320,6 +321,10 @@ declare module "sliftutils/misc/https/node-forge-ed25519" {
|
|
|
320
321
|
}
|
|
321
322
|
|
|
322
323
|
declare module "sliftutils/misc/https/persistentLocalStorage" {
|
|
324
|
+
export declare function DEV_getKeyStorePath(config: {
|
|
325
|
+
appName: string;
|
|
326
|
+
key: string;
|
|
327
|
+
}): string;
|
|
323
328
|
export declare function getKeyStore<T>(appName: string, key: string): {
|
|
324
329
|
get(): T | undefined;
|
|
325
330
|
set(value: T | null): void;
|
package/misc/https/certs.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type IdentityStorageType = {
|
|
|
10
10
|
certB64: string;
|
|
11
11
|
keyB64: string;
|
|
12
12
|
};
|
|
13
|
+
export declare function DEV_getIdentityFilePath(domain: string): string;
|
|
13
14
|
export interface X509KeyPair {
|
|
14
15
|
domain: string;
|
|
15
16
|
cert: Buffer;
|
|
@@ -38,7 +39,7 @@ export declare function generateKeyPair(): {
|
|
|
38
39
|
publicKey: forge.Ed25519PublicKey;
|
|
39
40
|
privateKey: forge.Ed25519PrivateKey;
|
|
40
41
|
};
|
|
41
|
-
export declare function
|
|
42
|
+
export declare function generateCA(domain: string): X509KeyPair;
|
|
42
43
|
export declare function createCertFromCA(config: {
|
|
43
44
|
CAKeyPair: X509KeyPair;
|
|
44
45
|
}): X509KeyPair;
|
package/misc/https/certs.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { measureBlock, measureFnc, measureWrap } from "socket-function/src/profi
|
|
|
18
18
|
import { getNodeIdDomain, getNodeIdDomainMaybeUndefined, getNodeIdLocation } from "socket-function/src/nodeCache";
|
|
19
19
|
import { SocketFunction } from "socket-function/SocketFunction";
|
|
20
20
|
import { resetAllNodeCallFactories } from "socket-function/src/nodeCache";
|
|
21
|
-
import { getKeyStore } from "./persistentLocalStorage";
|
|
21
|
+
import { getKeyStore, DEV_getKeyStorePath } from "./persistentLocalStorage";
|
|
22
22
|
import { ellipsize } from "../strings";
|
|
23
23
|
|
|
24
24
|
setFlag(require, "node-forge", "allowclient", true);
|
|
@@ -35,6 +35,12 @@ function getIdentityStore(domain: string) {
|
|
|
35
35
|
return getKeyStore<IdentityStorageType>(domain, identityStorageKey);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Just used for machine maintenance (finding a machine's identity file on disk, ex to copy it to
|
|
39
|
+
// another machine over ssh). NEVER access this unless explicitly given permission.
|
|
40
|
+
export function DEV_getIdentityFilePath(domain: string): string {
|
|
41
|
+
return DEV_getKeyStorePath({ appName: domain, key: identityStorageKey });
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
export interface X509KeyPair { domain: string; cert: Buffer; key: Buffer; }
|
|
39
45
|
|
|
40
46
|
export function getCommonName(cert: Buffer | string) {
|
|
@@ -331,7 +337,7 @@ export function generateKeyPair() {
|
|
|
331
337
|
});
|
|
332
338
|
}
|
|
333
339
|
|
|
334
|
-
export function
|
|
340
|
+
export function generateCA(domain: string) {
|
|
335
341
|
const keyPair = generateKeyPair();
|
|
336
342
|
let caPublicKeyPart = getDomainPartFromPublicKey(keyPair.publicKey);
|
|
337
343
|
let fullDomain = `${caPublicKeyPart}.${domain}`;
|
|
@@ -343,12 +349,7 @@ let identityCA = cache((domain: string) => lazy((): X509KeyPair => {
|
|
|
343
349
|
let caCached = identityCACached.get();
|
|
344
350
|
if (!caCached) {
|
|
345
351
|
console.log(`Generating new identity CA`);
|
|
346
|
-
|
|
347
|
-
let caPublicKeyPart = getDomainPartFromPublicKey(keyPair.publicKey);
|
|
348
|
-
let fullDomain = `${caPublicKeyPart}.${domain}`;
|
|
349
|
-
|
|
350
|
-
let value = createX509({ domain: fullDomain, issuer: "self", keyPair, lifeSpan: timeInDay * 365 * 20 });
|
|
351
|
-
|
|
352
|
+
let value = generateCA(domain);
|
|
352
353
|
caCached = {
|
|
353
354
|
domain: value.domain,
|
|
354
355
|
certB64: value.cert.toString("base64"),
|
|
@@ -3,12 +3,18 @@ import fs from "fs";
|
|
|
3
3
|
import os from "os";
|
|
4
4
|
import { cache } from "socket-function/src/caching";
|
|
5
5
|
|
|
6
|
+
// Just used for machine maintenance (finding a key store file on disk, ex to copy it to another
|
|
7
|
+
// machine over ssh). NEVER access this unless explicitly given permission.
|
|
8
|
+
export function DEV_getKeyStorePath(config: { appName: string; key: string }): string {
|
|
9
|
+
return os.homedir() + `/keystore_${config.appName}_` + config.key + ".json";
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export function getKeyStore<T>(appName: string, key: string): {
|
|
7
13
|
get(): T | undefined;
|
|
8
14
|
set(value: T | null): void;
|
|
9
15
|
} {
|
|
10
16
|
if (isNode()) {
|
|
11
|
-
let path =
|
|
17
|
+
let path = DEV_getKeyStorePath({ appName, key });
|
|
12
18
|
return {
|
|
13
19
|
get() {
|
|
14
20
|
let contents: string | undefined = undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sliftutils",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.133",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"files": [
|
|
@@ -35,9 +35,7 @@
|
|
|
35
35
|
"derivekey": "node ./bin/derivekey.js",
|
|
36
36
|
"unrevoke": "node ./bin/unrevoke.js",
|
|
37
37
|
"testnotify": "node ./bin/testnotify.js",
|
|
38
|
-
"addmachine": "node ./bin/addmachine.js"
|
|
39
|
-
"machineserver": "node ./bin/machineserver.js",
|
|
40
|
-
"machineclient": "node ./bin/machineclient.js"
|
|
38
|
+
"addmachine": "node ./bin/addmachine.js"
|
|
41
39
|
},
|
|
42
40
|
"bin": {
|
|
43
41
|
"filehoster": "./bin/filehoster.js",
|
|
@@ -62,9 +60,7 @@
|
|
|
62
60
|
"portsecuredaemon": "./bin/portsecuredaemon.js",
|
|
63
61
|
"unrevoke": "./bin/unrevoke.js",
|
|
64
62
|
"testnotify": "./bin/testnotify.js",
|
|
65
|
-
"addmachine": "./bin/addmachine.js"
|
|
66
|
-
"machineserver": "./bin/machineserver.js",
|
|
67
|
-
"machineclient": "./bin/machineclient.js"
|
|
63
|
+
"addmachine": "./bin/addmachine.js"
|
|
68
64
|
},
|
|
69
65
|
"dependencies": {
|
|
70
66
|
"@types/chrome": "^0.0.237",
|
|
@@ -65,7 +65,9 @@ export function publicKeyFromSeed(seed: Buffer) {
|
|
|
65
65
|
type: "pkcs8",
|
|
66
66
|
});
|
|
67
67
|
// The spki wrapper is a fixed 12 byte header followed by the raw key.
|
|
68
|
-
|
|
68
|
+
// The cast is because some @types/node versions don't accept a KeyObject here, even though the
|
|
69
|
+
// runtime always has. Consumers compile us with their own @types/node, so this has to not fail.
|
|
70
|
+
return crypto.createPublicKey(privateKey as any).export({ format: "der", type: "spki" }).subarray(12);
|
|
69
71
|
}
|
|
70
72
|
|
|
71
73
|
export function parseOpenSSHPrivateKey(contents: string) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import path from "path";
|
|
2
2
|
import { runPromise } from "socket-function/src/runPromise";
|
|
3
|
+
import { DEV_getIdentityFilePath, generateCA, getMachineId, getOwnMachineId, IdentityStorageType, loadIdentityCA } from "../../misc/https/certs";
|
|
3
4
|
import { getOwnIPs } from "../../misc/ownIPs";
|
|
4
|
-
import { describeHost } from "../helpers/remoteSSH";
|
|
5
|
-
import { createRemoteIdentity, Identity, localDomains, localIdentity, remoteIdentity } from "./identity";
|
|
5
|
+
import { describeHost, runOverSSH, writeRemoteFile } from "../helpers/remoteSSH";
|
|
6
6
|
import { resolveKeysRepo } from "../authorizedKeys/keysRepo";
|
|
7
7
|
import { signRepo } from "../signedFiles/signFiles";
|
|
8
8
|
import { getMachines, setMachines } from "./machines";
|
|
@@ -55,46 +55,51 @@ function parseArgs(argv: string[]) {
|
|
|
55
55
|
return { domain, host: second, machineId: "", addresses: [] as string[], pushToGit };
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/** The machine id of a machine reached over ssh, generating and installing an identity for it if
|
|
59
|
+
it has none under this domain. The private key ends up on that machine and nowhere else. */
|
|
60
|
+
async function getOrCreateRemoteMachineId(host: string, domain: string): Promise<string> {
|
|
61
|
+
let fileName = path.basename(DEV_getIdentityFilePath(domain));
|
|
62
|
+
let home = (await runOverSSH({ host, script: `echo $HOME` })).stdout.trim();
|
|
63
|
+
if (!home) {
|
|
64
|
+
throw new Error(`Expected ${host} to report a home directory, it reported nothing`);
|
|
65
|
+
}
|
|
66
|
+
let contents = await runOverSSH({ host, script: `cat ${home}/${fileName} 2>/dev/null || true` });
|
|
67
|
+
if (contents.stdout.trim()) {
|
|
68
|
+
let stored = JSON.parse(contents.stdout) as IdentityStorageType;
|
|
69
|
+
return getMachineId(stored.domain, domain);
|
|
70
|
+
}
|
|
71
|
+
console.log(`${describeHost(host)} has no identity for ${domain}, generating one`);
|
|
72
|
+
let generated = generateCA(domain);
|
|
73
|
+
let stored: IdentityStorageType = {
|
|
74
|
+
domain: generated.domain,
|
|
75
|
+
certB64: generated.cert.toString("base64"),
|
|
76
|
+
keyB64: generated.key.toString("base64"),
|
|
77
|
+
};
|
|
78
|
+
await writeRemoteFile({
|
|
79
|
+
host,
|
|
80
|
+
filePath: `${home}/${fileName}`,
|
|
81
|
+
contents: JSON.stringify(stored),
|
|
82
|
+
fileMode: "600",
|
|
83
|
+
directoryMode: "700",
|
|
84
|
+
});
|
|
85
|
+
return getMachineId(generated.domain, domain);
|
|
86
|
+
}
|
|
87
|
+
|
|
58
88
|
export async function main() {
|
|
59
89
|
let { domain, host, machineId, addresses, pushToGit } = parseArgs(process.argv.slice(2));
|
|
60
90
|
// The same repo the acceptance check reads. Editing anything else would write a machine list
|
|
61
91
|
// nothing on this machine ever looks at.
|
|
62
92
|
let { repoPath } = await resolveKeysRepo();
|
|
63
93
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// public key, so naming it is enough - it still has to prove it holds the key before any
|
|
68
|
-
// of this means anything.
|
|
69
|
-
describe = `${machineId} (named)`;
|
|
70
|
-
} else if (!host) {
|
|
71
|
-
let identity = await localIdentity(domain);
|
|
72
|
-
if (!identity) {
|
|
73
|
-
throw new Error(
|
|
74
|
-
`Expected an identity for ${domain} in ${os.homedir()}, this machine has none.\n`
|
|
75
|
-
+ `It has: ${(await localDomains()).join(", ") || "no identities at all"}\n`
|
|
76
|
-
+ `One is generated the first time something runs here under a domain, so run that`
|
|
77
|
-
+ ` first, or name another machine to add instead.`
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
machineId = identity.machineId;
|
|
81
|
-
describe = identity.fullDomain;
|
|
94
|
+
if (!machineId && !host) {
|
|
95
|
+
await loadIdentityCA(domain);
|
|
96
|
+
machineId = getOwnMachineId(domain);
|
|
82
97
|
addresses = await getOwnIPs();
|
|
83
98
|
if (!addresses.length) {
|
|
84
99
|
throw new Error(`Expected this machine to have an address anything else can see, it has none`);
|
|
85
100
|
}
|
|
86
|
-
} else {
|
|
87
|
-
|
|
88
|
-
// is written there and nothing keeps a copy.
|
|
89
|
-
let found = await remoteIdentity(host, domain);
|
|
90
|
-
if (!found) {
|
|
91
|
-
console.log(`${describeHost(host)} has no identity for ${domain}, generating one`);
|
|
92
|
-
}
|
|
93
|
-
let identity: Identity = found || await createRemoteIdentity({ host, domain });
|
|
94
|
-
machineId = identity.machineId;
|
|
95
|
-
describe = identity.fullDomain;
|
|
96
|
-
// The address it was named by is the address it talks to us from, which is the one thing
|
|
97
|
-
// it cannot lie about.
|
|
101
|
+
} else if (host) {
|
|
102
|
+
machineId = await getOrCreateRemoteMachineId(host, domain);
|
|
98
103
|
addresses = [host];
|
|
99
104
|
}
|
|
100
105
|
|
|
@@ -118,7 +123,6 @@ export async function main() {
|
|
|
118
123
|
} else {
|
|
119
124
|
console.log(` already allowed from ${addresses.join(", ")}`);
|
|
120
125
|
}
|
|
121
|
-
console.log(` identity ${describe}`);
|
|
122
126
|
console.log(` in ${repoPath}`);
|
|
123
127
|
|
|
124
128
|
if (!pushToGit) {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getCommonName, getIdentityCA, getMachineId, getThreadKeyCert, sign, validateCertificate, verify } from "../../misc/https/certs";
|
|
2
|
+
|
|
3
|
+
const MAX_SIGNED_AGE = 5 * 60 * 1000;
|
|
4
|
+
|
|
5
|
+
export type SignedPayload<T> = {
|
|
6
|
+
signature: string;
|
|
7
|
+
payload: {
|
|
8
|
+
time: number;
|
|
9
|
+
cert: string;
|
|
10
|
+
certIssuer: string;
|
|
11
|
+
data: T;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function signAsMachine<T>(domain: string, data: T): SignedPayload<T> {
|
|
16
|
+
let threadKeyCert = getThreadKeyCert(domain);
|
|
17
|
+
let issuer = getIdentityCA(domain);
|
|
18
|
+
let payload = {
|
|
19
|
+
time: Date.now(),
|
|
20
|
+
cert: threadKeyCert.cert.toString(),
|
|
21
|
+
certIssuer: issuer.cert.toString(),
|
|
22
|
+
data,
|
|
23
|
+
};
|
|
24
|
+
return { signature: sign(threadKeyCert, payload), payload };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Returns the machineId that signed, and the data. Throws if the signature, certificate chain,
|
|
28
|
+
or time do not check out. */
|
|
29
|
+
export function verifyMachineSigned<T>(domain: string, signed: SignedPayload<T>): { machineId: string; data: T } {
|
|
30
|
+
let { signature, payload } = signed;
|
|
31
|
+
let signedThreshold = Date.now() - MAX_SIGNED_AGE;
|
|
32
|
+
if (payload.time < signedThreshold) {
|
|
33
|
+
throw new Error(`Signed payload too old, ${payload.time} < ${signedThreshold}`);
|
|
34
|
+
}
|
|
35
|
+
verify(payload.cert, signature, payload);
|
|
36
|
+
validateCertificate(domain, payload.cert, payload.certIssuer);
|
|
37
|
+
let machineId = getMachineId(getCommonName(payload.certIssuer), domain);
|
|
38
|
+
return { machineId, data: payload.data };
|
|
39
|
+
}
|
|
@@ -11,6 +11,7 @@ import { verifyCheckout } from "../authorizedKeys/daemon/trust";
|
|
|
11
11
|
import { revokeRepoPath, revokeRepoURL } from "../authorizedKeys/revokeSource";
|
|
12
12
|
import { notify } from "../authorizedKeys/daemon/notify";
|
|
13
13
|
import { areDiscordNotificationsConfigured, configureDiscordNotifications, DEFAULT_WEBHOOK_FILE_PATH } from "../notifications/discord";
|
|
14
|
+
import { getOwnMachineId } from "../../misc/https/certs";
|
|
14
15
|
import { lazy } from "socket-function/src/caching";
|
|
15
16
|
import { runInfinitePoll } from "socket-function/src/batching";
|
|
16
17
|
import { spawnPromise } from "../helpers/spawn";
|
|
@@ -32,6 +33,7 @@ const MACHINES_REFRESH_INTERVAL = 60 * 1000;
|
|
|
32
33
|
// the machine is set up, and should pick it up once it is, without being restarted - but it must
|
|
33
34
|
// not pay for resolving the repo on every request either.
|
|
34
35
|
const REPO_RETRY_DELAY = 15 * 1000;
|
|
36
|
+
const LOOPBACK = ["127.0.0.1", "::1", "::ffff:127.0.0.1"];
|
|
35
37
|
|
|
36
38
|
/** One machine we are willing to talk to, and the addresses it may talk to us from. */
|
|
37
39
|
export type MachineState = {
|
|
@@ -322,14 +324,16 @@ export type MachineVerdict = { accepted: boolean; reason: string };
|
|
|
322
324
|
export async function isMachineAccepted(config: {
|
|
323
325
|
machineId: string;
|
|
324
326
|
ip: string;
|
|
325
|
-
//
|
|
326
|
-
// is answering as saves whoever is refused from having to guess it.
|
|
327
|
+
// Used for local loopback acceptance, and for better error messages.
|
|
327
328
|
domain?: string;
|
|
328
329
|
}): Promise<MachineVerdict> {
|
|
329
330
|
let { machineId, ip, domain } = config;
|
|
330
331
|
if (!machineId || !ip) {
|
|
331
332
|
throw new Error(`Expected a machineId and an ip, was ${JSON.stringify(machineId)} and ${JSON.stringify(ip)}`);
|
|
332
333
|
}
|
|
334
|
+
if (LOOPBACK.includes(ip) && !!domain && machineId === getOwnMachineId(domain)) {
|
|
335
|
+
return { accepted: true, reason: "" };
|
|
336
|
+
}
|
|
333
337
|
let { sourceURL } = await keysRepo();
|
|
334
338
|
// Verified before it is believed, and cached, since this is asked once per request.
|
|
335
339
|
let machine = (await getTrustedMachines()).find(entry => entry.machineId === machineId);
|
package/storage/backblaze.ts
CHANGED
|
@@ -562,6 +562,17 @@ export class ArchivesBackblaze implements IArchives {
|
|
|
562
562
|
} catch (err: any) {
|
|
563
563
|
if (retries <= 0) throw err;
|
|
564
564
|
|
|
565
|
+
// A file that does not exist is an answer, not a failure. It is the one error here that
|
|
566
|
+
// cannot become anything else by asking again, so it is thrown at once instead of being
|
|
567
|
+
// slept on twice - callers turn it into undefined, which is what they were asking.
|
|
568
|
+
if (
|
|
569
|
+
err.stack.includes(`"not_found"`)
|
|
570
|
+
|| err.stack.includes(`file_not_found`)
|
|
571
|
+
|| err.stack.includes(`"status": 404`)
|
|
572
|
+
) {
|
|
573
|
+
throw err;
|
|
574
|
+
}
|
|
575
|
+
|
|
565
576
|
// If it's a 503 and it's been a minute since we last reset, then Wait and reset.
|
|
566
577
|
if (
|
|
567
578
|
(err.stack.includes(`"status": 503`)
|
package/bin/machineclient.js
DELETED
package/bin/machineserver.js
DELETED
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
// Answers whether the machine asking is trusted, from the address it is asking from.
|
|
4
|
-
require("typenode");
|
|
5
|
-
|
|
6
|
-
// No exit on success: this one is a server, and returning from main only means it is listening.
|
|
7
|
-
require("../security/machines/trustServer").main().catch(e => {
|
|
8
|
-
console.error(`${e}`);
|
|
9
|
-
process.exit(1);
|
|
10
|
-
});
|
|
@@ -1,199 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import http from "http";
|
|
2
|
-
import { verifyTrustPacket } from "./identity";
|
|
3
|
-
import { isMachineAccepted } from "./machines";
|
|
4
|
-
|
|
5
|
-
// A demonstration of machine trust, and the smallest thing that exercises it end to end: a client
|
|
6
|
-
// proves which machine it is, and this answers whether that machine is trusted from the address it
|
|
7
|
-
// is actually talking from.
|
|
8
|
-
//
|
|
9
|
-
// Plain http on purpose. Nothing here is secret - the packet proves who the client is by signature
|
|
10
|
-
// rather than by the channel, and the answer is a yes or no about a machine the caller already
|
|
11
|
-
// knows the id of.
|
|
12
|
-
const DEFAULT_PORT = 47812;
|
|
13
|
-
const TRUST_PATH = "/trusted";
|
|
14
|
-
const MAX_BODY_LENGTH = 64 * 1024;
|
|
15
|
-
|
|
16
|
-
/** The address the client is actually talking from. Not read from anything the client sent,
|
|
17
|
-
because that is the one half of the pair it must not get to choose. */
|
|
18
|
-
function addressOf(request: http.IncomingMessage) {
|
|
19
|
-
let address = request.socket.remoteAddress || "";
|
|
20
|
-
// Node reports IPv4 over a dual stack socket this way.
|
|
21
|
-
return address.replace(/^::ffff:/, "");
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
async function readBody(request: http.IncomingMessage) {
|
|
25
|
-
let chunks: Buffer[] = [];
|
|
26
|
-
let length = 0;
|
|
27
|
-
for await (let chunk of request) {
|
|
28
|
-
length += chunk.length;
|
|
29
|
-
if (length > MAX_BODY_LENGTH) {
|
|
30
|
-
throw new Error(`Expected at most ${MAX_BODY_LENGTH} bytes, was more`);
|
|
31
|
-
}
|
|
32
|
-
chunks.push(chunk);
|
|
33
|
-
}
|
|
34
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function startTrustServer(config: { port: number }) {
|
|
38
|
-
let server = http.createServer((request, response) => {
|
|
39
|
-
let answer = async () => {
|
|
40
|
-
if (request.url !== TRUST_PATH) {
|
|
41
|
-
return { status: 404, body: { error: `POST a trust packet to ${TRUST_PATH}` } };
|
|
42
|
-
}
|
|
43
|
-
let packet = JSON.parse(await readBody(request));
|
|
44
|
-
let ip = addressOf(request);
|
|
45
|
-
let verified = verifyTrustPacket(packet);
|
|
46
|
-
if (!verified.valid) {
|
|
47
|
-
// Not a rejection of the machine, a packet that does not prove anything about one.
|
|
48
|
-
return { status: 400, body: { trusted: false, ip, reason: verified.problem } };
|
|
49
|
-
}
|
|
50
|
-
let verdict = await isMachineAccepted({ machineId: packet.machineId, ip });
|
|
51
|
-
return {
|
|
52
|
-
status: 200,
|
|
53
|
-
body: {
|
|
54
|
-
trusted: verdict.accepted,
|
|
55
|
-
machineId: packet.machineId,
|
|
56
|
-
ip,
|
|
57
|
-
// Whatever the check itself said. It is the only thing that knows which of the
|
|
58
|
-
// ways to be refused this was.
|
|
59
|
-
reason: verdict.reason,
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
};
|
|
63
|
-
answer().then(result => {
|
|
64
|
-
console.log(`${request.method} ${request.url} from ${addressOf(request)}: ${JSON.stringify(result.body)}`);
|
|
65
|
-
response.writeHead(result.status, { "Content-Type": "application/json" });
|
|
66
|
-
response.end(JSON.stringify(result.body, undefined, 4));
|
|
67
|
-
}).catch(e => {
|
|
68
|
-
console.log(`${request.method} ${request.url} from ${addressOf(request)} failed. ${e}`);
|
|
69
|
-
response.writeHead(500, { "Content-Type": "application/json" });
|
|
70
|
-
response.end(JSON.stringify({ trusted: false, error: `${e}` }, undefined, 4));
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
server.listen(config.port);
|
|
74
|
-
return server;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function main() {
|
|
78
|
-
let port = Number(process.argv[2] || DEFAULT_PORT);
|
|
79
|
-
if (!Number.isFinite(port)) {
|
|
80
|
-
throw new Error(`Usage: yarn machineserver [port]`);
|
|
81
|
-
}
|
|
82
|
-
startTrustServer({ port });
|
|
83
|
-
console.log(`Answering trust checks on http://0.0.0.0:${port}${TRUST_PATH}`);
|
|
84
|
-
console.log(`A client asks with: yarn machineclient http://<this machine>:${port}`);
|
|
85
|
-
// Left running. Nothing else to do here, the server is the program.
|
|
86
|
-
await new Promise(() => { });
|
|
87
|
-
}
|