frugal-iot-server 0.3.8 → 2.0.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/INSTALLATION.md +219 -61
- package/config.d/email.yaml +23 -0
- package/config.d/schema/devices.yaml +0 -2
- package/config.d/schema/modules.yaml +344 -2
- package/config.d/schema/topics.yaml +140 -46
- package/extras/aclfile +38 -0
- package/extras/mosquitto.conf +29 -0
- package/frugal-iot-createdb.sql +82 -1
- package/frugal-iot-server.js +692 -80
- package/lib/api-routes.js +7 -7
- package/lib/config-for-user.js +115 -0
- package/lib/dynsec-plan.js +246 -0
- package/lib/dynsec-server.js +204 -0
- package/lib/dynsec-sync.js +209 -0
- package/lib/dynsec.js +166 -0
- package/lib/enrol.js +288 -0
- package/lib/mailer.js +43 -0
- package/lib/replica.js +246 -0
- package/lib/resetcode.js +103 -0
- package/lib/retained.js +110 -0
- package/lib/secrets.js +148 -0
- package/package.json +12 -5
- package/public/index.html +8 -8
- package/public/service-worker.js +76 -27
- package/scripts/addbridge-pi.zsh +91 -7
- package/scripts/addbridge-prod.zsh +57 -3
- package/scripts/addorganization.zsh +5 -0
- package/scripts/clearretained.js +185 -0
- package/scripts/diagnostic.zsh +121 -2
- package/scripts/dynsec-init.js +142 -0
- package/scripts/init.zsh +64 -1
- package/scripts/install-pi.sh +83 -4
- package/scripts/migrate-permissions-project.sql +21 -0
- package/scripts/not-published.txt +5 -0
- package/scripts/rebuild-dynsec.js +190 -0
- package/scripts/resetnode.js +69 -0
- package/scripts/clearretained.zsh +0 -153
package/lib/mailer.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sending mail, configured in config.d/email.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Absent or incomplete configuration means this server does not send mail. That is a supported
|
|
5
|
+
* state - most installations are a Pi in a shed - so mailConfigured() lets a caller say so plainly
|
|
6
|
+
* instead of accepting a request and dropping it.
|
|
7
|
+
*/
|
|
8
|
+
import nodemailer from 'nodemailer';
|
|
9
|
+
|
|
10
|
+
let transport = null;
|
|
11
|
+
let mailFrom = null;
|
|
12
|
+
|
|
13
|
+
// emailConfig is config.email, i.e. config.d/email.yaml. Returns a line for the startup log.
|
|
14
|
+
function mailInit(emailConfig) {
|
|
15
|
+
transport = null;
|
|
16
|
+
mailFrom = null;
|
|
17
|
+
if (!emailConfig || !emailConfig.host || !emailConfig.from) {
|
|
18
|
+
return "Not sending mail (no host/from in config.d/email.yaml) - password reset is unavailable";
|
|
19
|
+
}
|
|
20
|
+
const port = emailConfig.port || 587;
|
|
21
|
+
transport = nodemailer.createTransport({
|
|
22
|
+
host: emailConfig.host,
|
|
23
|
+
port,
|
|
24
|
+
// 465 is TLS from the first byte; 587 and 25 start plain and STARTTLS. Wrong either way is a
|
|
25
|
+
// connection that hangs rather than an error that says what is wrong, so derive it.
|
|
26
|
+
secure: (emailConfig.secure !== undefined) ? emailConfig.secure : (port === 465),
|
|
27
|
+
auth: emailConfig.user ? { user: emailConfig.user, pass: emailConfig.pass } : undefined,
|
|
28
|
+
});
|
|
29
|
+
mailFrom = emailConfig.from;
|
|
30
|
+
return `Sending mail via ${emailConfig.host}:${port} as ${mailFrom}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mailConfigured() { return !!transport; }
|
|
34
|
+
|
|
35
|
+
// cb(err) - nodemailer is promise-based, wrapped here because the rest of this codebase is callbacks
|
|
36
|
+
function sendMail({ to, subject, text, html }, cb) {
|
|
37
|
+
if (!transport) { return cb(new Error("Mail is not configured on this server")); }
|
|
38
|
+
transport.sendMail({ from: mailFrom, to, subject, text, html })
|
|
39
|
+
.then(() => cb(null))
|
|
40
|
+
.catch((err) => cb(err));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { mailInit, mailConfigured, sendMail };
|
package/lib/replica.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sharing a production server's logins with a bridged Pi (SECURITY.md S11).
|
|
3
|
+
*
|
|
4
|
+
* The problem: a bridge relays TOPICS, not accounts. A person who logs into production therefore has
|
|
5
|
+
* no account on the Pi's broker, and the Pi is often the only thing reachable - it goes on working
|
|
6
|
+
* when the link is down, which is why it is there.
|
|
7
|
+
*
|
|
8
|
+
* What travels: username, the stored password hash, its salt, and the permission rows for one
|
|
9
|
+
* organization. Nothing derived from a password, and no secret shared - the Pi authenticates the
|
|
10
|
+
* login itself against that hash (the same pbkdf2 comparison production does) and derives its own
|
|
11
|
+
* broker credential from its OWN user_secret. So the two brokers hand out different passwords for
|
|
12
|
+
* the same person, and neither could compute the other's.
|
|
13
|
+
*
|
|
14
|
+
* Direction: the Pi PULLS. It sits behind a home router that production cannot reach, and its
|
|
15
|
+
* broker bridge is already an outbound connection for the same reason.
|
|
16
|
+
*
|
|
17
|
+
* Revocation: each pull REPLACES that organization's replicated rows. A permission that stops being
|
|
18
|
+
* sent stops existing, with nothing to diff and no way for a stale row to survive.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { randomBytes } from 'crypto';
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
* Replicated users take local id OFFSET + their production id.
|
|
25
|
+
*
|
|
26
|
+
* They cannot use production's ids directly: addorganization creates a login on the Pi too, and
|
|
27
|
+
* both machines allocate from AUTOINCREMENT starting at 2, so the ids would collide. The offset
|
|
28
|
+
* holds only while no installation has OFFSET-2 local users of its own, which is a safe bet for a
|
|
29
|
+
* farm and is asserted below rather than assumed.
|
|
30
|
+
*/
|
|
31
|
+
export const REPLICA_ID_OFFSET = 1000;
|
|
32
|
+
|
|
33
|
+
export function isReplicatedId(id) { return Number(id) >= REPLICA_ID_OFFSET; }
|
|
34
|
+
|
|
35
|
+
export function newBridgeToken() { return randomBytes(32).toString('base64url'); }
|
|
36
|
+
|
|
37
|
+
// ---- production side ---------------------------------------------------------------------------
|
|
38
|
+
/*
|
|
39
|
+
* What one organization's bridge is allowed to know: its people and what they may do.
|
|
40
|
+
*
|
|
41
|
+
* Deliberately not the whole users table - a Pi hosting one organization has no business knowing
|
|
42
|
+
* about anyone else's. id 0 ("everyone") is included, because the Pi's own permission checks read
|
|
43
|
+
* "id = ? or id = 0" and would otherwise disagree with production about what is public.
|
|
44
|
+
*/
|
|
45
|
+
export function replicaFor(db, org, cb) {
|
|
46
|
+
db.all(`SELECT DISTINCT id FROM permissions WHERE org = ? AND id > 0`, [org], (err, ids) => {
|
|
47
|
+
if (err) return cb(err);
|
|
48
|
+
const list = ids.map((r) => r.id);
|
|
49
|
+
const marks = list.map(() => '?').join(',') || 'NULL';
|
|
50
|
+
db.all(`SELECT id, username, hashed_password, salt, name, email
|
|
51
|
+
FROM users WHERE id IN (${marks}) AND username IS NOT NULL`, list, (uerr, users) => {
|
|
52
|
+
if (uerr) return cb(uerr);
|
|
53
|
+
db.all('SELECT id, capability, org, project FROM permissions WHERE org = ? AND (id > 0 OR id = 0)',
|
|
54
|
+
[org], (perr, permissions) => {
|
|
55
|
+
if (perr) return cb(perr);
|
|
56
|
+
cb(null, {
|
|
57
|
+
org,
|
|
58
|
+
at: Date.now(),
|
|
59
|
+
// Buffers do not survive JSON, and the Pi needs them back as BLOBs or its
|
|
60
|
+
// timingSafeEqual comparison throws instead of failing
|
|
61
|
+
users: users.map((u) => ({
|
|
62
|
+
id: u.id,
|
|
63
|
+
username: u.username,
|
|
64
|
+
hashed_password: u.hashed_password ? Buffer.from(u.hashed_password).toString('base64') : null,
|
|
65
|
+
salt: u.salt ? Buffer.from(u.salt).toString('base64') : null,
|
|
66
|
+
name: u.name,
|
|
67
|
+
email: u.email,
|
|
68
|
+
})),
|
|
69
|
+
permissions,
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Which bridge a token belongs to, or nothing. Constant-timeish is not the concern here: a token is
|
|
77
|
+
// 32 random bytes, and the lookup is by exact match.
|
|
78
|
+
export function bridgeForToken(db, token, cb) {
|
|
79
|
+
if (!token) return cb(null, null);
|
|
80
|
+
db.get('SELECT org, site FROM bridges WHERE token = ?', [token], cb);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function noteBridgePull(db, org, site, cb) {
|
|
84
|
+
cb = cb || (() => {});
|
|
85
|
+
db.run('UPDATE bridges SET last_pull = ? WHERE org = ? AND site = ?', [Date.now(), org, site], cb);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- the Pi's side -----------------------------------------------------------------------------
|
|
89
|
+
/*
|
|
90
|
+
* Apply one organization's replica, replacing whatever was there.
|
|
91
|
+
*
|
|
92
|
+
* cb(err, {users, permissions, skipped}) - skipped names logins that could not be taken because a
|
|
93
|
+
* LOCAL account already uses that name. Overwriting one would silently change who can log in as
|
|
94
|
+
* whom, so it is refused and reported.
|
|
95
|
+
*/
|
|
96
|
+
export function applyReplica(db, org, payload, cb) {
|
|
97
|
+
if (!payload || payload.org !== org || !Array.isArray(payload.users) || !Array.isArray(payload.permissions)) {
|
|
98
|
+
return cb(new Error('Not a replica for this organization'));
|
|
99
|
+
}
|
|
100
|
+
// A feed that named other organizations would be granting permissions the Pi does not host
|
|
101
|
+
const foreign = payload.permissions.find((p) => p.org !== org);
|
|
102
|
+
if (foreign) return cb(new Error(`Replica for ${org} carried a permission for ${foreign.org}`));
|
|
103
|
+
|
|
104
|
+
const skipped = [];
|
|
105
|
+
db.serialize(() => {
|
|
106
|
+
db.run('BEGIN IMMEDIATE');
|
|
107
|
+
// Everything replicated for THIS organization goes; other organizations, and local rows, stay.
|
|
108
|
+
db.run('DELETE FROM permissions WHERE org = ? AND id >= ?', [org, REPLICA_ID_OFFSET]);
|
|
109
|
+
|
|
110
|
+
let pending = payload.users.length;
|
|
111
|
+
const afterUsers = () => {
|
|
112
|
+
let left = payload.permissions.length;
|
|
113
|
+
const done = (err) => {
|
|
114
|
+
if (err) { return db.run('ROLLBACK', () => cb(err)); }
|
|
115
|
+
// A replicated user with no permissions left anywhere is nobody on this machine
|
|
116
|
+
db.run(`DELETE FROM users WHERE id >= ? AND id NOT IN (SELECT id FROM permissions)`,
|
|
117
|
+
[REPLICA_ID_OFFSET], (derr) => {
|
|
118
|
+
if (derr) { return db.run('ROLLBACK', () => cb(derr)); }
|
|
119
|
+
db.run('COMMIT', (cerr) => cb(cerr, {
|
|
120
|
+
users: payload.users.length - skipped.length,
|
|
121
|
+
permissions: payload.permissions.length,
|
|
122
|
+
skipped,
|
|
123
|
+
}));
|
|
124
|
+
});
|
|
125
|
+
};
|
|
126
|
+
if (!left) return done(null);
|
|
127
|
+
for (const p of payload.permissions) {
|
|
128
|
+
// id 0 is "everyone" and is local on every machine - never replicated over the top of it
|
|
129
|
+
const id = Number(p.id) === 0 ? 0 : REPLICA_ID_OFFSET + Number(p.id);
|
|
130
|
+
db.run(`INSERT OR IGNORE INTO permissions (id, capability, org, project)
|
|
131
|
+
VALUES (?, ?, ?, ?)`,
|
|
132
|
+
[id, p.capability, p.org, p.project || ''], (err) => {
|
|
133
|
+
if (err && left > 0) { left = -1; return done(err); }
|
|
134
|
+
if (--left === 0) done(null);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
if (!pending) return afterUsers();
|
|
139
|
+
for (const u of payload.users) {
|
|
140
|
+
const id = REPLICA_ID_OFFSET + Number(u.id);
|
|
141
|
+
if (!Number.isFinite(Number(u.id)) || Number(u.id) <= 0) {
|
|
142
|
+
skipped.push(u.username); if (--pending === 0) afterUsers(); continue;
|
|
143
|
+
}
|
|
144
|
+
const hash = u.hashed_password ? Buffer.from(u.hashed_password, 'base64') : null;
|
|
145
|
+
const salt = u.salt ? Buffer.from(u.salt, 'base64') : null;
|
|
146
|
+
// Take the row only if this name is free or already replicated. A local account of the same
|
|
147
|
+
// name is left alone: replacing it would change who can log in as that person.
|
|
148
|
+
db.get('SELECT id FROM users WHERE username = ?', [u.username], (gerr, existing) => {
|
|
149
|
+
if (gerr) { skipped.push(u.username); if (--pending === 0) afterUsers(); return; }
|
|
150
|
+
if (existing && !isReplicatedId(existing.id)) {
|
|
151
|
+
skipped.push(u.username);
|
|
152
|
+
if (--pending === 0) afterUsers();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
db.run(`INSERT INTO users (id, username, hashed_password, salt, organization, name, email)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
157
|
+
ON CONFLICT(id) DO UPDATE SET username = excluded.username,
|
|
158
|
+
hashed_password = excluded.hashed_password, salt = excluded.salt,
|
|
159
|
+
name = excluded.name, email = excluded.email`,
|
|
160
|
+
[id, u.username, hash, salt, org, u.name || u.username, u.email || null], () => {
|
|
161
|
+
if (--pending === 0) afterUsers();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/*
|
|
169
|
+
* Fetch one organization's replica from production and apply it.
|
|
170
|
+
*
|
|
171
|
+
* Configured in config.d/replica.yaml on the Pi:
|
|
172
|
+
*
|
|
173
|
+
* url: https://frugaliot.example.org the production server
|
|
174
|
+
* organizations: [myfarm] the ones this Pi hosts
|
|
175
|
+
* intervalSeconds: 900 how often to re-pull
|
|
176
|
+
*
|
|
177
|
+
* with the token in config.d/secrets.yaml as replica_token - a secret, and so not in config.d
|
|
178
|
+
* anywhere a browser could reach it (lib/config-for-user.js withholds that whole section).
|
|
179
|
+
*
|
|
180
|
+
* Never fatal. A Pi whose link is down keeps the replica it already has, and people go on logging
|
|
181
|
+
* in against it: working without the internet is the reason the Pi exists, so a failed pull is an
|
|
182
|
+
* expected state rather than an error.
|
|
183
|
+
*/
|
|
184
|
+
export function pullReplica(config, db, { onUser } = {}, cb) {
|
|
185
|
+
cb = cb || (() => {});
|
|
186
|
+
const settings = (config && config.replica) || {};
|
|
187
|
+
const token = config && config.secrets && config.secrets.replica_token;
|
|
188
|
+
const orgs = settings.organizations || [];
|
|
189
|
+
if (!settings.url || !token || !orgs.length) {
|
|
190
|
+
return cb(null, { skipped: 'not configured' });
|
|
191
|
+
}
|
|
192
|
+
const base = String(settings.url).replace(/\/+$/, '');
|
|
193
|
+
let i = 0;
|
|
194
|
+
const results = [];
|
|
195
|
+
const next = () => {
|
|
196
|
+
if (i >= orgs.length) return cb(null, { orgs: results });
|
|
197
|
+
const org = orgs[i++];
|
|
198
|
+
fetch(`${base}/replica/${encodeURIComponent(org)}`, {
|
|
199
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
200
|
+
signal: AbortSignal.timeout(30000),
|
|
201
|
+
})
|
|
202
|
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`${r.status} ${r.statusText}`))))
|
|
203
|
+
.then((payload) => new Promise((res, rej) =>
|
|
204
|
+
applyReplica(db, org, payload, (err, result) => (err ? rej(err) : res(result)))))
|
|
205
|
+
.then((result) => {
|
|
206
|
+
results.push({ org, ...result });
|
|
207
|
+
console.log(`Replica ${org}: ${result.users} user(s), ${result.permissions} permission(s)` +
|
|
208
|
+
(result.skipped.length ? `, skipped ${result.skipped.join(', ')} (a local account has that name)` : ''));
|
|
209
|
+
// Each replicated user's broker account, so they can connect here as well as on production
|
|
210
|
+
if (onUser) {
|
|
211
|
+
db.all('SELECT DISTINCT id FROM permissions WHERE org = ? AND id >= ?',
|
|
212
|
+
[org, REPLICA_ID_OFFSET], (err, ids) => {
|
|
213
|
+
for (const r of (ids || [])) onUser(r.id);
|
|
214
|
+
next();
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
next();
|
|
218
|
+
}
|
|
219
|
+
})
|
|
220
|
+
.catch((err) => {
|
|
221
|
+
// Expected whenever the link is down; the existing replica stays in place
|
|
222
|
+
console.log(`Replica ${org} not refreshed: ${err.message}`);
|
|
223
|
+
results.push({ org, error: err.message });
|
|
224
|
+
next();
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
next();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/*
|
|
231
|
+
* Pull now, and then on a timer.
|
|
232
|
+
*
|
|
233
|
+
* The timer lives in the server rather than in cron: it is part of being a bridge, so it should
|
|
234
|
+
* start and stop with the server rather than being a separate thing to install and remember.
|
|
235
|
+
*/
|
|
236
|
+
export function startReplica(config, db, deps) {
|
|
237
|
+
const settings = (config && config.replica) || {};
|
|
238
|
+
if (!settings.url) return null;
|
|
239
|
+
const seconds = Number(settings.intervalSeconds) || 900;
|
|
240
|
+
console.log(`Replicating users from ${settings.url} every ${seconds}s:`,
|
|
241
|
+
(settings.organizations || []).join(', ') || '(no organizations listed)');
|
|
242
|
+
pullReplica(config, db, deps);
|
|
243
|
+
const timer = setInterval(() => pullReplica(config, db, deps), seconds * 1000);
|
|
244
|
+
timer.unref(); // a pending timer should not hold the process open at shutdown
|
|
245
|
+
return timer;
|
|
246
|
+
}
|
package/lib/resetcode.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Password reset codes, held nowhere.
|
|
3
|
+
*
|
|
4
|
+
* A code is an HMAC over (user id, email, current password hash, five-minute slot), keyed by a
|
|
5
|
+
* secret this process made at startup. Nothing is written to the database and nothing expires by
|
|
6
|
+
* being deleted - a code stops verifying because the slot has moved on, the password has changed,
|
|
7
|
+
* or the server has restarted. Two slots are accepted, so a code lasts between 5 and 10 minutes.
|
|
8
|
+
*
|
|
9
|
+
* Including the current hashed_password is what makes a code single-use: resetting the password
|
|
10
|
+
* changes the hash, and every code derived from the old one stops matching immediately.
|
|
11
|
+
*
|
|
12
|
+
* Two forms come out of the same digest:
|
|
13
|
+
* code - six digits, short enough to read off a phone and type in
|
|
14
|
+
* token - 32 hex characters, put in the emailed link, where length costs nothing
|
|
15
|
+
* Six digits is only a million guesses, so the six-digit form is worth having only alongside the
|
|
16
|
+
* rate limiting below; the link form does not depend on it.
|
|
17
|
+
*/
|
|
18
|
+
import crypto from 'crypto';
|
|
19
|
+
|
|
20
|
+
const SLOT_MS = 5 * 60 * 1000; // a code is valid for its own slot and the one before it
|
|
21
|
+
const SLOTS_ACCEPTED = 2;
|
|
22
|
+
// Regenerated every start: a restart invalidates outstanding codes, which is the right way round.
|
|
23
|
+
const secret = crypto.randomBytes(32);
|
|
24
|
+
|
|
25
|
+
// Rate limits, per identifier, in memory. Lost on restart, which also invalidates every code, so
|
|
26
|
+
// there is nothing to carry over. Not a defence against a distributed attacker - it is here so that
|
|
27
|
+
// a million guesses at a six-digit code cannot be made in the ten minutes one is alive.
|
|
28
|
+
const SEND_LIMIT = 3; // reset emails per identifier
|
|
29
|
+
const CHECK_LIMIT = 10; // verification attempts per identifier
|
|
30
|
+
const LIMIT_WINDOW_MS = 15 * 60 * 1000;
|
|
31
|
+
const buckets = { send: new Map(), check: new Map() };
|
|
32
|
+
|
|
33
|
+
function slotNow() { return Math.floor(Date.now() / SLOT_MS); }
|
|
34
|
+
|
|
35
|
+
function digestFor(user, slot) {
|
|
36
|
+
// The password hash is a BLOB from sqlite; a user with no password yet has none.
|
|
37
|
+
const hashed = user.hashed_password ? Buffer.from(user.hashed_password).toString('hex') : '';
|
|
38
|
+
return crypto.createHmac('sha256', secret)
|
|
39
|
+
.update([user.id, user.email || '', hashed, slot].join('\n'))
|
|
40
|
+
.digest();
|
|
41
|
+
}
|
|
42
|
+
function formsFor(user, slot) {
|
|
43
|
+
const d = digestFor(user, slot);
|
|
44
|
+
return {
|
|
45
|
+
code: String(d.readUInt32BE(0) % 1000000).padStart(6, '0'),
|
|
46
|
+
token: d.toString('hex').slice(0, 32),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Constant-time, and safe on differing lengths - timingSafeEqual throws on those rather than
|
|
50
|
+
// returning false.
|
|
51
|
+
function sameSecret(a, b) {
|
|
52
|
+
const ba = Buffer.from(String(a));
|
|
53
|
+
const bb = Buffer.from(String(b));
|
|
54
|
+
if (ba.length !== bb.length) { return false; }
|
|
55
|
+
return crypto.timingSafeEqual(ba, bb);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// { code, token } for the current slot - what goes in the email
|
|
59
|
+
function resetCodeMake(user) {
|
|
60
|
+
return formsFor(user, slotNow());
|
|
61
|
+
}
|
|
62
|
+
// True if "supplied" is either form, for this slot or the one before
|
|
63
|
+
function resetCodeCheck(user, supplied) {
|
|
64
|
+
if (!supplied) { return false; }
|
|
65
|
+
const now = slotNow();
|
|
66
|
+
let ok = false;
|
|
67
|
+
for (let i = 0; i < SLOTS_ACCEPTED; i++) {
|
|
68
|
+
const { code, token } = formsFor(user, now - i);
|
|
69
|
+
// No early return: keep the work the same whichever slot matches
|
|
70
|
+
ok = sameSecret(supplied, code) || sameSecret(supplied, token) || ok;
|
|
71
|
+
}
|
|
72
|
+
return ok;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// True if this identifier may make another attempt of this kind, and counts it if so.
|
|
76
|
+
// bucket is 'send' (asking for an email) or 'check' (trying a code).
|
|
77
|
+
function resetRateOk(bucket, key) {
|
|
78
|
+
const limit = (bucket === 'send') ? SEND_LIMIT : CHECK_LIMIT;
|
|
79
|
+
const map = buckets[bucket];
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
const hits = (map.get(key) || []).filter((t) => (now - t) < LIMIT_WINDOW_MS);
|
|
82
|
+
if (hits.length >= limit) {
|
|
83
|
+
map.set(key, hits);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
hits.push(now);
|
|
87
|
+
map.set(key, hits);
|
|
88
|
+
// Nothing else prunes this, and an attacker can name any identifier they like, so drop entries
|
|
89
|
+
// whose window has passed whenever the map gets big rather than growing without bound.
|
|
90
|
+
if (map.size > 1000) {
|
|
91
|
+
for (const [k, v] of map) {
|
|
92
|
+
if (!v.some((t) => (now - t) < LIMIT_WINDOW_MS)) { map.delete(k); }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// So a test can start from a clean slate without waiting out the window
|
|
98
|
+
function resetRateClear() {
|
|
99
|
+
buckets.send.clear();
|
|
100
|
+
buckets.check.clear();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { resetCodeMake, resetCodeCheck, resetRateOk, resetRateClear, SLOT_MS, SLOTS_ACCEPTED };
|
package/lib/retained.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Deleting retained messages, on the server's behalf rather than the browser's.
|
|
3
|
+
*
|
|
4
|
+
* A retained topic is removed by publishing an empty payload to it, and there is no other way: the
|
|
5
|
+
* broker keeps the last value of every topic for ever, so a misspelled field or a node tested with
|
|
6
|
+
* the wrong id goes on appearing on every dashboard after the node is fixed (see
|
|
7
|
+
* scripts/clearretained.js, which does the same thing from the command line).
|
|
8
|
+
*
|
|
9
|
+
* This used to be done by the browser, using the organization's shared broker credential. S4 gave
|
|
10
|
+
* each user their own credential instead, which may publish to "set/" topics and nothing else - so
|
|
11
|
+
* the browser's delete stopped working, and worse, stopped working SILENTLY: an MQTT 3.1.1 broker
|
|
12
|
+
* acknowledges a QoS 1 publish it is about to discard, so the client saw success and the topics
|
|
13
|
+
* stayed. That is the regression this file exists to fix.
|
|
14
|
+
*
|
|
15
|
+
* The obvious repair - let a browser publish anywhere in its organization - would hand back exactly
|
|
16
|
+
* the capability S4 removed, because the broker cannot tell "forget this reading" from "here is a
|
|
17
|
+
* reading I invented". So the publishing is done here, by the server, with an account only the
|
|
18
|
+
* server holds (names.orgAdminClient), after checking that every topic named is inside the
|
|
19
|
+
* organization the user has ADMIN of.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import mqtt from 'mqtt';
|
|
23
|
+
import { deriveOrgAdminPassword, names } from './dynsec-plan.js';
|
|
24
|
+
|
|
25
|
+
const NAME = /^[a-z0-9]{1,32}$/;
|
|
26
|
+
// One topic per node module field, and a busy organization has a few thousand. Well above anything
|
|
27
|
+
// legitimate, and low enough that a mistake cannot tie the broker up for minutes.
|
|
28
|
+
const MAX_TOPICS = 5000;
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
* Which credential to publish as.
|
|
32
|
+
*
|
|
33
|
+
* The derived per-organization admin account when there is a user_secret and the broker has the
|
|
34
|
+
* plugin; otherwise the organization's own shared account, which still exists until S8 retires it.
|
|
35
|
+
* The fallback is what makes this work on a server whose broker has no dynamic security - the same
|
|
36
|
+
* arrangement the logger uses.
|
|
37
|
+
*/
|
|
38
|
+
export function adminCredentialFor(config, org) {
|
|
39
|
+
const userSecret = config && config.secrets && config.secrets.user_secret;
|
|
40
|
+
if (userSecret) {
|
|
41
|
+
return { username: names.orgAdminClient(org), password: deriveOrgAdminPassword(userSecret, org) };
|
|
42
|
+
}
|
|
43
|
+
const o = (config && config.organizations && config.organizations[org]) || {};
|
|
44
|
+
if (o.mqtt_password) return { username: org, password: o.mqtt_password };
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/*
|
|
49
|
+
* Publish an empty retained message to each of `topics`.
|
|
50
|
+
*
|
|
51
|
+
* cb(err, {deleted}) - err.status is an HTTP status for the route to use, because every way this
|
|
52
|
+
* can be refused is a different one.
|
|
53
|
+
*/
|
|
54
|
+
export function deleteRetained(config, org, topics, cb) {
|
|
55
|
+
if (!NAME.test(String(org || ''))) return refuse(cb, 400, 'Not an organization name');
|
|
56
|
+
if (!Array.isArray(topics) || !topics.length) return refuse(cb, 400, 'No topics given');
|
|
57
|
+
if (topics.length > MAX_TOPICS) return refuse(cb, 400, `More than ${MAX_TOPICS} topics at once`);
|
|
58
|
+
|
|
59
|
+
const prefix = `${org}/`;
|
|
60
|
+
for (const t of topics) {
|
|
61
|
+
if (typeof t !== 'string' || !t.length) return refuse(cb, 400, 'A topic was not a string');
|
|
62
|
+
// Inside this organization, and one exact topic - a wildcard would let "delete these" mean
|
|
63
|
+
// "delete everything", and unlike the command-line tool there is nobody here to look first.
|
|
64
|
+
if (!t.startsWith(prefix)) return refuse(cb, 403, `${t} is not in ${org}`);
|
|
65
|
+
if (t.includes('+') || t.includes('#')) return refuse(cb, 400, `${t} contains a wildcard`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const broker = config && config.mqtt && config.mqtt.broker;
|
|
69
|
+
if (!broker) return refuse(cb, 500, 'No mqtt.broker in the configuration');
|
|
70
|
+
const cred = adminCredentialFor(config, org);
|
|
71
|
+
if (!cred) return refuse(cb, 500, `No broker credential for ${org}`);
|
|
72
|
+
|
|
73
|
+
const client = mqtt.connect(broker, {
|
|
74
|
+
username: cred.username, password: cred.password,
|
|
75
|
+
connectTimeout: 5000,
|
|
76
|
+
reconnectPeriod: 0, // one-shot: a retry would silently double every publish
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let settled = false;
|
|
80
|
+
const finish = (err, res) => {
|
|
81
|
+
if (settled) return;
|
|
82
|
+
settled = true;
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
client.end(!!err, () => cb(err, res));
|
|
85
|
+
};
|
|
86
|
+
// Long enough for a few thousand publishes on a Pi, short enough that a wedged broker answers.
|
|
87
|
+
const timer = setTimeout(() => finish(err500('The broker did not answer')), 30000);
|
|
88
|
+
|
|
89
|
+
client.on('error', (err) => finish(refusal(502, `Could not reach the broker: ${err.message}`)));
|
|
90
|
+
client.on('connect', () => {
|
|
91
|
+
let left = topics.length;
|
|
92
|
+
let failed = 0;
|
|
93
|
+
for (const t of topics) {
|
|
94
|
+
// An empty payload is how MQTT spells "forget this topic". QoS 1 so the broker acknowledges
|
|
95
|
+
// it - though see the file header: the acknowledgement says "received", not "allowed".
|
|
96
|
+
client.publish(t, '', { retain: true, qos: 1 }, (err) => {
|
|
97
|
+
if (err) failed++;
|
|
98
|
+
if (--left === 0) finish(null, { deleted: topics.length - failed, failed });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function refusal(status, message) {
|
|
105
|
+
const e = new Error(message);
|
|
106
|
+
e.status = status;
|
|
107
|
+
return e;
|
|
108
|
+
}
|
|
109
|
+
function err500(message) { return refusal(500, message); }
|
|
110
|
+
function refuse(cb, status, message) { return cb(refusal(status, message)); }
|
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Secrets this server generates for itself, in config.d/secrets.yaml.
|
|
3
|
+
*
|
|
4
|
+
* frugal-iot-init writes that file, but a server upgraded from a release that predates it has none,
|
|
5
|
+
* and telling the operator to go and run init is a poor answer: the symptom (everybody logged out
|
|
6
|
+
* on every restart) does not point at the cause, so it would be lived with rather than fixed. So
|
|
7
|
+
* generate what is missing and write it down, which fixes itself once and stays fixed.
|
|
8
|
+
*
|
|
9
|
+
* Synchronous on purpose. This runs once, before the server listens, and doing it with callbacks
|
|
10
|
+
* would mean restructuring the startup sequence around a single small file write.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from 'crypto';
|
|
14
|
+
import { appendFileSync, chmodSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
15
|
+
|
|
16
|
+
const HEADER = `# Secrets belonging to this server. Never served to a browser - lib/config-for-user.js
|
|
17
|
+
# withholds this whole section. Not in git, and worth backing up alongside frugal-iot.db.
|
|
18
|
+
`;
|
|
19
|
+
|
|
20
|
+
// What each secret is for, written into the file beside it so whoever reads the file later knows
|
|
21
|
+
// what rotating it would cost.
|
|
22
|
+
const NOTES = {
|
|
23
|
+
session_secret: 'Signs the session cookie. Changing it ends every session.',
|
|
24
|
+
user_secret: "Mixed into each user's derived MQTT password. Changing it invalidates every browser's\n# broker credential until its next login, and nothing else.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// An organization's enrolment secret is written as a LIST, because rotating it means adding a new
|
|
28
|
+
// one while the old is still accepted - otherwise every node already flashed with the old value,
|
|
29
|
+
// and not yet enrolled, is stranded. Delete a line to withdraw that secret.
|
|
30
|
+
const LIST_NOTE = (name) => {
|
|
31
|
+
const org = name.replace(/^enrolment_/, '');
|
|
32
|
+
return `Enrolment secrets for organization "${org}" - any of them is accepted by POST /enrol.
|
|
33
|
+
# A node presents one once, to be issued its own broker credential; it grants nothing else, no read
|
|
34
|
+
# and no write. To rotate: add a new line above and leave the old one until every node flashed with
|
|
35
|
+
# it has enrolled, then delete it. To withdraw one immediately, delete its line.`;
|
|
36
|
+
};
|
|
37
|
+
const isList = (name) => name.startsWith('enrolment_');
|
|
38
|
+
|
|
39
|
+
function generate() {
|
|
40
|
+
return randomBytes(32).toString('base64url');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
* Make sure every name in `names` has a value, generating and persisting any that do not.
|
|
45
|
+
*
|
|
46
|
+
* Returns { secrets, generated, written, error }:
|
|
47
|
+
* secrets - name -> value, always complete, so the caller can use it unconditionally
|
|
48
|
+
* generated - names that had to be made up on this run
|
|
49
|
+
* written - true if they reached the file, false if they exist only for this process
|
|
50
|
+
* error - why the write failed, when it did
|
|
51
|
+
*
|
|
52
|
+
* A failed write is not fatal: the server works, and its sessions simply end when it restarts.
|
|
53
|
+
* That can happen legitimately - a read-only install directory, or running as a user who does not
|
|
54
|
+
* own it - so it is reported rather than thrown.
|
|
55
|
+
*/
|
|
56
|
+
export function ensureSecrets(existing, configDir, names) {
|
|
57
|
+
const secrets = { ...(existing || {}) };
|
|
58
|
+
const generated = names.filter((n) => !secrets[n]);
|
|
59
|
+
if (!generated.length) return { secrets, generated, written: true };
|
|
60
|
+
|
|
61
|
+
for (const n of generated) secrets[n] = isList(n) ? [generate()] : generate();
|
|
62
|
+
|
|
63
|
+
const path = `${configDir}/secrets.yaml`;
|
|
64
|
+
const body = generated.map((n) => (isList(n)
|
|
65
|
+
? `\n# ${LIST_NOTE(n)}\n${n}:\n - "${secrets[n][0]}"\n`
|
|
66
|
+
: `\n# ${NOTES[n] || n}\n${n}: "${secrets[n]}"\n`)).join('');
|
|
67
|
+
try {
|
|
68
|
+
if (existsSync(path)) {
|
|
69
|
+
appendFileSync(path, body);
|
|
70
|
+
} else {
|
|
71
|
+
writeFileSync(path, HEADER + body);
|
|
72
|
+
}
|
|
73
|
+
chmodSync(path, 0o600);
|
|
74
|
+
return { secrets, generated, written: true };
|
|
75
|
+
} catch (e) {
|
|
76
|
+
return { secrets, generated, written: false, error: e.message };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/*
|
|
81
|
+
* Adding and withdrawing an organization's enrolment secrets, for the dashboard (SECURITY.md S10).
|
|
82
|
+
*
|
|
83
|
+
* Rewrites just that organization's block, leaving the rest of the file - and its comments, which
|
|
84
|
+
* say what each secret costs to rotate - untouched. Loading and re-emitting the whole file as YAML
|
|
85
|
+
* would be less code and would throw those comments away.
|
|
86
|
+
*
|
|
87
|
+
* Synchronous, like ensureSecrets: one small file, changed by hand at human speed.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
const ENROL_NAME = (org) => `enrolment_${org}`;
|
|
91
|
+
|
|
92
|
+
// The organization's block, whether or not it is there: "enrolment_x:" and the indented list items
|
|
93
|
+
// under it. Anything else - a comment, another secret - ends the match.
|
|
94
|
+
const blockFor = (org) =>
|
|
95
|
+
new RegExp(`^${ENROL_NAME(org)}:[ \\t]*\\n(?:[ \\t]+-[^\\n]*\\n)*`, 'm');
|
|
96
|
+
|
|
97
|
+
function renderBlock(org, list) {
|
|
98
|
+
const items = list.map((v) => ` - "${v}"`).join('\n');
|
|
99
|
+
return `${ENROL_NAME(org)}:\n${items}${items ? '\n' : ''}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/*
|
|
103
|
+
* Write `list` as the organization's enrolment secrets.
|
|
104
|
+
*
|
|
105
|
+
* cb(err) - callers report and carry on. An unwritable file means the change lasts only until the
|
|
106
|
+
* next restart, which is worth saying rather than pretending it was saved.
|
|
107
|
+
*/
|
|
108
|
+
function writeEnrolmentSecrets(configDir, org, list, cb) {
|
|
109
|
+
const path = `${configDir}/secrets.yaml`;
|
|
110
|
+
try {
|
|
111
|
+
const block = renderBlock(org, list);
|
|
112
|
+
if (!existsSync(path)) {
|
|
113
|
+
writeFileSync(path, HEADER + `\n# ${LIST_NOTE(ENROL_NAME(org))}\n${block}`);
|
|
114
|
+
} else {
|
|
115
|
+
const text = readFileSync(path, 'utf8');
|
|
116
|
+
const re = blockFor(org);
|
|
117
|
+
if (re.test(text)) {
|
|
118
|
+
writeFileSync(path, text.replace(re, block));
|
|
119
|
+
} else {
|
|
120
|
+
// No block of its own yet - an organization added since the file was written
|
|
121
|
+
appendFileSync(path, `\n# ${LIST_NOTE(ENROL_NAME(org))}\n${block}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
chmodSync(path, 0o600);
|
|
125
|
+
cb(null);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
cb(e);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/*
|
|
132
|
+
* Add a secret, keeping the existing ones.
|
|
133
|
+
*
|
|
134
|
+
* That is the whole point of a list: a node already flashed with the old value, and not yet
|
|
135
|
+
* enrolled, would be stranded if adding replaced it. cb(err, {secret, list}).
|
|
136
|
+
*/
|
|
137
|
+
export function addEnrolmentSecret(configDir, org, current, cb) {
|
|
138
|
+
const list = [generate(), ...(current || [])];
|
|
139
|
+
writeEnrolmentSecrets(configDir, org, list, (err) => cb(err, { secret: list[0], list }));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Withdraw one secret, by value. cb(err, {list, removed}).
|
|
143
|
+
export function removeEnrolmentSecret(configDir, org, secret, current, cb) {
|
|
144
|
+
const list = (current || []).filter((v) => v !== secret);
|
|
145
|
+
const removed = list.length !== (current || []).length;
|
|
146
|
+
if (!removed) return cb(null, { list, removed });
|
|
147
|
+
writeEnrolmentSecrets(configDir, org, list, (err) => cb(err, { list, removed }));
|
|
148
|
+
}
|