frugal-iot-server 0.3.9 → 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.
@@ -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
+ }