@fireemu/linux-arm64 0.30.0 → 0.31.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/README.md +1 -1
- package/bin/fireemu +0 -0
- package/bin/runner-node/admin-app-check.mjs +428 -0
- package/bin/runner-node/index.mjs +15 -0
- package/bin/runner-node/protocol.mjs +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# @fireemu/linux-arm64
|
|
2
2
|
|
|
3
3
|
The [fireemu](https://www.npmjs.com/package/fireemu) daemon built for `linux-arm64`
|
|
4
|
-
(Rust target `aarch64-unknown-linux-musl`), version 0.
|
|
4
|
+
(Rust target `aarch64-unknown-linux-musl`), version 0.31.0.
|
|
5
5
|
|
|
6
6
|
Statically linked against musl: any glibc or musl distribution, including distroless and Alpine.
|
|
7
7
|
|
package/bin/fireemu
CHANGED
|
Binary file
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
// Version-bounded instrumentation of `firebase-admin`'s App Check `verifyToken` for the
|
|
2
|
+
// Functions runner (HTTP Functions that verify the `X-Firebase-AppCheck` header themselves).
|
|
3
|
+
//
|
|
4
|
+
// Why it exists. firebase-admin verifies an App Check token offline: it decodes the JWT, checks
|
|
5
|
+
// the claims and then checks the signature against the keys at a Google URL hard-coded in the
|
|
6
|
+
// SDK. A token the emulator minted can never verify there, and an SDK that did not reach the
|
|
7
|
+
// emulator's key could not see a session reset in any case (it caches keys for six hours). So
|
|
8
|
+
// the one step that cannot work, the signature check, is replaced by a question to the daemon,
|
|
9
|
+
// which answers from the same verifier the callable boundary uses, against the project's
|
|
10
|
+
// current epoch and app registry, under the session admission barrier. Nothing is cached here.
|
|
11
|
+
//
|
|
12
|
+
// What stays the SDK's: `safeDecode` and `verifyContent` (so malformed tokens and wrong
|
|
13
|
+
// algorithm, audience, issuer and subject fail with the SDK's own code and message, before the
|
|
14
|
+
// daemon is asked), and every error the daemon's answer becomes: the verdict is turned into
|
|
15
|
+
// the `JwtError` the analogous production condition raises and thrown through the SDK's own
|
|
16
|
+
// `mapJwtErrorToAppCheckError`, so the code, message and (14.x) `cause` shape are the SDK's.
|
|
17
|
+
//
|
|
18
|
+
// What is refused. `verifyToken(token, { consume: true })` would call Google's
|
|
19
|
+
// `verifyAppCheckToken` with the developer's credentials. Limited-use consumption is
|
|
20
|
+
// unimplemented (`APPCHECK-REPLAY-1`), so `verifyReplayProtection` is replaced by a fixed
|
|
21
|
+
// refusal that never builds a request. It is installed together with the signature check, in
|
|
22
|
+
// one step, because once fireemu tokens verify, the consume request becomes reachable.
|
|
23
|
+
//
|
|
24
|
+
// Failing closed. Outside the supported majors, or when any structural probe or the startup
|
|
25
|
+
// self-test fails, nothing stays patched: the stock SDK then fetches Google's keys, which no
|
|
26
|
+
// fireemu token verifies against, so it rejects fireemu tokens and never accepts one it cannot
|
|
27
|
+
// check. A second copy of `firebase-admin` somewhere else in `node_modules` is not the one
|
|
28
|
+
// resolved from the codebase and stays stock (and therefore rejecting).
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from "node:fs";
|
|
31
|
+
import { createRequire } from "node:module";
|
|
32
|
+
import { dirname, join, parse as parsePath } from "node:path";
|
|
33
|
+
import https from "node:https";
|
|
34
|
+
import timers from "node:timers";
|
|
35
|
+
|
|
36
|
+
// The natives, captured before the runtime clock can virtualize timers: a verification
|
|
37
|
+
// deadline is a real-time safety bound.
|
|
38
|
+
const { setTimeout, clearTimeout } = timers;
|
|
39
|
+
|
|
40
|
+
/// `firebase-admin` majors whose App Check internals this instrumentation is written for. 14.3.0
|
|
41
|
+
/// and 14.5.0 were compared and are identical on this path; 13.10.0 differs only in how the
|
|
42
|
+
/// SDK constructs its errors, which the instrumentation never does itself.
|
|
43
|
+
export const SUPPORTED_MAJORS = [13, 14];
|
|
44
|
+
|
|
45
|
+
/// How long a question to the daemon may stay unanswered. The daemon waits for a session reset
|
|
46
|
+
/// to finish before it answers, so this is generous.
|
|
47
|
+
export const DEFAULT_TIMEOUT_MS = 15_000;
|
|
48
|
+
|
|
49
|
+
/// Questions in flight at once; the daemon refuses more than this per runner as well.
|
|
50
|
+
export const MAX_IN_FLIGHT = 64;
|
|
51
|
+
|
|
52
|
+
const SELF_TEST_TIMEOUT_MS = 5_000;
|
|
53
|
+
const SELF_TEST_PROJECT = "fireemu-self-test";
|
|
54
|
+
|
|
55
|
+
/// The text of `AppCheckTokenVerifier.safeDecode`'s failure (identical in 13 and 14).
|
|
56
|
+
const DECODE_FAILED =
|
|
57
|
+
"Decoding App Check token failed. Make sure you passed the entire string JWT which represents the Firebase App Check token.";
|
|
58
|
+
const PROJECT_MATCH =
|
|
59
|
+
" Make sure the App Check token comes from the same Firebase project as the service account used to authenticate this SDK.";
|
|
60
|
+
|
|
61
|
+
/// What each reason of the daemon's verdict becomes. `code` names a `JwtErrorCode`; the SDK's
|
|
62
|
+
/// mapper turns the `JwtError` into the `FirebaseAppCheckError` (or, for the codes it
|
|
63
|
+
/// does not name, an `invalid-argument` carrying this message).
|
|
64
|
+
const REASONS = {
|
|
65
|
+
"no-matching-kid": {
|
|
66
|
+
code: "NO_MATCHING_KID",
|
|
67
|
+
message: "no-matching-kid-error",
|
|
68
|
+
},
|
|
69
|
+
"invalid-signature": { code: "INVALID_SIGNATURE", message: "invalid signature" },
|
|
70
|
+
"token-expired": {
|
|
71
|
+
code: "TOKEN_EXPIRED",
|
|
72
|
+
message: "The provided token has expired. Get a fresh token from your client app and try again.",
|
|
73
|
+
},
|
|
74
|
+
malformed: { code: "INVALID_ARGUMENT", message: DECODE_FAILED },
|
|
75
|
+
claims: {
|
|
76
|
+
code: "INVALID_ARGUMENT",
|
|
77
|
+
message: `The provided App Check token has incorrect "iss" (issuer) or "aud" (audience) claim for this project.${PROJECT_MATCH}`,
|
|
78
|
+
},
|
|
79
|
+
"not-yet-valid": {
|
|
80
|
+
code: "INVALID_ARGUMENT",
|
|
81
|
+
message: 'The provided App Check token has an "iat" (issued-at) claim in the future.',
|
|
82
|
+
},
|
|
83
|
+
"unknown-app": {
|
|
84
|
+
code: "INVALID_ARGUMENT",
|
|
85
|
+
message:
|
|
86
|
+
'The provided App Check token has a "sub" (subject) claim that names no registered, enabled app of this project in fireemu.',
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/// The failure of a question the daemon could not answer. This is the text of the SDK's own
|
|
91
|
+
/// JWKS-fetch failure path, so the caller sees the error it would see if the keys were
|
|
92
|
+
/// unreachable.
|
|
93
|
+
function unavailableMessage(why) {
|
|
94
|
+
return `Error fetching Json Web Keys: ${why}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// The runner's side of the pipe to the daemon: the questions in flight and their deadlines.
|
|
98
|
+
/// It exists before the instrumentation does, so that the daemon's answers have somewhere to
|
|
99
|
+
/// go while the startup self-test is still waiting for one.
|
|
100
|
+
export function createAdminChannel(send, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
|
101
|
+
const pending = new Map();
|
|
102
|
+
let sequence = 0;
|
|
103
|
+
let asked = 0;
|
|
104
|
+
let deadlineMs = timeoutMs;
|
|
105
|
+
const settle = (id, verdict) => {
|
|
106
|
+
const entry = pending.get(id);
|
|
107
|
+
if (!entry) return;
|
|
108
|
+
pending.delete(id);
|
|
109
|
+
clearTimeout(entry.timer);
|
|
110
|
+
entry.resolve(verdict);
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
/// Questions asked so far, answered or not.
|
|
114
|
+
get asked() {
|
|
115
|
+
return asked;
|
|
116
|
+
},
|
|
117
|
+
pending: () => pending.size,
|
|
118
|
+
/// Shortens (or restores) the deadline of the questions asked from now on.
|
|
119
|
+
setDeadline(ms) {
|
|
120
|
+
deadlineMs = ms;
|
|
121
|
+
},
|
|
122
|
+
ask(projectId, token) {
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
asked += 1;
|
|
125
|
+
if (typeof projectId !== "string" || projectId === "" || typeof token !== "string") {
|
|
126
|
+
resolve({ ok: false, reason: "unavailable" });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (pending.size >= MAX_IN_FLIGHT) {
|
|
130
|
+
resolve({ ok: false, reason: "unavailable" });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const id = `admin-${++sequence}`;
|
|
134
|
+
const timer = setTimeout(() => settle(id, { ok: false, reason: "timeout" }), deadlineMs);
|
|
135
|
+
pending.set(id, { resolve, timer });
|
|
136
|
+
try {
|
|
137
|
+
send({ type: "appCheck:verify", requestId: id, projectId, token });
|
|
138
|
+
} catch {
|
|
139
|
+
settle(id, { ok: false, reason: "unavailable" });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
/// Hands over one `appCheck:verdict` frame. Only the right request id settles anything, once.
|
|
144
|
+
deliver(message) {
|
|
145
|
+
if (message?.type !== "appCheck:verdict" || typeof message.requestId !== "string") return;
|
|
146
|
+
settle(
|
|
147
|
+
message.requestId,
|
|
148
|
+
message.ok === true ? { ok: true } : { ok: false, reason: String(message.reason ?? "unavailable") },
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
/// Fails everything in flight: the runner is going away.
|
|
152
|
+
dispose() {
|
|
153
|
+
for (const id of [...pending.keys()]) settle(id, { ok: false, reason: "disposed" });
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function unsupported(reason, version = null) {
|
|
159
|
+
return {
|
|
160
|
+
supported: false,
|
|
161
|
+
instrumentation: reason,
|
|
162
|
+
version,
|
|
163
|
+
report: { instrumentation: reason, firebaseAdminVersion: version },
|
|
164
|
+
restore() {},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// The directory of the `firebase-admin` package that owns `entry`.
|
|
169
|
+
function packageRootOf(entry) {
|
|
170
|
+
let dir = dirname(entry);
|
|
171
|
+
const { root: filesystemRoot } = parsePath(dir);
|
|
172
|
+
for (;;) {
|
|
173
|
+
try {
|
|
174
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
175
|
+
if (pkg.name === "firebase-admin") return dir;
|
|
176
|
+
} catch {
|
|
177
|
+
// Not a package directory; keep walking up.
|
|
178
|
+
}
|
|
179
|
+
if (dir === filesystemRoot) break;
|
|
180
|
+
dir = dirname(dir);
|
|
181
|
+
}
|
|
182
|
+
throw new Error(`no firebase-admin package directory above ${entry}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/// Whether `name` is an own function property of `target` that may be replaced.
|
|
186
|
+
function isPatchable(target, name) {
|
|
187
|
+
if (target === null || target === undefined) return false;
|
|
188
|
+
const d = Object.getOwnPropertyDescriptor(target, name);
|
|
189
|
+
return !!d && typeof d.value === "function" && d.writable !== false && d.configurable !== false;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function loadModules(require, root) {
|
|
193
|
+
const load = (relative) => require(join(root, relative));
|
|
194
|
+
return {
|
|
195
|
+
verifier: load("lib/app-check/token-verifier.js"),
|
|
196
|
+
jwt: load("lib/utils/jwt.js"),
|
|
197
|
+
client: load("lib/app-check/app-check-api-client-internal.js"),
|
|
198
|
+
utils: load("lib/utils/index.js"),
|
|
199
|
+
error: (() => {
|
|
200
|
+
try {
|
|
201
|
+
return load("lib/app-check/error.js");
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
})(),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/// Builds `(code, message) => FirebaseAppCheckError` for whichever constructor shape the
|
|
210
|
+
/// installed SDK has (13: positional, 14: info object), checking the result.
|
|
211
|
+
function errorFactory(modules) {
|
|
212
|
+
const Ctor = modules.error?.FirebaseAppCheckError ?? modules.client.FirebaseAppCheckError;
|
|
213
|
+
if (typeof Ctor !== "function") return null;
|
|
214
|
+
const shapes = [
|
|
215
|
+
(code, message) => new Ctor({ code, message }),
|
|
216
|
+
(code, message) => new Ctor(code, message),
|
|
217
|
+
];
|
|
218
|
+
for (const make of shapes) {
|
|
219
|
+
try {
|
|
220
|
+
const probe = make("invalid-argument", "probe");
|
|
221
|
+
if (probe instanceof Error && probe.code === "app-check/invalid-argument" && probe.message === "probe") {
|
|
222
|
+
return make;
|
|
223
|
+
}
|
|
224
|
+
} catch {
|
|
225
|
+
// Try the other shape.
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// An agent that refuses to open any connection: the self-test hands it to the SDK so that a
|
|
232
|
+
/// probe that unexpectedly reaches the stock key fetch cannot leave the process.
|
|
233
|
+
class RefusingAgent extends https.Agent {
|
|
234
|
+
createConnection() {
|
|
235
|
+
throw new Error("fireemu self-test: network access is refused");
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function base64url(value) {
|
|
240
|
+
return Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/// A well-formed token the SDK's own decode and claim checks accept, signed by nothing.
|
|
244
|
+
function probeToken() {
|
|
245
|
+
return [
|
|
246
|
+
base64url({ alg: "RS256", typ: "JWT", kid: "fireemu-self-test" }),
|
|
247
|
+
base64url({
|
|
248
|
+
iss: "https://firebaseappcheck.googleapis.com/0",
|
|
249
|
+
aud: [`projects/${SELF_TEST_PROJECT}`],
|
|
250
|
+
sub: "fireemu-self-test",
|
|
251
|
+
iat: 0,
|
|
252
|
+
exp: 1,
|
|
253
|
+
}),
|
|
254
|
+
"AAAA",
|
|
255
|
+
].join(".");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// Installs the instrumentation for the `firebase-admin` the codebase resolves.
|
|
259
|
+
///
|
|
260
|
+
/// `channel` is [`createAdminChannel`]'s: every `appCheck:verdict` frame the daemon sends must
|
|
261
|
+
/// reach its `deliver` from the moment it is created, the startup self-test included.
|
|
262
|
+
///
|
|
263
|
+
/// `options.enabled` is false unless the daemon asked for the instrumentation
|
|
264
|
+
/// (`FIREEMU_APP_CHECK_ADMIN=1`): without App Check there are no fireemu tokens to verify.
|
|
265
|
+
export async function instrumentAdminAppCheck(sourceDir, channel, options = {}) {
|
|
266
|
+
const enabled = options.enabled ?? process.env.FIREEMU_APP_CHECK_ADMIN === "1";
|
|
267
|
+
if (!enabled) return unsupported("disabled");
|
|
268
|
+
|
|
269
|
+
const require = createRequire(join(sourceDir, "package.json"));
|
|
270
|
+
let root;
|
|
271
|
+
let version;
|
|
272
|
+
try {
|
|
273
|
+
root = packageRootOf(require.resolve("firebase-admin"));
|
|
274
|
+
version = String(JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version ?? "");
|
|
275
|
+
} catch {
|
|
276
|
+
return unsupported("not-installed");
|
|
277
|
+
}
|
|
278
|
+
const major = Number.parseInt(version.split(".")[0], 10);
|
|
279
|
+
if (!SUPPORTED_MAJORS.includes(major)) {
|
|
280
|
+
return unsupported(
|
|
281
|
+
`firebase-admin ${version} is outside the supported range (major ${SUPPORTED_MAJORS.join(", ")})`,
|
|
282
|
+
version,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
let modules;
|
|
286
|
+
try {
|
|
287
|
+
// By absolute path on purpose: these are internal modules the package's `exports` map does
|
|
288
|
+
// not publish, and the realpath is the module instance the SDK itself holds.
|
|
289
|
+
modules = loadModules(require, root);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return unsupported(`firebase-admin ${version} has no App Check internals: ${e?.message ?? e}`, version);
|
|
292
|
+
}
|
|
293
|
+
const { verifier, jwt, client, utils } = modules;
|
|
294
|
+
const Verifier = verifier.AppCheckTokenVerifier;
|
|
295
|
+
const Client = client.AppCheckApiClient;
|
|
296
|
+
const probes = [
|
|
297
|
+
[Verifier?.prototype, "verifySignature"],
|
|
298
|
+
[Verifier?.prototype, "mapJwtErrorToAppCheckError"],
|
|
299
|
+
[Verifier?.prototype, "verifyContent"],
|
|
300
|
+
[Verifier?.prototype, "safeDecode"],
|
|
301
|
+
[Verifier?.prototype, "verifyToken"],
|
|
302
|
+
[Client?.prototype, "verifyReplayProtection"],
|
|
303
|
+
];
|
|
304
|
+
for (const [target, name] of probes) {
|
|
305
|
+
if (!isPatchable(target, name)) {
|
|
306
|
+
return unsupported(`firebase-admin ${version} has no patchable ${name}`, version);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const codes = jwt.JwtErrorCode;
|
|
310
|
+
if (
|
|
311
|
+
typeof jwt.JwtError !== "function" ||
|
|
312
|
+
typeof utils.findProjectId !== "function" ||
|
|
313
|
+
!codes ||
|
|
314
|
+
["NO_MATCHING_KID", "INVALID_SIGNATURE", "TOKEN_EXPIRED", "INVALID_ARGUMENT", "KEY_FETCH_ERROR"].some(
|
|
315
|
+
(name) => typeof codes[name] !== "string",
|
|
316
|
+
)
|
|
317
|
+
) {
|
|
318
|
+
return unsupported(`firebase-admin ${version} no longer defines the JWT errors this instrumentation maps to`, version);
|
|
319
|
+
}
|
|
320
|
+
const makeError = errorFactory(modules);
|
|
321
|
+
if (!makeError) {
|
|
322
|
+
return unsupported(`firebase-admin ${version} constructs FirebaseAppCheckError in a shape this instrumentation does not know`, version);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const originals = {
|
|
326
|
+
verifySignature: Verifier.prototype.verifySignature,
|
|
327
|
+
verifyReplayProtection: Client.prototype.verifyReplayProtection,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/// The error a verdict becomes, thrown through the SDK's own mapper.
|
|
331
|
+
function failure(self, verdict) {
|
|
332
|
+
const reason = Object.hasOwn(REASONS, verdict.reason) ? REASONS[verdict.reason] : undefined;
|
|
333
|
+
if (reason) {
|
|
334
|
+
return self.mapJwtErrorToAppCheckError(new jwt.JwtError(codes[reason.code], reason.message));
|
|
335
|
+
}
|
|
336
|
+
const why =
|
|
337
|
+
verdict.reason === "timeout"
|
|
338
|
+
? "the daemon did not answer in time"
|
|
339
|
+
: verdict.reason === "disposed"
|
|
340
|
+
? "the Functions runner is shutting down"
|
|
341
|
+
: "the daemon cannot verify App Check tokens";
|
|
342
|
+
return self.mapJwtErrorToAppCheckError(
|
|
343
|
+
new jwt.JwtError(codes.KEY_FETCH_ERROR, unavailableMessage(why)),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function verifySignature(jwtToken) {
|
|
348
|
+
const self = this;
|
|
349
|
+
return utils
|
|
350
|
+
.findProjectId(self.app)
|
|
351
|
+
.then((projectId) => channel.ask(projectId, jwtToken))
|
|
352
|
+
.then((verdict) => {
|
|
353
|
+
if (verdict?.ok === true) return undefined;
|
|
354
|
+
throw failure(self, verdict ?? { ok: false, reason: "unavailable" });
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
function verifyReplayProtection() {
|
|
358
|
+
return Promise.reject(
|
|
359
|
+
makeError(
|
|
360
|
+
"unimplemented",
|
|
361
|
+
"verifyToken({ consume: true }) is not supported by fireemu: limited-use App Check " +
|
|
362
|
+
"token consumption (APPCHECK-REPLAY-1) is unimplemented, and the production App Check " +
|
|
363
|
+
"API is never called.",
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
Verifier.prototype.verifySignature = verifySignature;
|
|
369
|
+
Client.prototype.verifyReplayProtection = verifyReplayProtection;
|
|
370
|
+
const restore = () => {
|
|
371
|
+
Verifier.prototype.verifySignature = originals.verifySignature;
|
|
372
|
+
Client.prototype.verifyReplayProtection = originals.verifyReplayProtection;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const handle = {
|
|
376
|
+
supported: true,
|
|
377
|
+
instrumentation: "ok",
|
|
378
|
+
version,
|
|
379
|
+
report: { instrumentation: "ok", firebaseAdminVersion: version },
|
|
380
|
+
restore,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// Startup self-test. Run the SDK's real `verifyToken` on a probe app and require that the
|
|
384
|
+
// patched step was the one consulted, exactly once, and that it failed the way a verdict
|
|
385
|
+
// does. A supported-range release that routes verification around `verifySignature` fails
|
|
386
|
+
// here instead of silently verifying nothing.
|
|
387
|
+
const app = { options: { projectId: SELF_TEST_PROJECT, httpAgent: new RefusingAgent() } };
|
|
388
|
+
const before = channel.asked;
|
|
389
|
+
channel.setDeadline(
|
|
390
|
+
Math.min(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.selfTestTimeoutMs ?? SELF_TEST_TIMEOUT_MS),
|
|
391
|
+
);
|
|
392
|
+
let outcome;
|
|
393
|
+
try {
|
|
394
|
+
const probe = new Verifier(app);
|
|
395
|
+
const expected = probe.mapJwtErrorToAppCheckError(
|
|
396
|
+
new jwt.JwtError(codes.NO_MATCHING_KID, REASONS["no-matching-kid"].message),
|
|
397
|
+
);
|
|
398
|
+
outcome = await probe.verifyToken(probeToken()).then(
|
|
399
|
+
() => ({ kind: "accepted" }),
|
|
400
|
+
(error) => ({ kind: "rejected", error, expected }),
|
|
401
|
+
);
|
|
402
|
+
} catch (e) {
|
|
403
|
+
outcome = { kind: "threw", error: e };
|
|
404
|
+
}
|
|
405
|
+
channel.setDeadline(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
406
|
+
const consultedOnce = channel.asked - before === 1;
|
|
407
|
+
if (outcome.kind === "accepted" || !consultedOnce) {
|
|
408
|
+
channel.dispose();
|
|
409
|
+
restore();
|
|
410
|
+
return unsupported(
|
|
411
|
+
`the startup self-test did not reach the instrumented signature step in firebase-admin ${version}`,
|
|
412
|
+
version,
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
const answeredByDaemon =
|
|
416
|
+
outcome.kind === "rejected" &&
|
|
417
|
+
outcome.error?.code === outcome.expected.code &&
|
|
418
|
+
outcome.error?.message === outcome.expected.message;
|
|
419
|
+
if (!answeredByDaemon) {
|
|
420
|
+
// The step was reached but the daemon did not answer the probe the way it answers: nothing
|
|
421
|
+
// stays patched. The stock SDK then rejects fireemu tokens, and a daemon that recovers later
|
|
422
|
+
// cannot start accepting tokens through a half-trusted channel.
|
|
423
|
+
channel.dispose();
|
|
424
|
+
restore();
|
|
425
|
+
return unsupported("daemon-unavailable", version);
|
|
426
|
+
}
|
|
427
|
+
return handle;
|
|
428
|
+
}
|
|
@@ -20,6 +20,7 @@ import { url as inspectorUrl } from "node:inspector";
|
|
|
20
20
|
|
|
21
21
|
import { createRuntimeClock } from "./clock.mjs";
|
|
22
22
|
import { instrumentCallables } from "./callable-app-check.mjs";
|
|
23
|
+
import { createAdminChannel, instrumentAdminAppCheck } from "./admin-app-check.mjs";
|
|
23
24
|
import { removeAppCheckToken, withholdsAppCheckToken } from "./app-check-token.mjs";
|
|
24
25
|
import { blockingFailure } from "./blocking-error.mjs";
|
|
25
26
|
import { blockingEvent, loadIdentityParsers, portedIdentityParsers } from "./blocking-event.mjs";
|
|
@@ -1637,14 +1638,23 @@ async function main() {
|
|
|
1637
1638
|
let functions;
|
|
1638
1639
|
let manifest;
|
|
1639
1640
|
let runnerReady = false;
|
|
1641
|
+
// The Admin SDK App Check instrumentation (installed before any user code loads) asks the
|
|
1642
|
+
// daemon through this channel; the daemon's answers arrive on the stdin reader below.
|
|
1643
|
+
const adminChannel = createAdminChannel(send);
|
|
1644
|
+
let adminAppCheck = null;
|
|
1640
1645
|
const activeInvocations = new InvocationBudget();
|
|
1641
1646
|
// Read before loading user code, which may await indefinitely after spawning children.
|
|
1642
1647
|
startupTrace('stdin frame reader start');
|
|
1643
1648
|
readFrames(
|
|
1644
1649
|
process.stdin,
|
|
1645
1650
|
(msg, payloadBytes) => {
|
|
1651
|
+
if (msg.type === "appCheck:verdict") {
|
|
1652
|
+
adminChannel.deliver(msg);
|
|
1653
|
+
return;
|
|
1654
|
+
}
|
|
1646
1655
|
if (msg.type === "shutdown") {
|
|
1647
1656
|
explicitShutdown = true;
|
|
1657
|
+
adminChannel.dispose();
|
|
1648
1658
|
// Volta must observe Node exit before its shim is killed, or the daemon
|
|
1649
1659
|
// can finish shutdown while Node still owns an inspector listener.
|
|
1650
1660
|
deferCleanShutdownGroupCleanup();
|
|
@@ -1697,6 +1707,7 @@ async function main() {
|
|
|
1697
1707
|
.finally(release);
|
|
1698
1708
|
},
|
|
1699
1709
|
() => {
|
|
1710
|
+
adminChannel.dispose();
|
|
1700
1711
|
cleanupProcessGroupOnInputClose();
|
|
1701
1712
|
finishOutput();
|
|
1702
1713
|
},
|
|
@@ -1713,6 +1724,9 @@ async function main() {
|
|
|
1713
1724
|
startupTrace('callable instrumentation start');
|
|
1714
1725
|
const instrumentation = instrumentCallables(sourceDir);
|
|
1715
1726
|
startupTrace('callable instrumentation complete');
|
|
1727
|
+
startupTrace('admin app check instrumentation start');
|
|
1728
|
+
adminAppCheck = await instrumentAdminAppCheck(sourceDir, adminChannel);
|
|
1729
|
+
startupTrace('admin app check instrumentation complete');
|
|
1716
1730
|
startupTrace('parameter initialization start');
|
|
1717
1731
|
resolveWhenDeclared(declaredParamsOfCodebase(), process.env);
|
|
1718
1732
|
startupTrace('parameter initialization complete');
|
|
@@ -1834,6 +1848,7 @@ async function main() {
|
|
|
1834
1848
|
authHeaders: instrumentation.authHeaders,
|
|
1835
1849
|
graphs: instrumentation.graphs,
|
|
1836
1850
|
},
|
|
1851
|
+
adminAppCheck: adminAppCheck.report,
|
|
1837
1852
|
});
|
|
1838
1853
|
startupTrace('hello written');
|
|
1839
1854
|
runnerReady = true;
|
|
@@ -44,6 +44,14 @@ function decodeMessage(payload) {
|
|
|
44
44
|
decimal(message.instantNanos, true) && decimal(message.elapsedNanos) && decimal(message.revision)) return message;
|
|
45
45
|
if (message.type === 'clock:runDue' && text(message.invocationId) &&
|
|
46
46
|
(message.budget === undefined || (Number.isSafeInteger(message.budget) && message.budget >= 1 && message.budget <= 10000))) return message;
|
|
47
|
+
// The daemon's answer to an Admin SDK App Check question (tools/runner-node/admin-app-check.mjs).
|
|
48
|
+
if (message.type === 'appCheck:verdict') {
|
|
49
|
+
if (!text(message.requestId) || message.requestId.length > 128 || typeof message.ok !== 'boolean' ||
|
|
50
|
+
(message.reason !== undefined && (typeof message.reason !== 'string' || message.reason.length > 64))) {
|
|
51
|
+
throw new ProtocolError('message fields');
|
|
52
|
+
}
|
|
53
|
+
return message;
|
|
54
|
+
}
|
|
47
55
|
if (message.type !== 'invoke' || !text(message.invocationId) ||
|
|
48
56
|
!text(message.function) || !text(message.trigger) || !object(message.event) ||
|
|
49
57
|
(message.entryPoint !== undefined && !text(message.entryPoint))) {
|