frugal-iot-server 0.3.9 → 2.0.1
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 +77 -18
- package/config.d/schema/modules.yaml +15 -0
- package/config.d/schema/topics.yaml +23 -0
- package/extras/aclfile +38 -0
- package/extras/mosquitto.conf +29 -0
- package/frugal-iot-createdb.sql +82 -1
- package/frugal-iot-server.js +552 -72
- 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 +237 -0
- package/lib/dynsec-sync.js +209 -0
- package/lib/dynsec.js +166 -0
- package/lib/enrol.js +288 -0
- package/lib/replica.js +246 -0
- package/lib/retained.js +110 -0
- package/lib/secrets.js +148 -0
- package/package.json +8 -4
- package/public/index.html +0 -8
- package/public/service-worker.js +51 -1
- package/scripts/addbridge-pi.zsh +56 -2
- package/scripts/addbridge-prod.zsh +48 -0
- package/scripts/addorganization.zsh +5 -0
- package/scripts/check-schema.js +24 -4
- package/scripts/copy-schema-to-examples.zsh +18 -3
- package/scripts/diagnostic.zsh +121 -2
- package/scripts/dynsec-init.js +142 -0
- package/scripts/init.zsh +63 -0
- package/scripts/install-pi.sh +83 -4
- package/scripts/migrate-permissions-project.sql +21 -0
- package/scripts/not-published.txt +5 -0
- package/scripts/prerelease.zsh +84 -33
- package/scripts/rebuild-dynsec.js +190 -0
- package/scripts/resetnode.js +69 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Making the broker match the database, and reporting where it does not.
|
|
3
|
+
*
|
|
4
|
+
* Both directions come from lib/dynsec-plan.js, so "apply" and "check" cannot disagree about what
|
|
5
|
+
* the answer should be - which is what makes "the database is the source of truth and dynsec can be
|
|
6
|
+
* rebuilt from it" a fact rather than a claim.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { desiredRolesAndGroups, groupsForUser, groupsForNode, groupsForLogger,
|
|
10
|
+
derivePassword, deriveLoggerPassword, deriveOrgAdminPassword, names } from './dynsec-plan.js';
|
|
11
|
+
|
|
12
|
+
// ---- reading the database -------------------------------------------------------------------
|
|
13
|
+
// Every (org, project) any permission row mentions, plus every organization in the config, so an
|
|
14
|
+
// organization with no permissions yet still gets its groups.
|
|
15
|
+
export function readScopes(db, configOrgs, cb) {
|
|
16
|
+
db.all('SELECT DISTINCT org, project FROM permissions', [], (err, rows) => {
|
|
17
|
+
if (err) return cb(err);
|
|
18
|
+
const seen = new Set();
|
|
19
|
+
const scopes = [];
|
|
20
|
+
const add = (org, project) => {
|
|
21
|
+
const k = `${org} ${project || ''}`;
|
|
22
|
+
if (!seen.has(k)) { seen.add(k); scopes.push({ org, project: project || '' }); }
|
|
23
|
+
};
|
|
24
|
+
for (const org of configOrgs || []) add(org, '');
|
|
25
|
+
for (const r of rows) add(r.org, r.project);
|
|
26
|
+
cb(null, scopes);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The id-0 "everyone" rows: what anybody, logged in or not, may read.
|
|
31
|
+
export function readPublics(db, cb) {
|
|
32
|
+
db.all("SELECT org, project FROM permissions WHERE id = 0 AND capability = 'READ'", [], (err, rows) =>
|
|
33
|
+
err ? cb(err) : cb(null, rows.map((r) => ({ org: r.org, project: r.project || '' }))));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The organization's registered projects. Used for two things that must agree: which project topics
|
|
37
|
+
// a node may announce on, and which projects enrolment will accept.
|
|
38
|
+
export function readProjects(db, cb) {
|
|
39
|
+
db.all('SELECT org, id AS project FROM projects', [], (err, rows) =>
|
|
40
|
+
err ? cb(err) : cb(null, rows.map((r) => ({ org: r.org, project: r.project }))));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A user's effective rows - the same "or id = 0" the session itself uses, so the broker and the web
|
|
44
|
+
// side can never disagree about what somebody has.
|
|
45
|
+
export function readUserRows(db, id, cb) {
|
|
46
|
+
db.all('SELECT capability, org, project FROM permissions WHERE id = ? or id = 0', [id], cb);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---- applying -------------------------------------------------------------------------------
|
|
50
|
+
/*
|
|
51
|
+
* Create every role and group the plan calls for, and set their ACLs. Idempotent: re-running is how
|
|
52
|
+
* the rebuild tool works, and how a release that adds a rule delivers it.
|
|
53
|
+
*/
|
|
54
|
+
export function applyRolesAndGroups(dynsec, scopes, publics, projects, cb) {
|
|
55
|
+
const { roles, groups } = desiredRolesAndGroups(scopes, publics, projects);
|
|
56
|
+
const create = [];
|
|
57
|
+
const acls = [];
|
|
58
|
+
for (const [rolename, list] of Object.entries(roles)) {
|
|
59
|
+
create.push({ command: 'createRole', rolename });
|
|
60
|
+
for (const a of list) {
|
|
61
|
+
acls.push({ command: 'addRoleACL', rolename, acltype: a.acltype, topic: a.topic,
|
|
62
|
+
priority: 0, allow: !!a.allow });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const groupname of Object.keys(groups)) create.push({ command: 'createGroup', groupname });
|
|
66
|
+
|
|
67
|
+
const links = [];
|
|
68
|
+
for (const [groupname, g] of Object.entries(groups)) {
|
|
69
|
+
for (const rolename of g.roles) links.push({ command: 'addGroupRole', groupname, rolename });
|
|
70
|
+
}
|
|
71
|
+
// Unauthenticated connections land in the public group, so an embedded page needs no credential.
|
|
72
|
+
links.push({ command: 'setAnonymousGroup', groupname: names.publicGroup });
|
|
73
|
+
|
|
74
|
+
dynsec.idempotent(create, (err) => {
|
|
75
|
+
if (err) return cb(err);
|
|
76
|
+
dynsec.idempotent(acls, (err2) => {
|
|
77
|
+
if (err2) return cb(err2);
|
|
78
|
+
dynsec.idempotent(links, cb);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* One user: make sure the client exists, its password is the derived one, and its group membership
|
|
85
|
+
* matches its permissions - adding and REMOVING, because a revoked permission has to take effect.
|
|
86
|
+
*/
|
|
87
|
+
export function applyUser(dynsec, { username, hashedPassword, rows, userSecret }, cb) {
|
|
88
|
+
const want = groupsForUser(rows);
|
|
89
|
+
const password = derivePassword(userSecret, username, hashedPassword);
|
|
90
|
+
// The broker account is "user/<login>", never the bare login - see names.userClient for why.
|
|
91
|
+
const client_id = names.userClient(username);
|
|
92
|
+
dynsec.idempotent([{ command: 'createClient', username: client_id, password }], (err) => {
|
|
93
|
+
if (err) return cb(err);
|
|
94
|
+
dynsec.getClient(client_id, (err2, client) => {
|
|
95
|
+
if (err2) return cb(err2);
|
|
96
|
+
const haveGroups = ((client && client.groups) || []).map((g) => g.groupname || g);
|
|
97
|
+
const haveRoles = ((client && client.roles) || []).map((r) => r.rolename || r);
|
|
98
|
+
const cmds = [{ command: 'setClientPassword', username: client_id, password }];
|
|
99
|
+
for (const g of want.groups) {
|
|
100
|
+
if (!haveGroups.includes(g)) cmds.push({ command: 'addGroupClient', groupname: g, username: client_id });
|
|
101
|
+
}
|
|
102
|
+
for (const g of haveGroups) {
|
|
103
|
+
if (!want.groups.includes(g) && g !== names.publicGroup) {
|
|
104
|
+
cmds.push({ command: 'removeGroupClient', groupname: g, username: client_id });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Only roles the client does not already have. Re-adding one answers "Internal error", which
|
|
108
|
+
// says nothing about what is wrong and is not distinguishable from a real failure - the same
|
|
109
|
+
// wording mosquitto_ctrl gets, so it is the plugin's, not ours. Reading the client first and
|
|
110
|
+
// sending only what is missing avoids having to interpret it at all.
|
|
111
|
+
for (const r of want.roles) {
|
|
112
|
+
if (!haveRoles.includes(r)) cmds.push({ command: 'addClientRole', username: client_id, rolename: r });
|
|
113
|
+
}
|
|
114
|
+
dynsec.idempotent(cmds, (err3) => cb(err3, { username: client_id, password, groups: want.groups }));
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// One node. Its password is random and stored in the database, not derived - a node keeps its copy
|
|
120
|
+
// in LittleFS and cannot recompute anything (SECURITY-REVIEW.md section 9).
|
|
121
|
+
export function applyNode(dynsec, { org, project, nodeid, password, lora }, cb) {
|
|
122
|
+
const username = names.nodeClient(org, project, nodeid);
|
|
123
|
+
const groups = groupsForNode(org, project, { lora });
|
|
124
|
+
dynsec.idempotent([{ command: 'createClient', username, password }], (err) => {
|
|
125
|
+
if (err) return cb(err);
|
|
126
|
+
const cmds = [{ command: 'setClientPassword', username, password }];
|
|
127
|
+
for (const g of groups) cmds.push({ command: 'addGroupClient', groupname: g, username });
|
|
128
|
+
dynsec.idempotent(cmds, (err2) => cb(err2, { username, groups }));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/*
|
|
133
|
+
* The logger's account for one organization. Its password is derived, so this is safe to re-run and
|
|
134
|
+
* the server can compute the same value to hand the logger at startup.
|
|
135
|
+
*/
|
|
136
|
+
export function applyLogger(dynsec, { org, userSecret }, cb) {
|
|
137
|
+
const username = names.loggerClient(org);
|
|
138
|
+
const password = deriveLoggerPassword(userSecret, org);
|
|
139
|
+
const groups = groupsForLogger(org);
|
|
140
|
+
dynsec.idempotent([{ command: 'createClient', username, password }], (err) => {
|
|
141
|
+
if (err) return cb(err);
|
|
142
|
+
dynsec.getClient(username, (err2, client) => {
|
|
143
|
+
if (err2) return cb(err2);
|
|
144
|
+
const have = ((client && client.groups) || []).map((g) => g.groupname || g);
|
|
145
|
+
const cmds = [{ command: 'setClientPassword', username, password }];
|
|
146
|
+
for (const g of groups) {
|
|
147
|
+
if (!have.includes(g)) cmds.push({ command: 'addGroupClient', groupname: g, username });
|
|
148
|
+
}
|
|
149
|
+
dynsec.idempotent(cmds, (err3) => cb(err3, { username, password, groups }));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/*
|
|
155
|
+
* The server's administrative account for one organization. Given the clear role DIRECTLY rather
|
|
156
|
+
* than through a group, so there is no group anything else could be added to by mistake.
|
|
157
|
+
*/
|
|
158
|
+
export function applyOrgAdmin(dynsec, { org, userSecret }, cb) {
|
|
159
|
+
const username = names.orgAdminClient(org);
|
|
160
|
+
const password = deriveOrgAdminPassword(userSecret, org);
|
|
161
|
+
const role = names.clearRole(org);
|
|
162
|
+
dynsec.idempotent([{ command: 'createClient', username, password }], (err) => {
|
|
163
|
+
if (err) return cb(err);
|
|
164
|
+
dynsec.getClient(username, (err2, client) => {
|
|
165
|
+
if (err2) return cb(err2);
|
|
166
|
+
const haveRoles = ((client && client.roles) || []).map((r) => r.rolename || r);
|
|
167
|
+
const cmds = [{ command: 'setClientPassword', username, password }];
|
|
168
|
+
if (!haveRoles.includes(role)) cmds.push({ command: 'addClientRole', username, rolename: role });
|
|
169
|
+
dynsec.idempotent(cmds, (err3) => cb(err3, { username, password }));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function removeUser(dynsec, username, cb) {
|
|
175
|
+
dynsec.raw([{ command: 'deleteClient', username: names.userClient(username) }], (err) => {
|
|
176
|
+
if (err && /not found/i.test(err.message)) return cb(null);
|
|
177
|
+
cb(err);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- checking -------------------------------------------------------------------------------
|
|
182
|
+
/*
|
|
183
|
+
* What differs between the database and the broker, changing nothing. This is what
|
|
184
|
+
* frugal-iot-rebuild-dynsec --check reports and what scripts/diagnostic.zsh calls: reporting drift
|
|
185
|
+
* is useful more often than repairing it.
|
|
186
|
+
*/
|
|
187
|
+
export function checkRolesAndGroups(dynsec, scopes, publics, projects, cb) {
|
|
188
|
+
const { roles, groups } = desiredRolesAndGroups(scopes, publics, projects);
|
|
189
|
+
const differences = [];
|
|
190
|
+
dynsec.listRoles((err, haveRoles) => {
|
|
191
|
+
if (err) return cb(err);
|
|
192
|
+
for (const r of Object.keys(roles)) {
|
|
193
|
+
if (!haveRoles.includes(r)) differences.push(`role missing: ${r}`);
|
|
194
|
+
}
|
|
195
|
+
dynsec.listGroups((err2, haveGroups) => {
|
|
196
|
+
if (err2) return cb(err2);
|
|
197
|
+
for (const g of Object.keys(groups)) {
|
|
198
|
+
if (!haveGroups.includes(g)) differences.push(`group missing: ${g}`);
|
|
199
|
+
}
|
|
200
|
+
dynsec.getAnonymousGroup((err3, anon) => {
|
|
201
|
+
// Not an error if the plugin will not say - only report a definite mismatch
|
|
202
|
+
if (!err3 && anon !== names.publicGroup) {
|
|
203
|
+
differences.push(`anonymous group is ${anon || '(unset)'}, expected ${names.publicGroup}`);
|
|
204
|
+
}
|
|
205
|
+
cb(null, differences);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
}
|
package/lib/dynsec.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Talking to mosquitto's dynamic security plugin, over MQTT.
|
|
3
|
+
*
|
|
4
|
+
* The plugin is driven by publishing JSON to $CONTROL/dynamic-security/v1 and reading the reply on
|
|
5
|
+
* the same topic - so the server needs no file access to the broker and no sudo, which is most of
|
|
6
|
+
* why dynsec is worth having (SECURITY-REVIEW.md section 2, Tier 3).
|
|
7
|
+
*
|
|
8
|
+
* Callbacks rather than promises, per claude.md.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import mqtt from 'mqtt';
|
|
12
|
+
|
|
13
|
+
// Commands go to one topic and replies come back on ANOTHER - the command topic with "/response"
|
|
14
|
+
// appended. Subscribing to the command topic (the obvious guess) gets no reply ever, while the
|
|
15
|
+
// commands themselves still execute, so it fails in the most confusing possible way: things happen
|
|
16
|
+
// and nothing answers. Confirmed from the broker's own log:
|
|
17
|
+
// Received PUBLISH from ... '$CONTROL/dynamic-security/v1'
|
|
18
|
+
// Sending PUBLISH to ... '$CONTROL/dynamic-security/v1/response'
|
|
19
|
+
const CONTROL = '$CONTROL/dynamic-security/v1';
|
|
20
|
+
const RESPONSE = '$CONTROL/dynamic-security/v1/response';
|
|
21
|
+
|
|
22
|
+
/*
|
|
23
|
+
* Connect as the dynsec admin. config is the whole configuration; the credential comes from
|
|
24
|
+
* config.secrets, written by frugal-iot-init and never served to a browser.
|
|
25
|
+
*
|
|
26
|
+
* cb(err, dynsec). A failure here must not be fatal to the caller: the dashboard is more useful
|
|
27
|
+
* without live broker accounts than not at all, so callers log and carry on.
|
|
28
|
+
*/
|
|
29
|
+
// Nothing here may hang. A command that gets no reply - a broker without the plugin, an account
|
|
30
|
+
// whose role does not permit $CONTROL, a dropped connection - would otherwise leave the caller
|
|
31
|
+
// waiting for ever, which is what the first version of scripts/rebuild-dynsec.js did.
|
|
32
|
+
const REPLY_TIMEOUT_MS = 10000;
|
|
33
|
+
|
|
34
|
+
export function dynsecConnect(config, cb) {
|
|
35
|
+
const secrets = (config && config.secrets) || {};
|
|
36
|
+
const broker = config && config.mqtt && config.mqtt.broker;
|
|
37
|
+
if (!broker) return cb(new Error('No mqtt.broker in the configuration'));
|
|
38
|
+
if (!secrets.dynsec_admin_user || !secrets.dynsec_admin_password) {
|
|
39
|
+
return cb(new Error('No dynsec_admin_user/password in config.d/secrets.yaml - run frugal-iot-init'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const client = mqtt.connect(broker, {
|
|
43
|
+
username: secrets.dynsec_admin_user,
|
|
44
|
+
password: secrets.dynsec_admin_password,
|
|
45
|
+
connectTimeout: 5000,
|
|
46
|
+
reconnectPeriod: 0, // a one-shot: callers reconnect rather than queueing behind a retry
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
let settled = false;
|
|
50
|
+
const waiting = []; // FIFO of {cb, timer}, one per command batch sent
|
|
51
|
+
|
|
52
|
+
const answer = (err, res) => {
|
|
53
|
+
const next = waiting.shift();
|
|
54
|
+
if (!next) return; // unsolicited, or a reply that arrived after we gave up
|
|
55
|
+
clearTimeout(next.timer);
|
|
56
|
+
next.cb(err, res);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
client.on('message', (topic, payload) => {
|
|
60
|
+
let parsed;
|
|
61
|
+
try { parsed = JSON.parse(payload.toString()); } catch (e) { parsed = null; }
|
|
62
|
+
if (!parsed || !parsed.responses) return answer(new Error('Unreadable reply from the plugin'));
|
|
63
|
+
// Every command in the batch has its own response; the first error is the useful one.
|
|
64
|
+
const bad = parsed.responses.find((r) => r.error);
|
|
65
|
+
if (bad) return answer(new Error(`${bad.command}: ${bad.error}`), parsed.responses);
|
|
66
|
+
answer(null, parsed.responses);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
client.on('error', (err) => {
|
|
70
|
+
if (!settled) { settled = true; client.end(true); return cb(err); }
|
|
71
|
+
answer(err); // in flight when the connection failed
|
|
72
|
+
});
|
|
73
|
+
client.on('close', () => { if (settled) answer(new Error('Connection to the broker closed')); });
|
|
74
|
+
|
|
75
|
+
// A connect that never completes has to fail too, not wait.
|
|
76
|
+
const connectTimer = setTimeout(() => {
|
|
77
|
+
if (!settled) {
|
|
78
|
+
settled = true;
|
|
79
|
+
client.end(true);
|
|
80
|
+
cb(new Error(`No answer from the broker at ${broker} within ${REPLY_TIMEOUT_MS}ms`));
|
|
81
|
+
}
|
|
82
|
+
}, REPLY_TIMEOUT_MS);
|
|
83
|
+
|
|
84
|
+
client.on('connect', () => {
|
|
85
|
+
client.subscribe(RESPONSE, { qos: 1 }, (err) => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
clearTimeout(connectTimer);
|
|
89
|
+
if (err) { client.end(true); return cb(err); }
|
|
90
|
+
cb(null, makeApi(client, waiting));
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function makeApi(client, waiting) {
|
|
96
|
+
// Send a batch and call back once with its combined result. Batching matters: creating a role and
|
|
97
|
+
// its ACLs is several commands, and one round trip per batch keeps a rebuild quick.
|
|
98
|
+
function send(commands, cb) {
|
|
99
|
+
cb = cb || (() => {});
|
|
100
|
+
if (!commands.length) return cb(null, []);
|
|
101
|
+
let done = false;
|
|
102
|
+
const once = (err, res) => { if (!done) { done = true; cb(err, res); } };
|
|
103
|
+
const entry = {
|
|
104
|
+
cb: once,
|
|
105
|
+
timer: setTimeout(() => {
|
|
106
|
+
const i = waiting.indexOf(entry);
|
|
107
|
+
if (i >= 0) waiting.splice(i, 1);
|
|
108
|
+
once(new Error(`The plugin did not answer ${commands[0].command} within ${REPLY_TIMEOUT_MS}ms`));
|
|
109
|
+
}, REPLY_TIMEOUT_MS),
|
|
110
|
+
};
|
|
111
|
+
waiting.push(entry);
|
|
112
|
+
client.publish(CONTROL, JSON.stringify({ commands }), { qos: 1 }, (err) => {
|
|
113
|
+
if (err) {
|
|
114
|
+
const i = waiting.indexOf(entry);
|
|
115
|
+
if (i >= 0) waiting.splice(i, 1);
|
|
116
|
+
clearTimeout(entry.timer);
|
|
117
|
+
once(err);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* For commands that create or add: succeeding and "it was already like that" are the same outcome,
|
|
124
|
+
* because every one of these is re-run - by the rebuild tool, at each login, and by a release that
|
|
125
|
+
* adds a rule.
|
|
126
|
+
*
|
|
127
|
+
* Matching on /already/ rather than a list of exact strings, because the plugin has a different
|
|
128
|
+
* wording for each command and getting one wrong makes a re-run FAIL rather than do nothing:
|
|
129
|
+
* "Role already exists", "Group already exists", "Client already exists", "Group is already in
|
|
130
|
+
* this role", "Client is already in this group"... The first version listed only the "already
|
|
131
|
+
* exists" ones, so the second apply aborted on addGroupRole before it reached the users, and a
|
|
132
|
+
* permission granted in the database never arrived at the broker.
|
|
133
|
+
*
|
|
134
|
+
* Safe to be broad here because this path is only ever given create/add commands - removals go
|
|
135
|
+
* through raw(), where an error is an error.
|
|
136
|
+
*/
|
|
137
|
+
function sendIgnoringExists(commands, cb) {
|
|
138
|
+
send(commands, (err, res) => {
|
|
139
|
+
if (err && /already/i.test(err.message)) return cb(null, res);
|
|
140
|
+
cb(err, res);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
raw: send,
|
|
146
|
+
idempotent: sendIgnoringExists,
|
|
147
|
+
end: (cb) => client.end(false, {}, cb),
|
|
148
|
+
|
|
149
|
+
listClients: (cb) => send([{ command: 'listClients', count: -1 }],
|
|
150
|
+
(e, r) => cb(e, e ? null : ((r[0].data && r[0].data.clients) || []))),
|
|
151
|
+
getClient: (username, cb) => send([{ command: 'getClient', username }],
|
|
152
|
+
(e, r) => cb(e, e ? null : (r[0].data && r[0].data.client))),
|
|
153
|
+
listGroups: (cb) => send([{ command: 'listGroups', count: -1 }],
|
|
154
|
+
(e, r) => cb(e, e ? null : ((r[0].data && r[0].data.groups) || []))),
|
|
155
|
+
getGroup: (groupname, cb) => send([{ command: 'getGroup', groupname }],
|
|
156
|
+
(e, r) => cb(e, e ? null : (r[0].data && r[0].data.group))),
|
|
157
|
+
listRoles: (cb) => send([{ command: 'listRoles', count: -1 }],
|
|
158
|
+
(e, r) => cb(e, e ? null : ((r[0].data && r[0].data.roles) || []))),
|
|
159
|
+
getRole: (rolename, cb) => send([{ command: 'getRole', rolename }],
|
|
160
|
+
(e, r) => cb(e, e ? null : (r[0].data && r[0].data.role))),
|
|
161
|
+
getAnonymousGroup: (cb) => send([{ command: 'getAnonymousGroup' }],
|
|
162
|
+
(e, r) => cb(e, e ? null : (r[0].data && r[0].data.group && r[0].data.group.groupname))),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export { CONTROL, RESPONSE };
|
package/lib/enrol.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Node enrolment: how a node gets its own broker credential.
|
|
3
|
+
*
|
|
4
|
+
* The problem this solves (SECURITY-REVIEW.md section 6): every node has the organization's shared
|
|
5
|
+
* broker password compiled into its firmware, so extracting one node's flash yields a credential
|
|
6
|
+
* for everything, and it cannot be rotated without reflashing the fleet. It has to be replaced by a
|
|
7
|
+
* per-node credential the node fetches for itself - because most nodes are flashed from PlatformIO
|
|
8
|
+
* or the Arduino IDE with no dashboard involved, and waiting for a human to approve each one does
|
|
9
|
+
* not work in practice.
|
|
10
|
+
*
|
|
11
|
+
* So: the firmware carries an ENROLMENT SECRET, which grants exactly one thing - "create a node in
|
|
12
|
+
* this organization". No read, no write, no broker access at all. It is rate limited, per
|
|
13
|
+
* organization, and revocable server-side without reflashing anything, none of which is true of the
|
|
14
|
+
* broker password it replaces.
|
|
15
|
+
*
|
|
16
|
+
* The one weakness, and why the 409 below exists: a node id is derived from the chip's MAC, so it is
|
|
17
|
+
* public and anyone can claim to be one. If the server re-issued a credential to any request quoting
|
|
18
|
+
* an existing node id, the enrolment secret would become "impersonate any node in the organization",
|
|
19
|
+
* which is the isolation this whole step exists to create. So a node that already exists must prove
|
|
20
|
+
* it holds the current password.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { randomBytes } from 'crypto';
|
|
24
|
+
|
|
25
|
+
// How enrolment can fail, in a shape the route can turn into a status code. Deliberately vague to
|
|
26
|
+
// the caller about WHY a secret was refused - a node has no use for the difference, and an attacker
|
|
27
|
+
// probing organization names should not learn which ones exist.
|
|
28
|
+
export const ENROL = {
|
|
29
|
+
BAD_REQUEST: 'bad_request',
|
|
30
|
+
REFUSED: 'refused', // wrong or unknown secret, or unknown organization
|
|
31
|
+
RATE_LIMITED: 'rate_limited',
|
|
32
|
+
NEEDS_RESET: 'needs_reset', // known node, no proof of its current password
|
|
33
|
+
NO_PROJECT: 'no_such_project', // the organization has no project of that name
|
|
34
|
+
DENIED: 'denied', // an admin has denied this node
|
|
35
|
+
BROKER: 'broker_unavailable',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
class EnrolError extends Error {
|
|
39
|
+
constructor(code, message) { super(message); this.code = code; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/*
|
|
43
|
+
* Which enrolment secrets an organization accepts.
|
|
44
|
+
*
|
|
45
|
+
* A LIST, not one secret, so a secret can be rotated without stranding nodes that were flashed with
|
|
46
|
+
* the old one and have not enrolled yet. Any secret in the list is accepted; a compromised one is
|
|
47
|
+
* deleted by hand. A node that has already enrolled never presents one again, so it is unaffected
|
|
48
|
+
* either way.
|
|
49
|
+
*
|
|
50
|
+
* Per organization, and that matters: an admin who shares their organization's secret, or leaks it
|
|
51
|
+
* in a firmware image, must not thereby let anyone enrol nodes into a different organization.
|
|
52
|
+
*/
|
|
53
|
+
export function enrolmentSecretsFor(config, org) {
|
|
54
|
+
const s = (config && config.secrets && config.secrets[`enrolment_${org}`]);
|
|
55
|
+
if (!s) return [];
|
|
56
|
+
return (Array.isArray(s) ? s : [s]).map(String).filter((x) => x.length);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Constant-time-ish comparison. The secrets are long random strings, so a timing attack is not the
|
|
60
|
+
// realistic threat here, but there is no reason to leak a prefix match either.
|
|
61
|
+
function sameSecret(a, b) {
|
|
62
|
+
if (a.length !== b.length) return false;
|
|
63
|
+
let differing = 0;
|
|
64
|
+
for (let i = 0; i < a.length; i++) differing |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
65
|
+
return differing === 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/*
|
|
69
|
+
* Rate limiting, in memory.
|
|
70
|
+
*
|
|
71
|
+
* Two counters, because they answer different questions: per organization stops somebody with a
|
|
72
|
+
* leaked secret filling the database with invented nodes, and per node id stops repeated attempts
|
|
73
|
+
* against one real node. In memory is enough - it survives as long as the process, a restart is a
|
|
74
|
+
* fair reset, and persisting it would mean a disk write per attempt.
|
|
75
|
+
*/
|
|
76
|
+
export function makeRateLimiter({ perOrg = 20, perNode = 5, windowMs = 3600000, now = Date.now } = {}) {
|
|
77
|
+
const hits = new Map(); // key -> [timestamps]
|
|
78
|
+
const record = (key, limit) => {
|
|
79
|
+
const t = now();
|
|
80
|
+
const kept = (hits.get(key) || []).filter((x) => x > t - windowMs);
|
|
81
|
+
kept.push(t);
|
|
82
|
+
hits.set(key, kept);
|
|
83
|
+
return kept.length <= limit;
|
|
84
|
+
};
|
|
85
|
+
return {
|
|
86
|
+
allow(org, nodeid) {
|
|
87
|
+
// Both are recorded even if the first refuses, so a flood counts against the organization
|
|
88
|
+
const orgOk = record(`org:${org}`, perOrg);
|
|
89
|
+
const nodeOk = record(`node:${org}/${nodeid}`, perNode);
|
|
90
|
+
return orgOk && nodeOk;
|
|
91
|
+
},
|
|
92
|
+
// For tests and for a successful enrolment, which should not count against the limit
|
|
93
|
+
forget(org, nodeid) { hits.delete(`org:${org}`); hits.delete(`node:${org}/${nodeid}`); },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const NODEID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
98
|
+
const NAME = /^[a-z0-9]{1,32}$/;
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* The record of nodes that asked and were refused, for the dashboard's Nodes card (SECURITY.md S12).
|
|
102
|
+
*
|
|
103
|
+
* This is how an admin learns a node's id at all: an id is not discoverable from the server, but a
|
|
104
|
+
* node that keeps asking puts itself in this list, and the admin approves it from there. The rows
|
|
105
|
+
* are therefore UNTRUSTED - anyone can make an id appear by attempting enrolment - so each keeps
|
|
106
|
+
* when it asked and from where, for the admin to recognise rather than to trust.
|
|
107
|
+
*
|
|
108
|
+
* In memory, and bounded. The nodes table deliberately has no last_seen column because that would
|
|
109
|
+
* be a database write per reading on an SD card; a write per enrolment attempt is the same mistake
|
|
110
|
+
* on a smaller scale. Losing the list on restart costs nothing: a node that is still retrying
|
|
111
|
+
* reappears within minutes.
|
|
112
|
+
*/
|
|
113
|
+
export function makeAttemptLog({ perOrg = 50, now = Date.now } = {}) {
|
|
114
|
+
const rows = new Map(); // "org/nodeid" -> {org, project, nodeid, at, from, reason, count}
|
|
115
|
+
return {
|
|
116
|
+
record({ org, project, nodeid, from, reason }) {
|
|
117
|
+
const key = `${org}/${nodeid}`;
|
|
118
|
+
const existing = rows.get(key);
|
|
119
|
+
rows.set(key, {
|
|
120
|
+
org, project, nodeid, from, reason,
|
|
121
|
+
at: now(),
|
|
122
|
+
count: existing ? existing.count + 1 : 1,
|
|
123
|
+
});
|
|
124
|
+
// Oldest out first, per organization, so one organization cannot crowd out another
|
|
125
|
+
const mine = [...rows.values()].filter((r) => r.org === org).sort((a, b) => a.at - b.at);
|
|
126
|
+
for (const r of mine.slice(0, Math.max(0, mine.length - perOrg))) rows.delete(`${r.org}/${r.nodeid}`);
|
|
127
|
+
},
|
|
128
|
+
forOrg(org) { return [...rows.values()].filter((r) => r.org === org); },
|
|
129
|
+
forget(org, nodeid) { rows.delete(`${org}/${nodeid}`); },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// An admin's decision about one node, from the node_grants table
|
|
134
|
+
export function readGrant(db, org, nodeid, cb) {
|
|
135
|
+
db.get('SELECT state, project FROM node_grants WHERE org = ? AND nodeid = ?', [org, nodeid], cb);
|
|
136
|
+
}
|
|
137
|
+
export function setGrant(db, { org, project, nodeid, state, by }, cb) {
|
|
138
|
+
db.run(`INSERT INTO node_grants (org, project, nodeid, state, created_by, created_at)
|
|
139
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
140
|
+
ON CONFLICT(org, nodeid)
|
|
141
|
+
DO UPDATE SET project = excluded.project, state = excluded.state,
|
|
142
|
+
created_by = excluded.created_by, created_at = excluded.created_at`,
|
|
143
|
+
[org, project || '', nodeid, state, by || '', Date.now()], cb);
|
|
144
|
+
}
|
|
145
|
+
export function clearGrant(db, org, nodeid, cb) {
|
|
146
|
+
db.run('DELETE FROM node_grants WHERE org = ? AND nodeid = ?', [org, nodeid],
|
|
147
|
+
function (err) { cb(err, this && this.changes); });
|
|
148
|
+
}
|
|
149
|
+
export function readGrants(db, org, cb) {
|
|
150
|
+
db.all('SELECT project, nodeid, state, created_by, created_at FROM node_grants WHERE org = ?',
|
|
151
|
+
[org], cb);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/*
|
|
155
|
+
* Handle one enrolment request.
|
|
156
|
+
*
|
|
157
|
+
* deps: { db, config, limiter, syncNode, attempts?, from? }
|
|
158
|
+
* syncNode(node, cb) creates the broker account
|
|
159
|
+
* attempts makeAttemptLog(), so a refusal reaches the dashboard's Nodes card
|
|
160
|
+
* from the caller's address, recorded with a refusal
|
|
161
|
+
* body: { org, project, nodeid, enrolment_secret, current_password?, lora? }
|
|
162
|
+
*
|
|
163
|
+
* cb(err, { username, password }) - err.code is one of ENROL above.
|
|
164
|
+
*/
|
|
165
|
+
export function enrol(deps, body, cb) {
|
|
166
|
+
const { db, config, limiter, syncNode, attempts, from } = deps;
|
|
167
|
+
// Every refusal past the shape check is worth showing an admin: it is the only way they learn
|
|
168
|
+
// that a node exists and is asking. The shape check is not - a request that is not even
|
|
169
|
+
// org/project/nodeid tells them nothing they could act on.
|
|
170
|
+
const refuse = (code, message) => {
|
|
171
|
+
if (attempts) {
|
|
172
|
+
attempts.record({
|
|
173
|
+
org: String((body && body.org) || ''), project: String((body && body.project) || ''),
|
|
174
|
+
nodeid: String((body && body.nodeid) || ''), from, reason: code,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return cb(new EnrolError(code, message));
|
|
178
|
+
};
|
|
179
|
+
const org = String((body && body.org) || '');
|
|
180
|
+
const project = String((body && body.project) || '');
|
|
181
|
+
const nodeid = String((body && body.nodeid) || '');
|
|
182
|
+
const secret = String((body && body.enrolment_secret) || '');
|
|
183
|
+
const lora = !!(body && body.lora);
|
|
184
|
+
|
|
185
|
+
if (!NAME.test(org) || !NAME.test(project) || !NODEID.test(nodeid)) {
|
|
186
|
+
return cb(new EnrolError(ENROL.BAD_REQUEST, 'org, project and nodeid are required and must be plain names'));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (limiter && !limiter.allow(org, nodeid)) {
|
|
190
|
+
return refuse(ENROL.RATE_LIMITED, 'Too many enrolment attempts - try later');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/*
|
|
194
|
+
* What the admin has decided about this node, before anything else is considered.
|
|
195
|
+
*
|
|
196
|
+
* denied refused whatever it presents, and the reason it is checked before the secret: a
|
|
197
|
+
* denied node usually still holds a perfectly good secret.
|
|
198
|
+
* approved the secret is not required, and neither is proof of a current password - which is
|
|
199
|
+
* the whole point, since this exists for a node that can prove nothing. Consumed on
|
|
200
|
+
* success, so one approval admits one node once.
|
|
201
|
+
*
|
|
202
|
+
* The project must still be registered either way. An approval says "this node may enrol", not
|
|
203
|
+
* "invent whatever structure it asks for".
|
|
204
|
+
*/
|
|
205
|
+
readGrant(db, org, nodeid, (gerr, grant) => {
|
|
206
|
+
if (gerr) return cb(gerr);
|
|
207
|
+
if (grant && grant.state === 'denied') {
|
|
208
|
+
return refuse(ENROL.DENIED, 'This node has been denied by an administrator');
|
|
209
|
+
}
|
|
210
|
+
const approved = !!(grant && grant.state === 'approved');
|
|
211
|
+
|
|
212
|
+
if (!approved) {
|
|
213
|
+
// A missing secret is a refusal, not a malformed request - a node flashed with none is
|
|
214
|
+
// precisely the node an admin may want to approve, so it has to reach the list above rather
|
|
215
|
+
// than being turned away as a bad request.
|
|
216
|
+
const accepted = enrolmentSecretsFor(config, org);
|
|
217
|
+
if (!secret || !accepted.some((x) => sameSecret(x, secret))) {
|
|
218
|
+
// Same answer whether the organization does not exist or the secret is wrong
|
|
219
|
+
return refuse(ENROL.REFUSED, 'Enrolment refused');
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/*
|
|
224
|
+
* The project has to be one the organization has actually registered.
|
|
225
|
+
*
|
|
226
|
+
* Without this, a project name mistyped into a node's captive portal is accepted, gets its own
|
|
227
|
+
* broker account, and then appears in the dashboard as a project with a node in it - inventing
|
|
228
|
+
* organisation structure from a typo. The admin dashboard's Projects tab is where projects are
|
|
229
|
+
* added, so the fix is in the operator's hands and the error below says so.
|
|
230
|
+
*/
|
|
231
|
+
db.get('SELECT 1 AS ok FROM projects WHERE org = ? AND id = ?', [org, project], (perr, known) => {
|
|
232
|
+
if (perr) return cb(perr);
|
|
233
|
+
if (!known) {
|
|
234
|
+
return refuse(ENROL.NO_PROJECT,
|
|
235
|
+
`Organization ${org} has no project "${project}". Check the spelling on the node, or add ` +
|
|
236
|
+
`the project on the dashboard's Projects tab, then let the node try again.`);
|
|
237
|
+
}
|
|
238
|
+
db.get('SELECT password FROM nodes WHERE org = ? AND project = ? AND nodeid = ?',
|
|
239
|
+
[org, project, nodeid], (err, existing) => {
|
|
240
|
+
if (err) return cb(err);
|
|
241
|
+
|
|
242
|
+
if (existing && !approved) {
|
|
243
|
+
const proof = String((body && body.current_password) || '');
|
|
244
|
+
if (!proof || !sameSecret(existing.password, proof)) {
|
|
245
|
+
// Trust on first use, and only on first use. See the note at the top of this file.
|
|
246
|
+
return refuse(ENROL.NEEDS_RESET,
|
|
247
|
+
'This node is already enrolled. Only the node itself can be issued a new credential; ' +
|
|
248
|
+
'if its filesystem was erased, an administrator can approve it on the dashboard, or ' +
|
|
249
|
+
'clear the binding with frugal-iot-resetnode.');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const password = randomBytes(24).toString('base64url');
|
|
254
|
+
const node = { org, project, nodeid, password, lora };
|
|
255
|
+
// The broker first: a node that stored a credential the broker does not know would be unable
|
|
256
|
+
// to connect AND unable to re-enrol, because it has one. Better to refuse and let it retry.
|
|
257
|
+
syncNode(node, (serr) => {
|
|
258
|
+
if (serr) {
|
|
259
|
+
return refuse(ENROL.BROKER,
|
|
260
|
+
'The broker could not be updated - not issuing a credential that would not work');
|
|
261
|
+
}
|
|
262
|
+
db.run(`INSERT INTO nodes (org, project, nodeid, password, lora, enrolled_at)
|
|
263
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
264
|
+
ON CONFLICT(org, project, nodeid)
|
|
265
|
+
DO UPDATE SET password = excluded.password, lora = excluded.lora,
|
|
266
|
+
enrolled_at = excluded.enrolled_at`,
|
|
267
|
+
[org, project, nodeid, password, lora ? 1 : 0, Date.now()], (ierr) => {
|
|
268
|
+
if (ierr) return cb(ierr);
|
|
269
|
+
if (limiter) limiter.forget(org, nodeid); // success is not an attempt worth counting
|
|
270
|
+
if (attempts) attempts.forget(org, nodeid); // nor a failure still worth showing
|
|
271
|
+
const done = () => cb(null, { username: `${org}/${project}/${nodeid}`, password });
|
|
272
|
+
// An approval admits one node once. Left in place it would be a standing invitation to
|
|
273
|
+
// anyone quoting a public node id.
|
|
274
|
+
if (approved) return clearGrant(db, org, nodeid, done);
|
|
275
|
+
done();
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Forget a node, so it can enrol afresh - for a node whose filesystem was erased and which
|
|
284
|
+
// therefore cannot prove anything. The one human step, and only in recovery.
|
|
285
|
+
export function forgetNode(db, org, project, nodeid, cb) {
|
|
286
|
+
db.run('DELETE FROM nodes WHERE org = ? AND project = ? AND nodeid = ?',
|
|
287
|
+
[org, project, nodeid], function (err) { cb(err, this && this.changes); });
|
|
288
|
+
}
|