sliftutils 1.7.142 → 1.7.144
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 +1 -0
- package/misc/random.d.ts +1 -0
- package/misc/random.ts +10 -0
- package/package.json +1 -1
- package/security/machines/identity2.ts +88 -37
package/index.d.ts
CHANGED
|
@@ -417,6 +417,7 @@ declare module "sliftutils/misc/random" {
|
|
|
417
417
|
export declare function getSeededRandom(seed: number): () => number;
|
|
418
418
|
export declare function shuffle<T>(array: T[], seed: number): T[];
|
|
419
419
|
export declare function secureRandom(): number;
|
|
420
|
+
export declare function secureRandomHex(byteLength: number): string;
|
|
420
421
|
|
|
421
422
|
}
|
|
422
423
|
|
package/misc/random.d.ts
CHANGED
package/misc/random.ts
CHANGED
|
@@ -42,4 +42,14 @@ export function secureRandom(): number {
|
|
|
42
42
|
crypto.getRandomValues(randomData);
|
|
43
43
|
}
|
|
44
44
|
return randomDataF64[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function secureRandomHex(byteLength: number): string {
|
|
48
|
+
let data = new Uint8Array(byteLength);
|
|
49
|
+
if (!isNode()) {
|
|
50
|
+
window.crypto.getRandomValues(data);
|
|
51
|
+
} else {
|
|
52
|
+
crypto.getRandomValues(data);
|
|
53
|
+
}
|
|
54
|
+
return Array.from(data).map(x => x.toString(16).padStart(2, "0")).join("");
|
|
45
55
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
NOTE: This whole verification process isn't secure if someone man-in-the-middles the IP. There is a way to make it more secure, but right now it is quite a bit of work. Here is the outline, though.
|
|
3
|
+
|
|
4
|
+
a) Use libsodium, do handshake ourself over unsecured websocket (or random self signed websocket), and use ChaCha20 for encryption so it's fast
|
|
5
|
+
- This lets us use ED25519 certificates.
|
|
6
|
+
b) Integrate with socket-function, So we can do everything with regular socket function calls. It's a lot nicer of an interface. We can even have it proxied to Python. As annoying as that is...
|
|
7
|
+
c) Use github pages to host the site, so we can also host the machine keys
|
|
8
|
+
- Every time they change, we need to redeploy, and... we don't get revocations without deploying. BUT... it's better than nothing.
|
|
9
|
+
|
|
10
|
+
NOTE: Just using real HTTPS certificates isn't really an alternative.
|
|
11
|
+
- They won't be bound to IPs
|
|
12
|
+
- Revoking them is a lot more difficult (and they won't be automatically revoked)
|
|
13
|
+
- Once you reach certain level of machines, you're going to run into problems with different certificates per machine
|
|
14
|
+
- You're still going to need a way to give servers access to either the certificates or a way to generate the certificates.
|
|
15
|
+
|
|
16
|
+
So doing it with our own code isn't about cost or difficulty. The reason we would want to implement it ourselves is because HTTPS wasn't made to create an internal network of trust. It's designed around web browsing, and it works well for that. However, for two-way authentication, there are significantly better systems.
|
|
17
|
+
*/
|
|
18
|
+
|
|
1
19
|
import sha256 from "js-sha256";
|
|
2
20
|
import { isNode } from "socket-function/src/misc";
|
|
21
|
+
import { secureRandomHex } from "../../misc/random";
|
|
3
22
|
import { getCommonName, getIdentityCA, getMachineId, getThreadKeyCert, sign, validateCertificate, verify } from "../../misc/https/certs";
|
|
4
23
|
import { getTrustedMachines } from "./machines";
|
|
5
24
|
|
|
6
25
|
const MAX_SIGNED_AGE = 5 * 60 * 1000;
|
|
26
|
+
// Clocks disagree, so a payload from slightly ahead is normal - but one from far ahead would
|
|
27
|
+
// never age out, and so would never stop being accepted.
|
|
28
|
+
const MAX_SIGNED_FUTURE = 60 * 60 * 1000;
|
|
29
|
+
const NONCE_BYTES = 16;
|
|
30
|
+
// Nonces of the requests we have accepted, so none of them can be replayed. Cleared wholesale at
|
|
31
|
+
// the limit rather than expired one by one: reaching it takes a million signed requests inside
|
|
32
|
+
// the five minute window, which is a server being hammered - something we would notice for other
|
|
33
|
+
// reasons - and all it buys is one replay of one request.
|
|
34
|
+
const MAX_SEEN_NONCES = 1000 * 1000;
|
|
35
|
+
let seenNonces = new Set<string>();
|
|
7
36
|
|
|
8
37
|
export type SignedRequest<T> = {
|
|
9
38
|
signature: string;
|
|
@@ -16,6 +45,9 @@ export type SignedRequest<T> = {
|
|
|
16
45
|
// real server, and the middleman cannot write requests of its own because it is not a
|
|
17
46
|
// trusted machine.
|
|
18
47
|
targetId: string;
|
|
48
|
+
targetIsMachineId?: boolean;
|
|
49
|
+
// Makes the request one use only - the server refuses a nonce it has already accepted
|
|
50
|
+
nonce: string;
|
|
19
51
|
// The thread certificate that signed this
|
|
20
52
|
cert: string;
|
|
21
53
|
// The machine CA that issued cert - the machineId comes from its common name
|
|
@@ -24,7 +56,7 @@ export type SignedRequest<T> = {
|
|
|
24
56
|
};
|
|
25
57
|
};
|
|
26
58
|
|
|
27
|
-
export type SignedReply
|
|
59
|
+
export type SignedReply = {
|
|
28
60
|
signature: string;
|
|
29
61
|
payload: {
|
|
30
62
|
time: number;
|
|
@@ -33,7 +65,6 @@ export type SignedReply<T> = {
|
|
|
33
65
|
request: Omit<SignedRequest<unknown>["payload"], "data"> & { dataHash: string };
|
|
34
66
|
cert: string;
|
|
35
67
|
certIssuer: string;
|
|
36
|
-
data: T;
|
|
37
68
|
};
|
|
38
69
|
};
|
|
39
70
|
|
|
@@ -41,12 +72,25 @@ function dataHash(data: unknown) {
|
|
|
41
72
|
return sha256.sha256(JSON.stringify(data));
|
|
42
73
|
}
|
|
43
74
|
|
|
44
|
-
|
|
75
|
+
function assertSignedTime(kind: string, time: number) {
|
|
76
|
+
let oldest = Date.now() - MAX_SIGNED_AGE;
|
|
77
|
+
if (time < oldest) {
|
|
78
|
+
throw new Error(`Signed ${kind} is too old, ${time} < ${oldest}`);
|
|
79
|
+
}
|
|
80
|
+
let newest = Date.now() + MAX_SIGNED_FUTURE;
|
|
81
|
+
if (time > newest) {
|
|
82
|
+
throw new Error(`Signed ${kind} is too far in the future, ${time} > ${newest}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function signRequest<T>(domain: string, config: { targetId: string; targetIsMachineId?: boolean; data: T }): SignedRequest<T> {
|
|
45
87
|
let threadKeyCert = getThreadKeyCert(domain);
|
|
46
88
|
let issuer = getIdentityCA(domain);
|
|
47
89
|
let payload: SignedRequest<T>["payload"] = {
|
|
48
90
|
time: Date.now(),
|
|
49
91
|
targetId: config.targetId,
|
|
92
|
+
targetIsMachineId: config.targetIsMachineId,
|
|
93
|
+
nonce: secureRandomHex(NONCE_BYTES),
|
|
50
94
|
cert: threadKeyCert.cert.toString(),
|
|
51
95
|
certIssuer: issuer.cert.toString(),
|
|
52
96
|
data: config.data,
|
|
@@ -54,57 +98,61 @@ export function signRequest<T>(domain: string, config: { targetId: string; data:
|
|
|
54
98
|
return { signature: sign(threadKeyCert, payload), payload };
|
|
55
99
|
}
|
|
56
100
|
|
|
57
|
-
/** Returns the machineId that signed, the data, and
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
101
|
+
/** Returns the machineId that signed, the data, and the signed reply to send back - always
|
|
102
|
+
produced, so the caller's only job is to return it. Throws if the signature, certificate
|
|
103
|
+
chain, or time do not check out, or if the request was addressed to someone we are not:
|
|
104
|
+
ownIdentities is every name this server answers to - its IPs (there are always several,
|
|
105
|
+
internal and external), its machineId, its threadId - and the signed targetId must be one of
|
|
106
|
+
them.
|
|
107
|
+
|
|
108
|
+
IMPORTANT! You still need to check the machine with isMachineAccepted if you want to know if
|
|
109
|
+
the machine is trusted. We just verify that they are who they say they are, not that who they
|
|
110
|
+
say they are is allowed. */
|
|
63
111
|
export function verifyRequest<T>(domain: string, signed: SignedRequest<T>, ownIdentities: string[]): {
|
|
64
112
|
machineId: string;
|
|
65
113
|
data: T;
|
|
66
|
-
reply
|
|
114
|
+
reply: SignedReply;
|
|
67
115
|
} {
|
|
68
116
|
let { signature, payload } = signed;
|
|
69
|
-
|
|
70
|
-
if (payload.time < signedThreshold) {
|
|
71
|
-
throw new Error(`Signed request too old, ${payload.time} < ${signedThreshold}`);
|
|
72
|
-
}
|
|
117
|
+
assertSignedTime("request", payload.time);
|
|
73
118
|
verify(payload.cert, signature, payload);
|
|
74
119
|
validateCertificate(domain, payload.cert, payload.certIssuer);
|
|
75
120
|
if (!ownIdentities.includes(payload.targetId)) {
|
|
76
121
|
throw new Error(`Request is for someone else. It is addressed to ${payload.targetId}, we are ${ownIdentities.join(", ")}`);
|
|
77
122
|
}
|
|
123
|
+
// After the signature, so only a real signer can take up a slot
|
|
124
|
+
if (seenNonces.has(payload.nonce)) {
|
|
125
|
+
throw new Error(`Request ${payload.nonce} was already used, so it is being refused as a replay`);
|
|
126
|
+
}
|
|
127
|
+
if (seenNonces.size >= MAX_SEEN_NONCES) {
|
|
128
|
+
seenNonces.clear();
|
|
129
|
+
}
|
|
130
|
+
seenNonces.add(payload.nonce);
|
|
78
131
|
let machineId = getMachineId(getCommonName(payload.certIssuer), domain);
|
|
132
|
+
let threadKeyCert = getThreadKeyCert(domain);
|
|
133
|
+
let issuer = getIdentityCA(domain);
|
|
134
|
+
let { data: requestData, ...requestRest } = payload;
|
|
135
|
+
let replyPayload: SignedReply["payload"] = {
|
|
136
|
+
time: Date.now(),
|
|
137
|
+
request: { ...requestRest, dataHash: dataHash(requestData) },
|
|
138
|
+
cert: threadKeyCert.cert.toString(),
|
|
139
|
+
certIssuer: issuer.cert.toString(),
|
|
140
|
+
};
|
|
79
141
|
return {
|
|
80
142
|
machineId,
|
|
81
143
|
data: payload.data,
|
|
82
|
-
reply
|
|
83
|
-
let threadKeyCert = getThreadKeyCert(domain);
|
|
84
|
-
let issuer = getIdentityCA(domain);
|
|
85
|
-
let { data: requestData, ...requestRest } = payload;
|
|
86
|
-
let replyPayload: SignedReply<R>["payload"] = {
|
|
87
|
-
time: Date.now(),
|
|
88
|
-
request: { ...requestRest, dataHash: dataHash(requestData) },
|
|
89
|
-
cert: threadKeyCert.cert.toString(),
|
|
90
|
-
certIssuer: issuer.cert.toString(),
|
|
91
|
-
data,
|
|
92
|
-
};
|
|
93
|
-
return { signature: sign(threadKeyCert, replyPayload), payload: replyPayload };
|
|
94
|
-
},
|
|
144
|
+
reply: { signature: sign(threadKeyCert, replyPayload), payload: replyPayload },
|
|
95
145
|
};
|
|
96
146
|
}
|
|
97
147
|
|
|
98
|
-
/** Returns the machineId that replied
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
machine
|
|
102
|
-
|
|
148
|
+
/** Returns the machineId that replied. Throws if the signature, certificate chain, or time do
|
|
149
|
+
not check out, or if the reply does not answer the request we actually sent. When the request
|
|
150
|
+
said targetIsMachineId, the machine that replied must be the machine the request was
|
|
151
|
+
addressed to. On node the replying machine must also be one of the trusted machines - the
|
|
152
|
+
browser has no machine list, so there that check is skipped. */
|
|
153
|
+
export async function verifyReply(domain: string, request: SignedRequest<unknown>, reply: SignedReply): Promise<{ machineId: string }> {
|
|
103
154
|
let { signature, payload } = reply;
|
|
104
|
-
|
|
105
|
-
if (payload.time < signedThreshold) {
|
|
106
|
-
throw new Error(`Signed reply too old, ${payload.time} < ${signedThreshold}`);
|
|
107
|
-
}
|
|
155
|
+
assertSignedTime("reply", payload.time);
|
|
108
156
|
verify(payload.cert, signature, payload);
|
|
109
157
|
validateCertificate(domain, payload.cert, payload.certIssuer);
|
|
110
158
|
let { data: requestData, ...requestRest } = request.payload;
|
|
@@ -113,10 +161,13 @@ export async function verifyReply<T>(domain: string, request: SignedRequest<unkn
|
|
|
113
161
|
throw new Error(`Reply answers a different request than the one we sent`);
|
|
114
162
|
}
|
|
115
163
|
let machineId = getMachineId(getCommonName(payload.certIssuer), domain);
|
|
164
|
+
if (request.payload.targetIsMachineId && machineId !== request.payload.targetId) {
|
|
165
|
+
throw new Error(`Reply is from machine ${machineId}, but the request was addressed to machine ${request.payload.targetId}`);
|
|
166
|
+
}
|
|
116
167
|
if (isNode()) {
|
|
117
168
|
if (!(await getTrustedMachines()).some(machine => machine.machineId === machineId)) {
|
|
118
169
|
throw new Error(`Reply is signed by ${machineId}, which is not a trusted machine`);
|
|
119
170
|
}
|
|
120
171
|
}
|
|
121
|
-
return { machineId
|
|
172
|
+
return { machineId };
|
|
122
173
|
}
|