Newer
Older
client_dwatch / lib / ds-agent / tokens.ts
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;
}