import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import { toJstIso } from "@/lib/format/date";
import {
createJsonStore,
type JsonStore,
type JsonStoreWarning,
} from "@/lib/settings/json-store";
/**
* 制御盤トークン(指示書AY 段階4。D7・D16・D22)。期待値の正は指示書AY §3 表3(`__tests__/tokens.test.ts`)。
*
* - 制御盤ごとに32バイトの乱数を base64url(43文字)。★ストアには SHA-256 の16進だけを書く★。
* ★生の値は `issueAgentToken()` の戻り値で1回だけ★(保存しない・ログに出さない)。
* - 保存先は DFA 独自ストア `data/settings/ds-agent-tokens.json`。★メインDBには書かない★(`CLAUDE.md` §7)。
* - ★`lib/logger` を import しない★(proxy から呼ぶため。`json-store.ts` と同じ考え方)。壊れたストアの警告は `onWarn` で外に渡す。
* - 読みは `fs.stat` の `mtimeMs` を見てメモリにキャッシュする(D22。表3 K12)。発行・失効の後は自分のキャッシュを捨てる。
* - ★シリアルの実在(DB)はここでは見ない★(ルートが `dsDeviceExists()` で見る)。照合は JS の `===`(D-4)。
* - 書き込みは `json-store` の待ち行列で直列化されるが、★読み → 書きの後勝ちは防がない★
* (同じ制御盤の発行と失効を同時に押すと、結果はどちらか一方になる。P1 は社内の少人数なので許容)。
*/
export interface AgentTokenEntry {
token_sha256: string;
issued_at: string;
}
/** `serial_number` → トークンのハッシュと発行日時 */
export type AgentTokens = Record<string, AgentTokenEntry>;
/** ★ストアとパスを組で持つ★(`JsonStore` はパスを返さないので、mtime を見るためにパスを別に持つ。M13) */
export interface AgentTokenStoreTarget {
store: JsonStore<AgentTokens>;
filePath: string;
}
const BEARER = /^Bearer ([A-Za-z0-9_-]{43})$/;
const SHA256_HEX = /^[0-9a-f]{64}$/;
/** ★呼び出し時に組み立てる★(`ds-device-notes.ts` の既定パスと同じ理由) */
function defaultAgentTokenStorePath(): string {
return path.join(process.cwd(), "data", "settings", "ds-agent-tokens.json");
}
declare global {
/** 壊れたストアの警告の受け口(`setAgentTokenStoreWarnHandler()`)。ルートのバンドルが別でも同じ関数を見るよう `globalThis` に置く */
var __dwatchAgentTokenStoreWarn: ((info: JsonStoreWarning) => void) | undefined;
}
/**
* 既定のストアの壊れた警告の受け口を登録する(★ここは logger を import しない★ので、
* `settings.store_corrupt` を出すのはルート側の仕事。`lib/ds-agent/relay-log.ts`)。
*/
export function setAgentTokenStoreWarnHandler(handler: ((info: JsonStoreWarning) => void) | undefined): void {
globalThis.__dwatchAgentTokenStoreWarn = handler;
}
export function createAgentTokenStore(
filePath: string = defaultAgentTokenStorePath(),
onWarn: (info: JsonStoreWarning) => void = (info) => globalThis.__dwatchAgentTokenStoreWarn?.(info)
): AgentTokenStoreTarget {
return {
store: createJsonStore<AgentTokens>({
filePath,
empty: () => Object.create(null) as AgentTokens,
onWarn,
}),
filePath,
};
}
let target: AgentTokenStoreTarget | null = null;
interface VerifyCache {
filePath: string;
mtimeMs: number | null;
entries: ReadonlyArray<readonly [serial: string, hash: Buffer]>;
}
let cache: VerifyCache | null = null;
function storeTarget(): AgentTokenStoreTarget {
target ??= createAgentTokenStore();
return target;
}
/** ★テスト専用★。★ストアとパスを一緒に差し替える★(`null` で既定へ戻す。キャッシュも捨てる) */
export function __setAgentTokenStoreForTesting(next: AgentTokenStoreTarget | null): void {
target = next;
cache = null;
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
}
/** 32バイトの乱数を base64url。ストアには SHA-256 の16進だけを書く。★生の値は戻り値で1回だけ★ */
export async function issueAgentToken(
serialNumber: string
): Promise<{ token: string; issued_at: string }> {
const { store } = storeTarget();
const token = randomBytes(32).toString("base64url");
const issued_at = toJstIso(new Date());
const tokens = await store.read();
tokens[serialNumber] = { token_sha256: sha256Hex(token), issued_at };
await store.write(tokens);
cache = null;
return { token, issued_at };
}
/** 失効させる。★無ければ false★(ルートは未発行でも 204。P2) */
export async function revokeAgentToken(serialNumber: string): Promise<boolean> {
const { store } = storeTarget();
const tokens = await store.read();
if (!Object.prototype.hasOwnProperty.call(tokens, serialNumber)) return false;
delete tokens[serialNumber];
await store.write(tokens);
cache = null;
return true;
}
/** 発行日時(P1)。未発行・壊れた行なら `null`。★トークン・ハッシュは返さない★ */
export async function agentTokenIssuedAt(serialNumber: string): Promise<string | null> {
const tokens = await storeTarget().store.read();
if (!Object.prototype.hasOwnProperty.call(tokens, serialNumber)) return null;
const entry: unknown = tokens[serialNumber];
if (!isValidEntry(entry)) return null;
return entry.issued_at;
}
function isValidEntry(entry: unknown): entry is AgentTokenEntry {
return (
typeof entry === "object" &&
entry !== null &&
typeof (entry as AgentTokenEntry).token_sha256 === "string" &&
SHA256_HEX.test((entry as AgentTokenEntry).token_sha256) &&
typeof (entry as AgentTokenEntry).issued_at === "string"
);
}
async function currentMtime(filePath: string): Promise<number | null> {
try {
return (await fs.stat(filePath)).mtimeMs;
} catch (err) {
if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT") return null;
throw err;
}
}
/** mtime が変わっていなければメモリのキャッシュを返す(D22)。★壊れた1件は無視し例外にしない★(表3 K11) */
async function loadEntries(): Promise<VerifyCache["entries"]> {
const { store, filePath } = storeTarget();
const mtimeMs = await currentMtime(filePath);
if (cache && cache.filePath === filePath && cache.mtimeMs === mtimeMs) return cache.entries;
const tokens = mtimeMs === null ? (Object.create(null) as AgentTokens) : await store.read();
const entries: Array<readonly [string, Buffer]> = [];
for (const serial of Object.keys(tokens)) {
const entry: unknown = tokens[serial];
if (!isValidEntry(entry)) continue;
entries.push([serial, Buffer.from(entry.token_sha256, "hex")]);
}
cache = { filePath, mtimeMs, entries };
return entries;
}
/**
* `Authorization` ヘッダの生値 → serial または `null`。
* ★形は厳密に `Bearer ` + 43文字(表3 K5・K6)★。形が違えばストアを読まずに `null`。
* ★入力の SHA-256 と全件を timingSafeEqual で比べ、見つかっても途中で抜けない★。
*/
export async function verifyAgentAuthorization(header: string | null): Promise<string | null> {
if (!header) return null;
const match = BEARER.exec(header);
if (!match) return null;
const inputHash = createHash("sha256").update(match[1], "utf8").digest();
const entries = await loadEntries();
let found: string | null = null;
for (const [serial, hash] of entries) {
if (timingSafeEqual(inputHash, hash) && found === null) found = serial;
}
return found;
}