@vellumai/vellum-gateway 0.7.0 → 0.7.2
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/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect, afterAll } from "bun:test";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Proves that runtime proxy passthrough routes stay disabled by default while
|
|
5
|
-
* dedicated gateway routes still work. Uses the same routing logic as
|
|
6
|
-
* src/index.ts so that changes to production defaults are caught here.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
// Minimal env for loadConfig
|
|
10
|
-
const env: Record<string, string> = {
|
|
11
|
-
TELEGRAM_BOT_TOKEN: "test-tok",
|
|
12
|
-
TELEGRAM_WEBHOOK_SECRET: "wh-sec",
|
|
13
|
-
GATEWAY_PORT: "7830",
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// Save and set env
|
|
17
|
-
const saved: Record<string, string | undefined> = {};
|
|
18
|
-
for (const [k, v] of Object.entries(env)) {
|
|
19
|
-
saved[k] = process.env[k];
|
|
20
|
-
process.env[k] = v;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
// Dynamically import to pick up env
|
|
24
|
-
const { loadConfig } = await import("../config.js");
|
|
25
|
-
const { createTelegramWebhookHandler } =
|
|
26
|
-
await import("../http/routes/telegram-webhook.js");
|
|
27
|
-
const { createRuntimeProxyHandler } =
|
|
28
|
-
await import("../http/routes/runtime-proxy.js");
|
|
29
|
-
const { createRuntimeHealthProxyHandler } =
|
|
30
|
-
await import("../http/routes/runtime-health-proxy.js");
|
|
31
|
-
|
|
32
|
-
const config = await loadConfig();
|
|
33
|
-
|
|
34
|
-
const { handler: handleTelegramWebhook } = createTelegramWebhookHandler(config);
|
|
35
|
-
const runtimeHealthProxy = createRuntimeHealthProxyHandler(config);
|
|
36
|
-
|
|
37
|
-
// Mirror production routing from src/index.ts: only create proxy when enabled
|
|
38
|
-
const handleRuntimeProxy = config.runtimeProxyEnabled
|
|
39
|
-
? createRuntimeProxyHandler(config)
|
|
40
|
-
: null;
|
|
41
|
-
|
|
42
|
-
async function handleRequest(req: Request): Promise<Response> {
|
|
43
|
-
const url = new URL(req.url);
|
|
44
|
-
|
|
45
|
-
if (url.pathname === "/healthz") {
|
|
46
|
-
return Response.json({ status: "ok" });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
if (url.pathname === "/readyz") {
|
|
50
|
-
return Response.json({ status: "ok" });
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (url.pathname === "/webhooks/telegram") {
|
|
54
|
-
return handleTelegramWebhook(req);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (url.pathname === "/v1/health" && req.method === "GET") {
|
|
58
|
-
const authHeader = req.headers.get("authorization");
|
|
59
|
-
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
|
|
60
|
-
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
61
|
-
}
|
|
62
|
-
return runtimeHealthProxy.handleRuntimeHealth(req);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
if (handleRuntimeProxy) {
|
|
66
|
-
return handleRuntimeProxy(req);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
return Response.json({ error: "Not found" }, { status: 404 });
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
afterAll(() => {
|
|
73
|
-
for (const [k, v] of Object.entries(saved)) {
|
|
74
|
-
if (v === undefined) delete process.env[k];
|
|
75
|
-
else process.env[k] = v;
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("Telegram-only default: runtime proxy is disabled by default", () => {
|
|
80
|
-
test("GET / returns 404", async () => {
|
|
81
|
-
const res = await handleRequest(new Request("http://gateway.test/"));
|
|
82
|
-
expect(res.status).toBe(404);
|
|
83
|
-
const body = await res.json();
|
|
84
|
-
expect(body.error).toBe("Not found");
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("GET /v1/health returns 401 without bearer auth", async () => {
|
|
88
|
-
const res = await handleRequest(
|
|
89
|
-
new Request("http://gateway.test/v1/health"),
|
|
90
|
-
);
|
|
91
|
-
expect(res.status).toBe(401);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("POST /v1/assistants/foo/chat returns 404", async () => {
|
|
95
|
-
const res = await handleRequest(
|
|
96
|
-
new Request("http://gateway.test/v1/assistants/foo/chat", {
|
|
97
|
-
method: "POST",
|
|
98
|
-
body: "{}",
|
|
99
|
-
headers: { "content-type": "application/json" },
|
|
100
|
-
}),
|
|
101
|
-
);
|
|
102
|
-
expect(res.status).toBe(404);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("GET /random-path returns 404", async () => {
|
|
106
|
-
const res = await handleRequest(
|
|
107
|
-
new Request("http://gateway.test/random-path"),
|
|
108
|
-
);
|
|
109
|
-
expect(res.status).toBe(404);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("config.runtimeProxyEnabled is false by default", () => {
|
|
113
|
-
expect(config.runtimeProxyEnabled).toBe(false);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test("runtime proxy handler is not created when proxy is disabled", () => {
|
|
117
|
-
expect(handleRuntimeProxy).toBeNull();
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test("GET /healthz returns 200 (infrastructure routes still work)", async () => {
|
|
121
|
-
const res = await handleRequest(new Request("http://gateway.test/healthz"));
|
|
122
|
-
expect(res.status).toBe(200);
|
|
123
|
-
const body = await res.json();
|
|
124
|
-
expect(body.status).toBe("ok");
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("GET /readyz returns 200 (infrastructure routes still work)", async () => {
|
|
128
|
-
const res = await handleRequest(new Request("http://gateway.test/readyz"));
|
|
129
|
-
expect(res.status).toBe(200);
|
|
130
|
-
const body = await res.json();
|
|
131
|
-
expect(body.status).toBe("ok");
|
|
132
|
-
});
|
|
133
|
-
});
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Capability token minting and verification for scoped, short-lived tokens
|
|
3
|
-
* issued to the chrome extension (and other thin clients) so they can submit
|
|
4
|
-
* results back to the runtime without a full guardian-bound JWT.
|
|
5
|
-
*
|
|
6
|
-
* Design:
|
|
7
|
-
* - Tokens are HMAC-SHA256 signed over a JSON claims payload.
|
|
8
|
-
* - Claims include a bound capability, guardian id, nonce, and expiry.
|
|
9
|
-
* - Signing uses a long-lived random secret persisted to
|
|
10
|
-
* GATEWAY_SECURITY_DIR with 0600 permissions.
|
|
11
|
-
* - The secret is generated once on first launch and reused across
|
|
12
|
-
* subsequent restarts so previously-minted tokens still verify.
|
|
13
|
-
*
|
|
14
|
-
* The encoded token format is `<base64url(payload)>.<base64url(sig)>`.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
18
|
-
import {
|
|
19
|
-
chmodSync,
|
|
20
|
-
existsSync,
|
|
21
|
-
mkdirSync,
|
|
22
|
-
readFileSync,
|
|
23
|
-
renameSync,
|
|
24
|
-
writeFileSync,
|
|
25
|
-
} from "node:fs";
|
|
26
|
-
import { dirname, join } from "node:path";
|
|
27
|
-
|
|
28
|
-
import { getLogger } from "../logger.js";
|
|
29
|
-
import { getGatewaySecurityDir } from "../paths.js";
|
|
30
|
-
|
|
31
|
-
const log = getLogger("capability-tokens");
|
|
32
|
-
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// Types
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
/** Capability identifiers that can be bound to a capability token. */
|
|
38
|
-
export type Capability = "host_browser_command";
|
|
39
|
-
|
|
40
|
-
/** Claims encoded in the signed payload. */
|
|
41
|
-
export interface CapabilityClaims {
|
|
42
|
-
capability: Capability;
|
|
43
|
-
guardianId: string;
|
|
44
|
-
/** 16-byte random nonce, hex-encoded. Prevents replay across fresh mints. */
|
|
45
|
-
nonce: string;
|
|
46
|
-
/** ms-since-epoch expiry. */
|
|
47
|
-
expiresAt: number;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** A freshly-minted capability token and its absolute expiry. */
|
|
51
|
-
export interface CapabilityToken {
|
|
52
|
-
token: string;
|
|
53
|
-
expiresAt: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Secret lifecycle
|
|
58
|
-
// ---------------------------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
let _secret: Buffer | undefined;
|
|
61
|
-
|
|
62
|
-
const CAPABILITY_TOKEN_SECRET_FILENAME = "capability-token-secret";
|
|
63
|
-
|
|
64
|
-
function getSecretPath(): string {
|
|
65
|
-
return join(getGatewaySecurityDir(), CAPABILITY_TOKEN_SECRET_FILENAME);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Write `secret` to `keyPath` atomically with mode 0o600.
|
|
70
|
-
*/
|
|
71
|
-
function writeSecretAtomic(keyPath: string, secret: Buffer): void {
|
|
72
|
-
const dir = dirname(keyPath);
|
|
73
|
-
if (!existsSync(dir)) {
|
|
74
|
-
mkdirSync(dir, { recursive: true });
|
|
75
|
-
}
|
|
76
|
-
const tmpPath = `${keyPath}.tmp.${process.pid}`;
|
|
77
|
-
writeFileSync(tmpPath, secret, { mode: 0o600 });
|
|
78
|
-
renameSync(tmpPath, keyPath);
|
|
79
|
-
try {
|
|
80
|
-
chmodSync(keyPath, 0o600);
|
|
81
|
-
} catch (err) {
|
|
82
|
-
log.warn(
|
|
83
|
-
{ err, keyPath },
|
|
84
|
-
"Failed to chmod capability token secret after write",
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Load the capability-token secret from disk or generate and persist a new
|
|
91
|
-
* one. Atomically writes with mode 0o600 so the secret is not readable by
|
|
92
|
-
* other users on the same host.
|
|
93
|
-
*/
|
|
94
|
-
export function loadOrCreateCapabilityTokenSecret(): Buffer {
|
|
95
|
-
const keyPath = getSecretPath();
|
|
96
|
-
if (existsSync(keyPath)) {
|
|
97
|
-
try {
|
|
98
|
-
const raw = readFileSync(keyPath);
|
|
99
|
-
if (raw.length === 32) {
|
|
100
|
-
return raw;
|
|
101
|
-
}
|
|
102
|
-
log.warn(
|
|
103
|
-
{ keyPath, length: raw.length },
|
|
104
|
-
"capability token secret has unexpected length — regenerating",
|
|
105
|
-
);
|
|
106
|
-
} catch (err) {
|
|
107
|
-
log.warn(
|
|
108
|
-
{ err, keyPath },
|
|
109
|
-
"Failed to read capability token secret — regenerating",
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const fresh = randomBytes(32);
|
|
115
|
-
writeSecretAtomic(keyPath, fresh);
|
|
116
|
-
log.info("Capability token secret generated and persisted");
|
|
117
|
-
return fresh;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Initialize the module-level secret. Called once at gateway startup.
|
|
122
|
-
*/
|
|
123
|
-
export function initCapabilityTokenSecret(secret: Buffer): void {
|
|
124
|
-
if (secret.length !== 32) {
|
|
125
|
-
throw new Error(
|
|
126
|
-
`capability token secret must be 32 bytes, got ${secret.length}`,
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
_secret = secret;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Test-only helper to inject a deterministic secret.
|
|
134
|
-
*/
|
|
135
|
-
export function setCapabilityTokenSecretForTests(secret: Buffer): void {
|
|
136
|
-
_secret = secret;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Reset the cached secret. Test-only.
|
|
141
|
-
*/
|
|
142
|
-
export function resetCapabilityTokenSecretForTests(): void {
|
|
143
|
-
_secret = undefined;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
function getSecret(): Buffer {
|
|
147
|
-
if (_secret) return _secret;
|
|
148
|
-
if (process.env.NODE_ENV === "test") {
|
|
149
|
-
_secret = randomBytes(32);
|
|
150
|
-
return _secret;
|
|
151
|
-
}
|
|
152
|
-
_secret = loadOrCreateCapabilityTokenSecret();
|
|
153
|
-
return _secret;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ---------------------------------------------------------------------------
|
|
157
|
-
// Mint / verify
|
|
158
|
-
// ---------------------------------------------------------------------------
|
|
159
|
-
|
|
160
|
-
const CAPABILITY_TOKEN_DEFAULT_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
161
|
-
|
|
162
|
-
function base64urlEncode(buf: Buffer): string {
|
|
163
|
-
return buf
|
|
164
|
-
.toString("base64")
|
|
165
|
-
.replace(/\+/g, "-")
|
|
166
|
-
.replace(/\//g, "_")
|
|
167
|
-
.replace(/=+$/, "");
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function base64urlDecode(s: string): Buffer {
|
|
171
|
-
const pad = s.length % 4 === 0 ? 0 : 4 - (s.length % 4);
|
|
172
|
-
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(pad);
|
|
173
|
-
return Buffer.from(b64, "base64");
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function sign(payload: string, secret: Buffer): string {
|
|
177
|
-
return base64urlEncode(createHmac("sha256", secret).update(payload).digest());
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Mint a capability token bound to the `host_browser_command` capability
|
|
182
|
-
* for the given guardian id. Default TTL is 30 minutes.
|
|
183
|
-
*/
|
|
184
|
-
export function mintHostBrowserCapability(
|
|
185
|
-
guardianId: string,
|
|
186
|
-
ttlMs: number = CAPABILITY_TOKEN_DEFAULT_TTL_MS,
|
|
187
|
-
): CapabilityToken {
|
|
188
|
-
const expiresAt = Date.now() + ttlMs;
|
|
189
|
-
const nonce = randomBytes(16).toString("hex");
|
|
190
|
-
const claims: CapabilityClaims = {
|
|
191
|
-
capability: "host_browser_command",
|
|
192
|
-
guardianId,
|
|
193
|
-
nonce,
|
|
194
|
-
expiresAt,
|
|
195
|
-
};
|
|
196
|
-
const payload = base64urlEncode(Buffer.from(JSON.stringify(claims), "utf8"));
|
|
197
|
-
const sig = sign(payload, getSecret());
|
|
198
|
-
return { token: `${payload}.${sig}`, expiresAt };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Verify a capability token minted by `mintHostBrowserCapability`.
|
|
203
|
-
*
|
|
204
|
-
* Returns the decoded claims on success or null if the signature is
|
|
205
|
-
* invalid, the payload is malformed, the token has expired, or the bound
|
|
206
|
-
* capability is not `host_browser_command`.
|
|
207
|
-
*
|
|
208
|
-
* Signature comparison uses `timingSafeEqual` to avoid leaking the secret
|
|
209
|
-
* through timing side channels.
|
|
210
|
-
*/
|
|
211
|
-
export function verifyHostBrowserCapability(
|
|
212
|
-
token: string,
|
|
213
|
-
): CapabilityClaims | null {
|
|
214
|
-
if (typeof token !== "string") return null;
|
|
215
|
-
const dot = token.indexOf(".");
|
|
216
|
-
if (dot < 0) return null;
|
|
217
|
-
const payload = token.slice(0, dot);
|
|
218
|
-
const sig = token.slice(dot + 1);
|
|
219
|
-
if (!payload || !sig) return null;
|
|
220
|
-
|
|
221
|
-
const expected = sign(payload, getSecret());
|
|
222
|
-
const a = Buffer.from(sig, "utf8");
|
|
223
|
-
const b = Buffer.from(expected, "utf8");
|
|
224
|
-
if (a.length !== b.length) return null;
|
|
225
|
-
if (!timingSafeEqual(a, b)) return null;
|
|
226
|
-
|
|
227
|
-
let claims: CapabilityClaims;
|
|
228
|
-
try {
|
|
229
|
-
claims = JSON.parse(
|
|
230
|
-
base64urlDecode(payload).toString("utf8"),
|
|
231
|
-
) as CapabilityClaims;
|
|
232
|
-
} catch {
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (!claims || typeof claims !== "object") return null;
|
|
237
|
-
if (claims.capability !== "host_browser_command") return null;
|
|
238
|
-
if (typeof claims.guardianId !== "string" || claims.guardianId.length === 0) {
|
|
239
|
-
return null;
|
|
240
|
-
}
|
|
241
|
-
if (typeof claims.nonce !== "string" || claims.nonce.length === 0) {
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
if (typeof claims.expiresAt !== "number" || claims.expiresAt <= Date.now()) {
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
return claims;
|
|
248
|
-
}
|