@thuzjq/meteorcloud-device-sdk-node 0.5.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/lib/config.js ADDED
@@ -0,0 +1,257 @@
1
+ 'use strict';
2
+
3
+ const fsp = require('node:fs/promises');
4
+ const net = require('node:net');
5
+ const path = require('node:path');
6
+ const crypto = require('node:crypto');
7
+
8
+ const { MeteorCloudError, fail, isObject } = require('./errors');
9
+ const { parseKeyReference } = require('./keystore');
10
+ const { replaceFileDurable } = require('./durable');
11
+
12
+ const CONFIG_SCHEMA = 'mlc.installation/1';
13
+ const MAX_CONFIG_SIZE = 64 * 1024;
14
+ const INSTALLATION_UID_RE = /^ins_[0-9a-f]{32}$/;
15
+ const PRODUCT_CLIENT_IDS = new Set(['meteormasterai', 'meteorstudio', 'ufocapture-adapter']);
16
+
17
+ /**
18
+ * Exactly the members that may appear in the on-disk config. Anything else is a
19
+ * hard failure on write, which is the mechanism that keeps the file zero-secret:
20
+ * a future field carrying a token cannot be added by accident, only by editing
21
+ * this list and the test that asserts it.
22
+ */
23
+ const CONFIG_KEYS = Object.freeze(['schema', 'issuer', 'client_id', 'installation_uid', 'key_reference']);
24
+
25
+ /**
26
+ * Substrings that must never occur in a config file or a journal. Names, not
27
+ * values — the point is to catch a field named `access_token` before anyone
28
+ * ever puts a real one there.
29
+ */
30
+ const FORBIDDEN_CONFIG_SUBSTRINGS = Object.freeze([
31
+ 'access_token',
32
+ 'accessToken',
33
+ 'refresh_token',
34
+ 'refreshToken',
35
+ 'client_secret',
36
+ 'clientSecret',
37
+ 'device_secret',
38
+ 'deviceSecret',
39
+ 'private_key',
40
+ 'privateKey',
41
+ 'PRIVATE KEY',
42
+ 'code_verifier',
43
+ 'codeVerifier',
44
+ 'client_assertion',
45
+ 'clientAssertion',
46
+ 'tmpSecret',
47
+ 'sessionToken',
48
+ 'Authorization'
49
+ ]);
50
+
51
+ function isLoopbackHost(hostname) {
52
+ const host = String(hostname || '').toLowerCase().replace(/^\[|\]$/g, '');
53
+ if (host === 'localhost' || host === '::1') return true;
54
+ return net.isIPv4(host) && host.startsWith('127.');
55
+ }
56
+
57
+ /**
58
+ * Cleartext HTTP is tolerated only against a loopback host. That is what lets
59
+ * the whole test suite and a developer's local stack run without certificates,
60
+ * while a typo that points production at `http://cloud.example.com` still fails
61
+ * before a single assertion is signed.
62
+ */
63
+ function validateHttpsOrigin(value, label) {
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(value);
67
+ } catch (cause) {
68
+ throw new MeteorCloudError(`${label} is not a valid URL`, { kind: 'validation', cause });
69
+ }
70
+ if (parsed.username || parsed.password || parsed.search || parsed.hash) {
71
+ fail(`${label} must not carry credentials, a query or a fragment`);
72
+ }
73
+ const loopback = isLoopbackHost(parsed.hostname);
74
+ if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && loopback)) {
75
+ fail(`${label} must use https (http is allowed only for loopback hosts)`);
76
+ }
77
+ if (!loopback && !parsed.hostname.includes('.')) {
78
+ fail(`${label} must use a fully qualified hostname`);
79
+ }
80
+ return parsed;
81
+ }
82
+
83
+ /**
84
+ * The issuer may carry a path prefix (SAS mounted under a sub-path), so the
85
+ * trailing slash is normalised away rather than the path being rejected.
86
+ */
87
+ function normalizeIssuer(issuer) {
88
+ const parsed = validateHttpsOrigin(issuer, 'issuer');
89
+ const pathname = parsed.pathname.replace(/\/+$/, '');
90
+ return `${parsed.origin}${pathname}`;
91
+ }
92
+
93
+ function normalizeApiBase(apiBase) {
94
+ const parsed = validateHttpsOrigin(apiBase, 'apiBase');
95
+ if (!['', '/', '/cloud', '/cloud/'].includes(parsed.pathname)) {
96
+ fail('apiBase must be an origin, optionally ending in /cloud');
97
+ }
98
+ return parsed.origin;
99
+ }
100
+
101
+ function tokenEndpoint(issuer) {
102
+ return `${normalizeIssuer(issuer)}/oauth2/token`;
103
+ }
104
+
105
+ function authorizeEndpoint(issuer) {
106
+ return `${normalizeIssuer(issuer)}/oauth2/authorize`;
107
+ }
108
+
109
+ function validateInstallationConfig(input) {
110
+ if (!isObject(input)) fail('installation config must be an object');
111
+ // A pre-convergence C++ 0.3-dev build wrote {config_version: 3,
112
+ // installation_id, client_id: <the installation uid>}. Name that file for
113
+ // what it is: "schema must be mlc.installation/1" against a document that has
114
+ // no `schema` member at all sends the reader looking for a typo.
115
+ if (input.config_version !== undefined && input.schema === undefined) {
116
+ fail(
117
+ 'this installation config was written by a pre-0.3.0 C++ SDK ' +
118
+ '(config_version); the current schema is mlc.installation/1 and the ' +
119
+ 'installation must be re-bound'
120
+ );
121
+ }
122
+ const schema = input.schema ?? CONFIG_SCHEMA;
123
+ if (schema !== CONFIG_SCHEMA) fail(`installation config schema must be ${CONFIG_SCHEMA}`);
124
+ const issuer = normalizeIssuer(input.issuer);
125
+ const clientId = input.client_id ?? input.clientId;
126
+ const installationUid = input.installation_uid ?? input.installationUid;
127
+ const keyReference = input.key_reference ?? input.keyReference;
128
+ if (!PRODUCT_CLIENT_IDS.has(clientId)) {
129
+ // Same cause as above, one layer in: the schema is right but client_id
130
+ // still holds the installation uid the C++ SDK used to put there.
131
+ if (INSTALLATION_UID_RE.test(clientId || '')) {
132
+ fail(
133
+ 'installation config client_id holds an installation uid: it was ' +
134
+ 'written by a pre-0.3.0 C++ SDK and must carry the product client ' +
135
+ `(${[...PRODUCT_CLIENT_IDS].join(', ')}) instead`
136
+ );
137
+ }
138
+ fail('client_id is not an approved product client');
139
+ }
140
+ if (!INSTALLATION_UID_RE.test(installationUid || '')) fail('installation_uid is not canonical');
141
+ parseKeyReference(keyReference);
142
+ return { schema, issuer, clientId, installationUid, keyReference };
143
+ }
144
+
145
+ /**
146
+ * Throws if `text` looks like it carries a credential. Applied to the config and
147
+ * the journal before either is written, so the no-secrets rule is enforced by
148
+ * the writer rather than by a code review.
149
+ */
150
+ function assertNoSecrets(text, label) {
151
+ for (const needle of FORBIDDEN_CONFIG_SUBSTRINGS) {
152
+ if (text.includes(needle)) {
153
+ fail(`${label} would contain a credential-shaped field (${needle})`);
154
+ }
155
+ }
156
+ }
157
+
158
+ async function loadInstallationConfig(configPath) {
159
+ if (typeof configPath !== 'string' || !configPath) fail('configPath is required');
160
+ let text;
161
+ try {
162
+ const stat = await fsp.lstat(configPath);
163
+ if (!stat.isFile() || stat.isSymbolicLink()) fail('installation config must be a regular file');
164
+ if (stat.size < 1 || stat.size > MAX_CONFIG_SIZE) fail('installation config size is implausible');
165
+ text = await fsp.readFile(configPath, 'utf8');
166
+ } catch (error) {
167
+ if (error instanceof MeteorCloudError) throw error;
168
+ throw new MeteorCloudError('cannot read installation config', { kind: 'io', cause: error });
169
+ }
170
+ let parsed;
171
+ try {
172
+ parsed = JSON.parse(text);
173
+ } catch (cause) {
174
+ throw new MeteorCloudError('installation config is invalid JSON', { kind: 'validation', cause });
175
+ }
176
+ return validateInstallationConfig(parsed);
177
+ }
178
+
179
+ async function saveInstallationConfig(configPath, config) {
180
+ if (typeof configPath !== 'string' || !configPath) fail('configPath is required');
181
+ const validated = validateInstallationConfig(config);
182
+ const document = {
183
+ schema: CONFIG_SCHEMA,
184
+ issuer: validated.issuer,
185
+ client_id: validated.clientId,
186
+ installation_uid: validated.installationUid,
187
+ key_reference: validated.keyReference
188
+ };
189
+ const extra = Object.keys(document).filter((key) => !CONFIG_KEYS.includes(key));
190
+ if (extra.length) fail(`installation config carries unexpected members: ${extra.join(', ')}`);
191
+ const encoded = `${JSON.stringify(document, null, 2)}\n`;
192
+ // The scanner runs over the document with key_reference's value masked out.
193
+ //
194
+ // That value is a filesystem path the integrator chose, not something we
195
+ // produce, and `private_key.pem` is about the most natural name a person can
196
+ // give a private key file. Scanning it made the substring rule fire on the
197
+ // path itself and refuse to save — permanently, since the name never changes
198
+ // on its own. The bite was worst inside connect(): it fires *after* the code
199
+ // exchange, so the server already holds the installation and the one-time
200
+ // bridge grant is already spent, and the user is left with a registered
201
+ // installation, a key on disk, and no config naming it.
202
+ //
203
+ // Masking does not weaken the guard. What the guard is for is a credential
204
+ // accidentally serialized into this file, and every other route to that is
205
+ // still closed: CONFIG_KEYS is a closed allowlist checked just above, the
206
+ // other four values are validated to be a URL, a client id, and two uids, and
207
+ // key_reference itself has already been through parseKeyReference. The one
208
+ // thing still checked in the path is the PEM banner, because a literal
209
+ // "-----BEGIN PRIVATE KEY-----" in a filename is not a naming choice, it is a
210
+ // key that ended up somewhere it should never be.
211
+ assertNoSecrets(encoded.replace(JSON.stringify(document.key_reference), '"<key_reference>"'),
212
+ 'installation config');
213
+ // Decode before checking: key_reference is a URL, so the spaces in a PEM
214
+ // banner arrive as %20 and a raw substring test silently never matches.
215
+ let decodedReference = document.key_reference;
216
+ try {
217
+ decodedReference = decodeURIComponent(document.key_reference);
218
+ } catch (_) {
219
+ /* malformed escapes: fall back to the raw form, which is still checked */
220
+ }
221
+ if (decodedReference.includes('PRIVATE KEY') || document.key_reference.includes('PRIVATE KEY')) {
222
+ fail('installation config would contain a credential-shaped field (PRIVATE KEY)');
223
+ }
224
+
225
+ const temporary = `${configPath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
226
+ try {
227
+ // Durable: this config is what names the key. A migration that flips
228
+ // key_reference and then loses the flip to a power cut leaves an
229
+ // installation pointing at a PEM that has already been shredded.
230
+ await replaceFileDurable(configPath, Buffer.from(encoded, 'utf8'), temporary, { mode: 0o600 });
231
+ } catch (cause) {
232
+ try {
233
+ await fsp.rm(temporary, { force: true });
234
+ } catch (_) {
235
+ /* best effort */
236
+ }
237
+ throw new MeteorCloudError('cannot save installation config', { kind: 'io', cause });
238
+ }
239
+ return validated;
240
+ }
241
+
242
+ module.exports = {
243
+ CONFIG_SCHEMA,
244
+ CONFIG_KEYS,
245
+ FORBIDDEN_CONFIG_SUBSTRINGS,
246
+ PRODUCT_CLIENT_IDS,
247
+ INSTALLATION_UID_RE,
248
+ isLoopbackHost,
249
+ normalizeIssuer,
250
+ normalizeApiBase,
251
+ tokenEndpoint,
252
+ authorizeEndpoint,
253
+ validateInstallationConfig,
254
+ assertNoSecrets,
255
+ loadInstallationConfig,
256
+ saveInstallationConfig
257
+ };