@adtl/gateway 0.8.0 → 0.8.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/README.md +53 -2
- package/dist/cli.js +10 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +145 -881
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +37 -9
- package/dist/config.js.map +1 -1
- package/dist/elicitation.d.ts +7 -1
- package/dist/elicitation.d.ts.map +1 -1
- package/dist/elicitation.js +21 -14
- package/dist/elicitation.js.map +1 -1
- package/dist/gateway.d.ts +5 -1
- package/dist/gateway.d.ts.map +1 -1
- package/dist/gateway.js +4 -2
- package/dist/gateway.js.map +1 -1
- package/dist/hooks/claudeCode.d.ts +1 -0
- package/dist/hooks/claudeCode.d.ts.map +1 -1
- package/dist/hooks/claudeCode.js +8 -1
- package/dist/hooks/claudeCode.js.map +1 -1
- package/dist/hooks/run.d.ts.map +1 -1
- package/dist/hooks/run.js +2 -1
- package/dist/hooks/run.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/invoker.d.ts +2 -0
- package/dist/invoker.d.ts.map +1 -1
- package/dist/invoker.js +6 -0
- package/dist/invoker.js.map +1 -1
- package/dist/openfga.d.ts +13 -0
- package/dist/openfga.d.ts.map +1 -0
- package/dist/openfga.js +101 -0
- package/dist/openfga.js.map +1 -0
- package/dist/startup.d.ts +1 -1
- package/dist/startup.d.ts.map +1 -1
- package/dist/startup.js +27 -6
- package/dist/startup.js.map +1 -1
- package/dist/totp.d.ts +42 -0
- package/dist/totp.d.ts.map +1 -0
- package/dist/totp.js +176 -0
- package/dist/totp.js.map +1 -0
- package/dist/totpCli.d.ts +11 -0
- package/dist/totpCli.d.ts.map +1 -0
- package/dist/totpCli.js +46 -0
- package/dist/totpCli.js.map +1 -0
- package/package.json +8 -7
- package/playground/server.mjs +13 -3
- package/src/cli.ts +10 -1
- package/src/config.ts +42 -9
- package/src/elicitation.ts +35 -17
- package/src/gateway.ts +7 -2
- package/src/hooks/claudeCode.ts +9 -2
- package/src/hooks/run.ts +2 -1
- package/src/index.ts +3 -0
- package/src/invoker.ts +7 -0
- package/src/openfga.ts +101 -0
- package/src/startup.ts +27 -6
- package/src/totp.ts +191 -0
- package/src/totpCli.ts +52 -0
- package/src/types/qrcode-terminal.d.ts +4 -0
package/src/hooks/claudeCode.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
*
|
|
27
27
|
* Errors fail closed: a PreToolUse hook that can't decide denies the call.
|
|
28
28
|
*/
|
|
29
|
-
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
29
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
30
30
|
import {
|
|
31
31
|
callHash,
|
|
32
32
|
evaluatePolicy,
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
} from "@adtl/labels";
|
|
42
42
|
import { chunkHashes, valueHashes, type Ledger } from "@adtl/ledger";
|
|
43
43
|
import type { AuditEntry, AuditSink } from "../audit.js";
|
|
44
|
+
import { TOTP_KEYCHAIN_SERVICE } from "../totp.js";
|
|
44
45
|
import type { AgentLinks } from "./agentLinks.js";
|
|
45
46
|
import { CLAUDE_CODE_PRESET, CLAUDE_CODE_SERVER } from "./preset.js";
|
|
46
47
|
|
|
@@ -71,7 +72,7 @@ export interface HookOptions {
|
|
|
71
72
|
/** Sub-agent → parent session links, from SubagentStart. */
|
|
72
73
|
agents?: AgentLinks;
|
|
73
74
|
/** Files the agent must not change: the ADTL config, the session directory and the ledger. */
|
|
74
|
-
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string };
|
|
75
|
+
protect?: { configPath?: string; sessionsDir?: string; ledgerPath?: string; auditPath?: string; signingKeyPath?: string; totpPath?: string };
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
/** What the hook process prints and exits with. */
|
|
@@ -144,6 +145,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
144
145
|
if (protect.signingKeyPath && abs === resolve(protect.signingKeyPath)) {
|
|
145
146
|
return { decision: "block", reason: "The agent may not touch the ADTL signing key" };
|
|
146
147
|
}
|
|
148
|
+
if (protect.totpPath && abs.startsWith(dirname(resolve(protect.totpPath)) + sep + "totp-")) {
|
|
149
|
+
return { decision: "block", reason: "The agent may not touch the ADTL TOTP secret" };
|
|
150
|
+
}
|
|
147
151
|
}
|
|
148
152
|
const command = typeof args.command === "string" ? args.command : undefined;
|
|
149
153
|
if (command !== undefined && ["Bash", "Monitor"].includes(route.tool)) {
|
|
@@ -161,6 +165,9 @@ function protectedTarget(input: HookInput, route: { server: string; tool: string
|
|
|
161
165
|
if (protect.signingKeyPath && command.includes(basename(protect.signingKeyPath))) {
|
|
162
166
|
return { decision: "ask", reason: "This command touches the ADTL signing key" };
|
|
163
167
|
}
|
|
168
|
+
if ([TOTP_KEYCHAIN_SERVICE, "totp-secret", "totp-last-step", protect.totpPath].some((m) => m && command.includes(m))) {
|
|
169
|
+
return { decision: "ask", reason: "This command touches the ADTL TOTP secret" };
|
|
170
|
+
}
|
|
164
171
|
}
|
|
165
172
|
return undefined;
|
|
166
173
|
}
|
package/src/hooks/run.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { Ledger, defaultLedgerPath } from "@adtl/ledger";
|
|
|
13
13
|
import { parseConfigText } from "@adtl/interceptors";
|
|
14
14
|
import { parse as parseYaml } from "yaml";
|
|
15
15
|
import { JsonlAuditLog } from "../audit.js";
|
|
16
|
+
import { totpSecretPath } from "../totp.js";
|
|
16
17
|
import { FileAgentLinks } from "./agentLinks.js";
|
|
17
18
|
import { failure, handleHook, type HookInput, type HookOutcome } from "./claudeCode.js";
|
|
18
19
|
|
|
@@ -52,7 +53,7 @@ export async function runHook(opts: RunHookOptions): Promise<HookOutcome> {
|
|
|
52
53
|
...(opts.gatewayServer ? { gatewayServer: opts.gatewayServer } : {}),
|
|
53
54
|
mode: gw.mode === "audit" ? "audit" : "active",
|
|
54
55
|
...(auditPath ? { audit: new JsonlAuditLog(auditPath) } : {}),
|
|
55
|
-
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env) },
|
|
56
|
+
protect: { configPath: path, sessionsDir, ledgerPath, ...(auditPath ? { auditPath } : {}), signingKeyPath: defaultSigningKeyPath(env), totpPath: totpSecretPath(env) },
|
|
56
57
|
});
|
|
57
58
|
} catch (err) {
|
|
58
59
|
return failure(input.hook_event_name, (err as Error).message);
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,9 @@ export * from "./ledgerCli.js";
|
|
|
13
13
|
export * from "./auditCli.js";
|
|
14
14
|
export * from "./roles.js";
|
|
15
15
|
export * from "./cedar.js";
|
|
16
|
+
export * from "./openfga.js";
|
|
17
|
+
export * from "./totp.js";
|
|
18
|
+
export * from "./totpCli.js";
|
|
16
19
|
export * from "./elicitation.js";
|
|
17
20
|
export * from "./receipt.js";
|
|
18
21
|
export * from "./receiptCli.js";
|
package/src/invoker.ts
CHANGED
|
@@ -160,6 +160,13 @@ export function checkOverrides(defs: InterceptorDefinition[], overrides: Record<
|
|
|
160
160
|
return problems;
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/** Interceptors of a type this invoker doesn't run (e.g. the proposed `sink`). They are never invoked. */
|
|
164
|
+
export function unsupportedInterceptors(bound: BoundInterceptor[]): string[] {
|
|
165
|
+
return bound
|
|
166
|
+
.filter((b) => b.definition.type !== "validation" && b.definition.type !== "mutation")
|
|
167
|
+
.map((b) => `${b.definition.name} (${String(b.definition.type)})`);
|
|
168
|
+
}
|
|
169
|
+
|
|
163
170
|
/** Interceptor names offered by more than one server: each must be invoked on its own server, so names must be unique. */
|
|
164
171
|
export function duplicateInterceptors(bound: BoundInterceptor[]): string[] {
|
|
165
172
|
const servers = new Map<string, string[]>();
|
package/src/openfga.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relationship-based authorization with OpenFGA (`gateway.openfga`):
|
|
3
|
+
*
|
|
4
|
+
* openfga:
|
|
5
|
+
* url: http://localhost:8080
|
|
6
|
+
* storeId: "${FGA_STORE_ID}"
|
|
7
|
+
* modelId: "${FGA_MODEL_ID}" # optional: pin an authorization model
|
|
8
|
+
* token: "${FGA_TOKEN}" # optional: bearer token
|
|
9
|
+
* relation: can_use # optional: the default relation
|
|
10
|
+
* timeoutMs: 2000 # optional
|
|
11
|
+
* tools: # optional: per-resource rules, from the call's arguments
|
|
12
|
+
* github.add_issue_comment: { relation: can_comment, object: "repo:{owner}/{repo}" }
|
|
13
|
+
*
|
|
14
|
+
* Every call becomes one OpenFGA check:
|
|
15
|
+
*
|
|
16
|
+
* user user:<id>, service:<id>, or user:anonymous without an id
|
|
17
|
+
* relation the tool's rule, else `relation`
|
|
18
|
+
* object the tool's rule, else tool:<server>.<tool>, prompt:<server>.<name> or resource:<uri>
|
|
19
|
+
*
|
|
20
|
+
* Ids are percent-encoded, because OpenFGA ids can't hold `:`, `#`, `@` or
|
|
21
|
+
* spaces; ids too long for OpenFGA are replaced by their SHA-256. A rule's
|
|
22
|
+
* `{argument}` must be a string or number, or the call is denied. Tool lists
|
|
23
|
+
* have no arguments, so tools whose object needs them stay listed and are
|
|
24
|
+
* checked when called. An unreachable server or an error denies the call.
|
|
25
|
+
*
|
|
26
|
+
* Works with Auth0 FGA too, by pointing `url` at its API and passing a token.
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from "node:crypto";
|
|
29
|
+
import type { AuthorizationDecision, AuthorizationRequest, Authorizer } from "./authorizer.js";
|
|
30
|
+
import type { OpenFgaConfig } from "./config.js";
|
|
31
|
+
|
|
32
|
+
/** OpenFGA's limit on the id part of an object or user. */
|
|
33
|
+
const MAX_ID = 254;
|
|
34
|
+
|
|
35
|
+
function fgaId(value: string): string {
|
|
36
|
+
const id = encodeURIComponent(value);
|
|
37
|
+
return id.length <= MAX_ID ? id : `sha256-${createHash("sha256").update(value).digest("hex")}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PLACEHOLDER = /\{([A-Za-z0-9_.-]+)\}/g;
|
|
41
|
+
|
|
42
|
+
export class OpenFgaAuthorizer implements Authorizer {
|
|
43
|
+
readonly name = "openfga";
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private readonly cfg: OpenFgaConfig,
|
|
47
|
+
private readonly fetchFn: typeof fetch = fetch,
|
|
48
|
+
) {}
|
|
49
|
+
|
|
50
|
+
private user(req: AuthorizationRequest): string {
|
|
51
|
+
const p = req.principal;
|
|
52
|
+
if (p.type === "anonymous" || !p.id) return "user:anonymous";
|
|
53
|
+
return `${p.type === "service" ? "service" : "user"}:${fgaId(p.id)}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The object of this call, or a reason it can't be built. "listing" means: decide when called. */
|
|
57
|
+
private object(req: AuthorizationRequest, template?: string): { object: string } | { missing: string } | "listing" {
|
|
58
|
+
if (template !== undefined) {
|
|
59
|
+
if (req.arguments === undefined && template.search(PLACEHOLDER) !== -1) return "listing";
|
|
60
|
+
const colon = template.indexOf(":");
|
|
61
|
+
const type = template.slice(0, colon + 1);
|
|
62
|
+
let missing: string | undefined;
|
|
63
|
+
const id = template.slice(colon + 1).replace(PLACEHOLDER, (_, name: string) => {
|
|
64
|
+
const v = req.arguments?.[name];
|
|
65
|
+
if (typeof v !== "string" && typeof v !== "number") {
|
|
66
|
+
missing ??= name;
|
|
67
|
+
return "";
|
|
68
|
+
}
|
|
69
|
+
return String(v);
|
|
70
|
+
});
|
|
71
|
+
return missing !== undefined ? { missing } : { object: `${type}${fgaId(id)}` };
|
|
72
|
+
}
|
|
73
|
+
if (req.tool !== undefined) return { object: `tool:${fgaId(`${req.server}.${req.tool}`)}` };
|
|
74
|
+
if (req.prompt !== undefined) return { object: `prompt:${fgaId(`${req.server}.${req.prompt}`)}` };
|
|
75
|
+
return { object: `resource:${fgaId(req.resource ?? "")}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async check(req: AuthorizationRequest): Promise<AuthorizationDecision> {
|
|
79
|
+
const rule = req.tool !== undefined ? this.cfg.tools[`${req.server}.${req.tool}`] : undefined;
|
|
80
|
+
const target = this.object(req, rule?.object);
|
|
81
|
+
if (target === "listing") return { allow: true };
|
|
82
|
+
const what = req.tool ?? req.prompt ?? req.resource;
|
|
83
|
+
if ("missing" in target) return { allow: false, reason: `openfga: ${what} has no usable argument "${target.missing}"` };
|
|
84
|
+
|
|
85
|
+
const user = this.user(req);
|
|
86
|
+
const relation = rule?.relation ?? this.cfg.relation;
|
|
87
|
+
const res = await this.fetchFn(`${this.cfg.url.replace(/\/+$/, "")}/stores/${encodeURIComponent(this.cfg.storeId)}/check`, {
|
|
88
|
+
method: "POST",
|
|
89
|
+
headers: { "content-type": "application/json", ...(this.cfg.token ? { authorization: `Bearer ${this.cfg.token}` } : {}) },
|
|
90
|
+
body: JSON.stringify({
|
|
91
|
+
tuple_key: { user, relation, object: target.object },
|
|
92
|
+
...(this.cfg.modelId ? { authorization_model_id: this.cfg.modelId } : {}),
|
|
93
|
+
}),
|
|
94
|
+
signal: AbortSignal.timeout(this.cfg.timeoutMs),
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) throw new Error(`OpenFGA answered ${res.status}`);
|
|
97
|
+
const body = (await res.json()) as { allowed?: unknown };
|
|
98
|
+
if (body.allowed === true) return { allow: true };
|
|
99
|
+
return { allow: false, reason: `OpenFGA: ${user} has no ${relation} on ${target.object}` };
|
|
100
|
+
}
|
|
101
|
+
}
|
package/src/startup.ts
CHANGED
|
@@ -28,7 +28,9 @@ import {
|
|
|
28
28
|
} from "./config.js";
|
|
29
29
|
import { EmailNotifier } from "./email.js";
|
|
30
30
|
import { AdtlGateway, GATEWAY_VERSION, type GatewayLogger } from "./gateway.js";
|
|
31
|
-
import { InterceptorInvoker, checkOverrides, duplicateInterceptors, mutatorsAfter } from "./invoker.js";
|
|
31
|
+
import { InterceptorInvoker, checkOverrides, duplicateInterceptors, mutatorsAfter, unsupportedInterceptors } from "./invoker.js";
|
|
32
|
+
import { OpenFgaAuthorizer } from "./openfga.js";
|
|
33
|
+
import { loadTotpVerifier } from "./totp.js";
|
|
32
34
|
import { NtfyNotifier } from "./ntfy.js";
|
|
33
35
|
import { RoleAuthorizer } from "./roles.js";
|
|
34
36
|
|
|
@@ -124,12 +126,13 @@ export function createApprovals(cfg: GatewayConfig, env: NodeJS.ProcessEnv, log:
|
|
|
124
126
|
const approverChannels = new Map<string, Notifier[]>(
|
|
125
127
|
Object.entries(a.approvers ?? {}).map(([id, c]) => [id, [new NtfyNotifier({ ...c.ntfy, name: `ntfy:${id}` })]]),
|
|
126
128
|
);
|
|
127
|
-
if (notifiers.length === 0 && approverChannels.size === 0 && !a.elicitation) return undefined;
|
|
128
|
-
if (!notifiers.some((n) => n.listen) && approverChannels.size === 0 && !a.elicitation) {
|
|
129
|
+
if (notifiers.length === 0 && approverChannels.size === 0 && !a.elicitation && !a.totp) return undefined;
|
|
130
|
+
if (!notifiers.some((n) => n.listen) && approverChannels.size === 0 && !a.elicitation && !a.totp) {
|
|
129
131
|
log.warn('no approval channel can carry answers (configure gateway.approvals.ntfy); "ask" still blocks');
|
|
130
132
|
}
|
|
131
133
|
const approvers = approverChannels.size ? `; approvers: ${[...approverChannels.keys()].join(", ")}` : "";
|
|
132
|
-
const
|
|
134
|
+
const inClient = a.totp ? ["authenticator code (if the client supports elicitation)"] : a.elicitation ? ["elicitation (if the client supports it)"] : [];
|
|
135
|
+
const shared = [...notifiers.map((n) => n.name), ...inClient];
|
|
133
136
|
log.info(`approvals: ${shared.join(", ") || "none shared"}${approvers} (timeout ${a.timeoutSeconds}s)`);
|
|
134
137
|
return new ApprovalBroker({ notifiers, approverChannels, timeoutMs: a.timeoutSeconds * 1000, log });
|
|
135
138
|
}
|
|
@@ -175,7 +178,7 @@ export function gatewayPrincipal(cfg: GatewayConfig, env: NodeJS.ProcessEnv): Pr
|
|
|
175
178
|
* The configured authorizers, in the order they are asked: roles, then cedar.
|
|
176
179
|
* Cedar files are read and parsed here, so a bad policy fails startup.
|
|
177
180
|
*/
|
|
178
|
-
export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): Promise<Authorizer[]> {
|
|
181
|
+
export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string, env: NodeJS.ProcessEnv = process.env): Promise<Authorizer[]> {
|
|
179
182
|
const out: Authorizer[] = [];
|
|
180
183
|
if (cfg.roles) out.push(new RoleAuthorizer(cfg.roles));
|
|
181
184
|
if (cfg.cedar) {
|
|
@@ -185,6 +188,19 @@ export async function gatewayAuthorizers(cfg: GatewayConfig, baseDir: string): P
|
|
|
185
188
|
throw new GatewayConfigError((err as Error).message);
|
|
186
189
|
}
|
|
187
190
|
}
|
|
191
|
+
if (cfg.openfga) {
|
|
192
|
+
const f = cfg.openfga;
|
|
193
|
+
const expand = (v: string, key: string) => expandEnv(v, env, `gateway.openfga.${key}`);
|
|
194
|
+
out.push(
|
|
195
|
+
new OpenFgaAuthorizer({
|
|
196
|
+
...f,
|
|
197
|
+
url: expand(f.url, "url"),
|
|
198
|
+
storeId: expand(f.storeId, "storeId"),
|
|
199
|
+
...(f.modelId !== undefined ? { modelId: expand(f.modelId, "modelId") } : {}),
|
|
200
|
+
...(f.token !== undefined ? { token: expand(f.token, "token") } : {}),
|
|
201
|
+
}),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
188
204
|
return out;
|
|
189
205
|
}
|
|
190
206
|
|
|
@@ -222,7 +238,7 @@ export async function startGateway(
|
|
|
222
238
|
// Config problems that need no process: fail before starting any.
|
|
223
239
|
const approverMembers = approverRoles(loaded.labels, loaded.gateway);
|
|
224
240
|
const baseDir = dirname(loaded.path);
|
|
225
|
-
const authorizers = await gatewayAuthorizers(loaded.gateway, baseDir);
|
|
241
|
+
const authorizers = await gatewayAuthorizers(loaded.gateway, baseDir, env);
|
|
226
242
|
const log = opts.logger ?? { info: (m: string) => console.error(`[adtl-gateway] ${m}`), warn: (m: string) => console.error(`[adtl-gateway] warning: ${m}`) };
|
|
227
243
|
const clients: Client[] = [];
|
|
228
244
|
const closeAll = () => Promise.allSettled(clients.map((c) => c.close())).then(() => undefined);
|
|
@@ -278,6 +294,8 @@ export async function startGateway(
|
|
|
278
294
|
const overrides = loaded.gateway.interceptorOverrides;
|
|
279
295
|
const problems = checkOverrides(bound.map((b) => b.definition), overrides);
|
|
280
296
|
if (problems.length) throw new GatewayConfigError(`gateway.interceptorOverrides: ${problems.join("; ")}`);
|
|
297
|
+
const unsupported = unsupportedInterceptors(bound);
|
|
298
|
+
if (unsupported.length) log.warn(`not running interceptors of an unknown type: ${unsupported.join(", ")}`);
|
|
281
299
|
const late = mutatorsAfter(bound, LABEL_INTERCEPTOR, overrides);
|
|
282
300
|
if (late.length) {
|
|
283
301
|
log.warn(`${late.join(", ")} run after ${LABEL_INTERCEPTOR} and could change or drop its label; give them a lower priorityHint`);
|
|
@@ -324,11 +342,14 @@ export async function startGateway(
|
|
|
324
342
|
if (!principal) log.warn('roles are set but gateway.principal is not: only roles with members ["*"] apply');
|
|
325
343
|
}
|
|
326
344
|
for (const a of authorizers) if (a.name !== "roles") log.info(`authorizer: ${a.name}`);
|
|
345
|
+
const totp = loaded.gateway.approvals?.totp ? loadTotpVerifier(env) : undefined;
|
|
346
|
+
if (loaded.gateway.approvals?.totp && !totp) throw new GatewayConfigError("gateway.approvals.totp is on, but no secret is set up: run adtl-gateway totp setup");
|
|
327
347
|
const agent = gatewayAgent(env);
|
|
328
348
|
if (agent) log.info(`agent: ${agent.id}${agent.type ? ` (${agent.type})` : ""}`);
|
|
329
349
|
const gateway = new AdtlGateway({
|
|
330
350
|
...(shared ? { sessionId: shared.sessionId } : {}),
|
|
331
351
|
...(agent ? { agent } : {}),
|
|
352
|
+
...(totp ? { totp } : {}),
|
|
332
353
|
upstreams,
|
|
333
354
|
invoker,
|
|
334
355
|
...(auditLog ? { audit: auditLog } : {}),
|
package/src/totp.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval codes from an authenticator app (TOTP, RFC 6238: SHA-1, 6 digits,
|
|
3
|
+
* 30 seconds), entered in the MCP client's prompt. Codes are made offline on
|
|
4
|
+
* the phone, and a client that accepts prompts by itself can't produce one.
|
|
5
|
+
*
|
|
6
|
+
* The secret lives in the macOS Keychain, or elsewhere in a file only its
|
|
7
|
+
* owner can read (`ADTL_TOTP_FILE`, or `~/.adtl/totp-secret`). An agent with
|
|
8
|
+
* shell access as the same user may still reach either: the hooks ask before
|
|
9
|
+
* commands that mention them, but only a gateway on another machine rules it out.
|
|
10
|
+
*
|
|
11
|
+
* A code is accepted within one time step either way, and once: the last used
|
|
12
|
+
* step is kept in a file, so a code can't be replayed after a restart either.
|
|
13
|
+
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
16
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
const STEP_SECONDS = 30;
|
|
21
|
+
const DIGITS = 6;
|
|
22
|
+
const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
23
|
+
|
|
24
|
+
/** The Keychain service name, also how the hooks recognize commands that read it. */
|
|
25
|
+
export const TOTP_KEYCHAIN_SERVICE = "adtl-totp";
|
|
26
|
+
|
|
27
|
+
export function base32Encode(bytes: Buffer): string {
|
|
28
|
+
let bits = 0;
|
|
29
|
+
let value = 0;
|
|
30
|
+
let out = "";
|
|
31
|
+
for (const b of bytes) {
|
|
32
|
+
value = (value << 8) | b;
|
|
33
|
+
bits += 8;
|
|
34
|
+
while (bits >= 5) {
|
|
35
|
+
out += BASE32[(value >>> (bits - 5)) & 31];
|
|
36
|
+
bits -= 5;
|
|
37
|
+
}
|
|
38
|
+
value &= (1 << bits) - 1;
|
|
39
|
+
}
|
|
40
|
+
return bits > 0 ? out + BASE32[(value << (5 - bits)) & 31] : out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function base32Decode(text: string): Buffer {
|
|
44
|
+
let bits = 0;
|
|
45
|
+
let value = 0;
|
|
46
|
+
const out: number[] = [];
|
|
47
|
+
for (const c of text.toUpperCase().replace(/[\s=]/g, "")) {
|
|
48
|
+
const i = BASE32.indexOf(c);
|
|
49
|
+
if (i < 0) throw new Error("The TOTP secret is not valid base32");
|
|
50
|
+
value = (value << 5) | i;
|
|
51
|
+
bits += 5;
|
|
52
|
+
if (bits >= 8) {
|
|
53
|
+
out.push((value >>> (bits - 8)) & 255);
|
|
54
|
+
bits -= 8;
|
|
55
|
+
}
|
|
56
|
+
value &= (1 << bits) - 1;
|
|
57
|
+
}
|
|
58
|
+
return Buffer.from(out);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The code for one time step (RFC 4226's HOTP with the step as the counter). */
|
|
62
|
+
export function totpCode(secret: Buffer, step: number, digits = DIGITS): string {
|
|
63
|
+
const counter = Buffer.alloc(8);
|
|
64
|
+
counter.writeBigUInt64BE(BigInt(step));
|
|
65
|
+
const mac = createHmac("sha1", secret).update(counter).digest();
|
|
66
|
+
const offset = mac[mac.length - 1]! & 15;
|
|
67
|
+
const n = mac.readUInt32BE(offset) & 0x7fffffff;
|
|
68
|
+
return String(n % 10 ** digits).padStart(digits, "0");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function totpStep(ms: number): number {
|
|
72
|
+
return Math.floor(ms / 1000 / STEP_SECONDS);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The `otpauth://` URI authenticator apps scan. */
|
|
76
|
+
export function totpUri(secretBase32: string, account: string): string {
|
|
77
|
+
const label = encodeURIComponent(`ADTL:${account}`);
|
|
78
|
+
return `otpauth://totp/${label}?secret=${secretBase32}&issuer=ADTL&algorithm=SHA1&digits=${DIGITS}&period=${STEP_SECONDS}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface TotpState {
|
|
82
|
+
/** The last time step a code was accepted for. */
|
|
83
|
+
lastStep(): number;
|
|
84
|
+
setLastStep(step: number): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class FileTotpState implements TotpState {
|
|
88
|
+
constructor(private readonly path: string) {}
|
|
89
|
+
|
|
90
|
+
lastStep(): number {
|
|
91
|
+
try {
|
|
92
|
+
const n = Number(readFileSync(this.path, "utf8").trim());
|
|
93
|
+
return Number.isSafeInteger(n) ? n : Number.MAX_SAFE_INTEGER; // damaged: accept nothing
|
|
94
|
+
} catch (err) {
|
|
95
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return 0;
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
setLastStep(step: number): void {
|
|
101
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
102
|
+
writeFileSync(this.path, String(step), { encoding: "utf8", mode: 0o600 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export class TotpVerifier {
|
|
107
|
+
constructor(
|
|
108
|
+
private readonly secret: Buffer,
|
|
109
|
+
private readonly state: TotpState,
|
|
110
|
+
private readonly now: () => number = Date.now,
|
|
111
|
+
) {}
|
|
112
|
+
|
|
113
|
+
/** True if `code` is right for now, give or take one step, and that step or a later one wasn't used yet. */
|
|
114
|
+
verify(code: string): boolean {
|
|
115
|
+
const given = Buffer.from(code.trim());
|
|
116
|
+
const current = totpStep(this.now());
|
|
117
|
+
const last = this.state.lastStep();
|
|
118
|
+
let matched: number | undefined;
|
|
119
|
+
for (const step of [current - 1, current, current + 1]) {
|
|
120
|
+
const expected = Buffer.from(totpCode(this.secret, step));
|
|
121
|
+
if (given.length === expected.length && timingSafeEqual(given, expected) && step > last) matched = step;
|
|
122
|
+
}
|
|
123
|
+
if (matched === undefined) return false;
|
|
124
|
+
this.state.setLastStep(matched);
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface TotpSecretStore {
|
|
130
|
+
/** Where the secret is kept, for messages. */
|
|
131
|
+
readonly where: string;
|
|
132
|
+
read(): string | undefined;
|
|
133
|
+
write(secretBase32: string): void;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `ADTL_TOTP_FILE`, or `~/.adtl/totp-secret`. */
|
|
137
|
+
export function totpSecretPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
138
|
+
return env.ADTL_TOTP_FILE?.trim() || join(homedir(), ".adtl", "totp-secret");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function totpStatePath(env: NodeJS.ProcessEnv): string {
|
|
142
|
+
return join(dirname(totpSecretPath(env)), "totp-last-step");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
class FileSecretStore implements TotpSecretStore {
|
|
146
|
+
readonly where: string;
|
|
147
|
+
constructor(private readonly path: string) {
|
|
148
|
+
this.where = path;
|
|
149
|
+
}
|
|
150
|
+
read(): string | undefined {
|
|
151
|
+
try {
|
|
152
|
+
return readFileSync(this.path, "utf8").trim() || undefined;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
write(secretBase32: string): void {
|
|
159
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
160
|
+
writeFileSync(this.path, `${secretBase32}\n`, { encoding: "utf8", mode: 0o600 });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
class KeychainSecretStore implements TotpSecretStore {
|
|
165
|
+
readonly where = "the macOS Keychain";
|
|
166
|
+
read(): string | undefined {
|
|
167
|
+
try {
|
|
168
|
+
return execFileSync("security", ["find-generic-password", "-s", TOTP_KEYCHAIN_SERVICE, "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || undefined;
|
|
169
|
+
} catch {
|
|
170
|
+
return undefined; // no such item
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
write(secretBase32: string): void {
|
|
174
|
+
execFileSync("security", ["add-generic-password", "-U", "-a", "adtl", "-s", TOTP_KEYCHAIN_SERVICE, "-w", secretBase32], { stdio: "ignore" });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The macOS Keychain, unless `ADTL_TOTP_FILE` is set; a file on other systems. */
|
|
179
|
+
export function totpSecretStore(env: NodeJS.ProcessEnv = process.env, platform: NodeJS.Platform = process.platform): TotpSecretStore {
|
|
180
|
+
return platform === "darwin" && !env.ADTL_TOTP_FILE?.trim() ? new KeychainSecretStore() : new FileSecretStore(totpSecretPath(env));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function newTotpSecret(): string {
|
|
184
|
+
return base32Encode(randomBytes(20));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The verifier for `gateway.approvals.totp`, or undefined if no secret was set up. */
|
|
188
|
+
export function loadTotpVerifier(env: NodeJS.ProcessEnv = process.env, store: TotpSecretStore = totpSecretStore(env)): TotpVerifier | undefined {
|
|
189
|
+
const secret = store.read();
|
|
190
|
+
return secret ? new TotpVerifier(base32Decode(secret), new FileTotpState(totpStatePath(env))) : undefined;
|
|
191
|
+
}
|
package/src/totpCli.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `adtl-gateway totp`: set up authenticator-app approvals.
|
|
3
|
+
*
|
|
4
|
+
* adtl-gateway totp setup [--force]
|
|
5
|
+
* adtl-gateway totp check <code>
|
|
6
|
+
*/
|
|
7
|
+
import { userInfo } from "node:os";
|
|
8
|
+
import qrcode from "qrcode-terminal";
|
|
9
|
+
import { base32Decode, newTotpSecret, totpCode, totpSecretStore, totpStep, totpUri, type TotpSecretStore } from "./totp.js";
|
|
10
|
+
|
|
11
|
+
export const TOTP_USAGE = `Usage: adtl-gateway totp setup [--force]
|
|
12
|
+
adtl-gateway totp check <code>
|
|
13
|
+
|
|
14
|
+
setup makes a secret, stores it, and shows a QR code to scan into an
|
|
15
|
+
authenticator app. --force replaces an existing secret. check tells you
|
|
16
|
+
whether a code matches right now, without using it up. Then set
|
|
17
|
+
gateway.approvals.totp: true.`;
|
|
18
|
+
|
|
19
|
+
export interface TotpCommandResult {
|
|
20
|
+
stdout: string;
|
|
21
|
+
exitCode: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function qr(text: string): Promise<string> {
|
|
25
|
+
return new Promise((resolve) => qrcode.generate(text, { small: true }, resolve));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function runTotpCommand(argv: string[], opts: { store?: TotpSecretStore; now?: () => number } = {}): Promise<TotpCommandResult> {
|
|
29
|
+
const store = opts.store ?? totpSecretStore();
|
|
30
|
+
const [cmd, ...rest] = argv;
|
|
31
|
+
if (cmd === "setup" && rest.every((a) => a === "--force")) {
|
|
32
|
+
if (store.read() && !rest.includes("--force")) {
|
|
33
|
+
return { stdout: `A secret is already stored in ${store.where}. Use --force to replace it; codes from the old one stop working.`, exitCode: 1 };
|
|
34
|
+
}
|
|
35
|
+
const secret = newTotpSecret();
|
|
36
|
+
store.write(secret);
|
|
37
|
+
const uri = totpUri(secret, userInfo().username);
|
|
38
|
+
return {
|
|
39
|
+
stdout: `${await qr(uri)}\nScan this with your authenticator app, or enter the key by hand:\n\n ${secret}\n\nStored in ${store.where}. Clear your terminal afterwards: this screen shows the secret.\nThen set gateway.approvals.totp: true, and test a code with: adtl-gateway totp check <code>`,
|
|
40
|
+
exitCode: 0,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (cmd === "check" && rest.length === 1) {
|
|
44
|
+
const secret = store.read();
|
|
45
|
+
if (!secret) return { stdout: "No secret yet: run adtl-gateway totp setup", exitCode: 1 };
|
|
46
|
+
const key = base32Decode(secret);
|
|
47
|
+
const step = totpStep((opts.now ?? Date.now)());
|
|
48
|
+
const ok = [step - 1, step, step + 1].some((s) => totpCode(key, s) === rest[0]);
|
|
49
|
+
return { stdout: ok ? "The code matches." : "The code doesn't match. Check the time on your phone.", exitCode: ok ? 0 : 1 };
|
|
50
|
+
}
|
|
51
|
+
return { stdout: TOTP_USAGE, exitCode: cmd === undefined || cmd === "--help" || cmd === "-h" ? 0 : 1 };
|
|
52
|
+
}
|