anyos-client 0.2.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 +218 -0
- package/bin/anyos.js +18 -0
- package/connect-line.js +75 -0
- package/connect.css +166 -0
- package/connect.html +68 -0
- package/connect.js +265 -0
- package/guard.js +76 -0
- package/local.js +197 -0
- package/machines.js +225 -0
- package/main.js +287 -0
- package/package.json +38 -0
- package/preload.cjs +18 -0
- package/secrets.js +203 -0
package/secrets.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secrets: the token a kernel asks for, kept apart from the machine list.
|
|
3
|
+
*
|
|
4
|
+
* machines.json is a plain document — a name and an address per machine,
|
|
5
|
+
* safe to read over a shoulder or hand-edit, and `clean()` refuses even a
|
|
6
|
+
* user name inside an address. A password does not belong in it, so it
|
|
7
|
+
* lives here instead, keyed by the same machine id.
|
|
8
|
+
*
|
|
9
|
+
* Where the OS offers a keychain, Electron's safeStorage seals each value
|
|
10
|
+
* with it and what lands on disk is ciphertext. Where it does not — a Linux
|
|
11
|
+
* box with no keyring is the usual case — the value is written as it is and
|
|
12
|
+
* the record says so, because a store that lies about how it kept your
|
|
13
|
+
* secret is worse than one that keeps it badly. Ask `sealed()` and tell the
|
|
14
|
+
* person. The file is 0600 either way.
|
|
15
|
+
*
|
|
16
|
+
* The same file also keeps each machine's pinned certificate fingerprint.
|
|
17
|
+
* That is not a secret — the kernel prints it at startup for anyone to read —
|
|
18
|
+
* but it is a claim about who the machine is, and quietly editing one is an
|
|
19
|
+
* attack. It belongs with the token rather than in a document people are
|
|
20
|
+
* told is safe to hand-edit, so it lives here, written plain because
|
|
21
|
+
* pretending to seal a public value would be theatre.
|
|
22
|
+
*
|
|
23
|
+
* Nothing here reaches Electron directly: the cipher is handed in, so this
|
|
24
|
+
* runs and is tested without a display, like the rest of the client.
|
|
25
|
+
*/
|
|
26
|
+
import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A store over one JSON file. `cipher` is Electron's safeStorage, or
|
|
31
|
+
* anything with the same three calls; omit it and secrets are kept plain.
|
|
32
|
+
*/
|
|
33
|
+
export function createSecrets(file, { cipher = null } = {}) {
|
|
34
|
+
const sealing = () => { try { return Boolean(cipher?.isEncryptionAvailable()); } catch { return false; } };
|
|
35
|
+
const data = load();
|
|
36
|
+
|
|
37
|
+
function load() {
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
40
|
+
const tokens = parsed?.tokens;
|
|
41
|
+
const pins = parsed?.pins;
|
|
42
|
+
return {
|
|
43
|
+
tokens: tokens && typeof tokens === 'object' ? tokens : {},
|
|
44
|
+
pins: pins && typeof pins === 'object' ? pins : {}
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return { tokens: {}, pins: {} };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function save() {
|
|
52
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
53
|
+
writeFileSync(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
54
|
+
/* mode only applies to a file being created — an existing one keeps
|
|
55
|
+
whatever it had, so say it again every time */
|
|
56
|
+
try { chmodSync(file, 0o600); } catch { /* not every filesystem has modes */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
/** Whether what goes to disk is ciphertext. The picker says this out loud. */
|
|
61
|
+
sealed: sealing,
|
|
62
|
+
|
|
63
|
+
/** The token for a machine, or null. A sealed value that will not open is a null, not a throw. */
|
|
64
|
+
get(id) {
|
|
65
|
+
const row = data.tokens[String(id)];
|
|
66
|
+
if (!row || typeof row.value !== 'string') return null;
|
|
67
|
+
if (row.how !== 'sealed') return row.value;
|
|
68
|
+
try { return cipher.decryptString(Buffer.from(row.value, 'base64')); } catch { return null; }
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
/** Keep a token, or forget it when the token is empty — one call for both, so a blank field clears. */
|
|
72
|
+
set(id, token) {
|
|
73
|
+
const value = String(token ?? '');
|
|
74
|
+
const key = String(id);
|
|
75
|
+
if (!value) {
|
|
76
|
+
if (!(key in data.tokens)) return;
|
|
77
|
+
delete data.tokens[key];
|
|
78
|
+
return save();
|
|
79
|
+
}
|
|
80
|
+
data.tokens[key] = sealing()
|
|
81
|
+
? { how: 'sealed', value: cipher.encryptString(value).toString('base64') }
|
|
82
|
+
: { how: 'plain', value };
|
|
83
|
+
save();
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/** Is there one at all — asked without opening it, so the picker can show a field as filled. */
|
|
87
|
+
has(id) { return Boolean(data.tokens[String(id)]); },
|
|
88
|
+
|
|
89
|
+
/** The fingerprint this machine's certificate must have, or null. */
|
|
90
|
+
pin(id) {
|
|
91
|
+
const kept = data.pins[String(id)];
|
|
92
|
+
return typeof kept === 'string' && kept ? kept : null;
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
/** Pin a fingerprint, or drop it when the field is cleared. Stored normalised. */
|
|
96
|
+
setPin(id, fingerprint) {
|
|
97
|
+
const value = normalise(fingerprint);
|
|
98
|
+
const key = String(id);
|
|
99
|
+
if (!value) {
|
|
100
|
+
if (!(key in data.pins)) return;
|
|
101
|
+
delete data.pins[key];
|
|
102
|
+
return save();
|
|
103
|
+
}
|
|
104
|
+
data.pins[key] = value;
|
|
105
|
+
save();
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/** A machine dropped from the list takes its secret and its pin with it. */
|
|
109
|
+
forget(id) { this.set(id, ''); this.setPin(id, ''); }
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One fingerprint, in the form everything here compares: uppercase hex pairs
|
|
115
|
+
* separated by colons, which is what the kernel prints and what Node's
|
|
116
|
+
* X509Certificate.fingerprint256 gives back.
|
|
117
|
+
*
|
|
118
|
+
* What arrives may be neither — someone pastes it lowercase, or without the
|
|
119
|
+
* colons, or with a space where the terminal wrapped it. All of those mean
|
|
120
|
+
* the same certificate, so all of them are accepted and stored the same way.
|
|
121
|
+
* Anything that is not 32 bytes of hex is not a fingerprint at all.
|
|
122
|
+
*/
|
|
123
|
+
export function normalise(fingerprint) {
|
|
124
|
+
const hex = String(fingerprint ?? '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
|
125
|
+
if (hex.length !== 64) return '';
|
|
126
|
+
return hex.match(/../g).join(':');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Do two fingerprints name the same certificate, however they were written? */
|
|
130
|
+
export function samePin(a, b) {
|
|
131
|
+
const left = normalise(a);
|
|
132
|
+
return Boolean(left) && left === normalise(b);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The keyring: which secret to answer a challenge from a given origin with.
|
|
137
|
+
*
|
|
138
|
+
* Separate from the store above because most of what it holds never reaches
|
|
139
|
+
* disk — a kernel this client started has a token that exists only in two
|
|
140
|
+
* live processes. What lands here is whatever the window will need before it
|
|
141
|
+
* is pointed at a URL.
|
|
142
|
+
*
|
|
143
|
+
* The attempt count is the whole reason this is not a bare Map. Chromium asks
|
|
144
|
+
* again every time a credential is refused, so answering with the same wrong
|
|
145
|
+
* secret forever is an infinite loop with a spinner on it. One refusal proves
|
|
146
|
+
* it is wrong; after that we decline and let the kernel's own 401 be seen.
|
|
147
|
+
*/
|
|
148
|
+
export function createKeyring() {
|
|
149
|
+
const keys = new Map(); /* host:port → the secret */
|
|
150
|
+
const tried = new Map(); /* host:port → times offered since it was set */
|
|
151
|
+
const pins = new Map(); /* host:port → the fingerprint its certificate must have */
|
|
152
|
+
|
|
153
|
+
const origin = url => {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = new URL(url);
|
|
156
|
+
return `${parsed.hostname}:${parsed.port || (parsed.protocol === 'https:' ? '443' : '80')}`;
|
|
157
|
+
} catch { return null; }
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
origin,
|
|
162
|
+
|
|
163
|
+
/** Hold a secret for whatever is at this URL — or drop it, when there is none. */
|
|
164
|
+
offer(url, token) {
|
|
165
|
+
const at = origin(url);
|
|
166
|
+
if (!at) return null;
|
|
167
|
+
tried.delete(at); /* a freshly given secret has not been refused yet */
|
|
168
|
+
if (token) keys.set(at, token); else keys.delete(at);
|
|
169
|
+
return at;
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* What to answer `host:port` with, or null to decline. Counts the asking:
|
|
174
|
+
* a second challenge for the same origin means the first answer was wrong.
|
|
175
|
+
*/
|
|
176
|
+
answer(host, port) {
|
|
177
|
+
const at = `${host}:${port}`;
|
|
178
|
+
const token = keys.get(at);
|
|
179
|
+
const attempts = (tried.get(at) ?? 0) + 1;
|
|
180
|
+
tried.set(at, attempts);
|
|
181
|
+
return token && attempts === 1 ? token : null;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/** Hold the fingerprint whatever is at this URL must present — or drop it. */
|
|
185
|
+
expect(url, fingerprint) {
|
|
186
|
+
const at = origin(url);
|
|
187
|
+
if (!at) return null;
|
|
188
|
+
const value = normalise(fingerprint);
|
|
189
|
+
if (value) pins.set(at, value); else pins.delete(at);
|
|
190
|
+
return at;
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The fingerprint required of `host:port`, or null when nothing is pinned
|
|
195
|
+
* there. Null means refuse: a certificate no one vouched for and no one
|
|
196
|
+
* pinned is not one to accept.
|
|
197
|
+
*/
|
|
198
|
+
expected(host, port) { return pins.get(`${host}:${port}`) ?? null; },
|
|
199
|
+
|
|
200
|
+
/** For tests and for forgetting a machine. */
|
|
201
|
+
has(url) { return keys.has(origin(url)); }
|
|
202
|
+
};
|
|
203
|
+
}
|