HalofyIndex
AI agent governance · MCP/HTTP memory kernel

可修正、可审计、可证明删除的 memory store

Halofy 写入治理源码拆解:事实抽取与验证、supersede、append-only audit chain、合规 erasure 与 ed25519 删除证书。

观察结论

它不是又一个 prompt memory 插件,而是把身份、ACL、写入验证、检索驱动、审计链和合规删除放进同一个内核边界。

25
verify batch
SHA-256
audit chain
ed25519
certificate
1
hard-delete exception
01 / write

写入是候选事实的治理队列

源码判断

src/write/index.ts:1 模块注释把写入拆成治理管线,说明 LLM 抽取不是权威写入。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/write/index.ts:61 index port 支持 closeValidity,新事实通过关闭旧事实有效期表达修正。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/write/index.ts:133 语义 supersede 阈值将相近冲突从普通重复里分离出来。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/write/index.ts:321 write() 的入口在抽取前先处理 classification 与断言。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/write/index.ts:354 每个 candidate 单独处理,失败和 brownout 不必拖垮所有候选。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/write/index.ts:421 processCandidate 对低信息事实、实体注解和目的地做过滤。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/write/index.ts:321-418source
 321  async write(
 322    req: WriteRequest,
 323    policy: WritePolicy,
 324    /** Agent identity for audit attribution (D4); defaults to the actor. */
 325    agentId: string = req.actor,
 326    hooks?: WritePathHooks,
 327  ): Promise<WriteResult> {
 328    const now = new Date().toISOString();
 329    const inputClassification = governedClassification(req.content, req, policy);
 330    if (!req.hints?.verbatim) {
 331      assertDestinationAllowed(inputClassification, "llm-extraction", {
 332        region: policy.dataRegion,
 333      });
 334    }
 335    const result: WriteResult = {
 336      stored: [],
 337      rejected: [],
 338      superseded: [],
 339      deduplicated: 0,
 340    };
 341
 342    // a. Extraction.
 343    await hooks?.onStage?.("extraction");
 344    const candidates = await this.extract(req);
 345
 346    // b (hoisted). One verifier call covers every extracted candidate —
 347    // per-fact calls re-sent the whole transcript per candidate, which made
 348    // verification the write path's dominant model spend on multi-fact
 349    // documents. Runs BEFORE any candidate commits, so an LLM outage here
 350    // propagates with zero progress and the caller's verbatim fallback stays
 351    // safe to re-run (nothing committed to duplicate).
 352    const verdicts = await this.verifyCandidates(req, policy, candidates, now);
 353
 354    for (const [index, cand] of candidates.entries()) {
 355      // M6 (D14): the caller's hard cap on STORED facts (the L4 write-back
 356      // passes its remaining per-fault cache budget). Checked before each
 357      // candidate — extraction fan-out can never land more rows than the cap,
 358      // and the overflow is reported, not silently dropped.
 359      if (req.maxFacts !== undefined && result.stored.length >= req.maxFacts) {
 360        result.rejected.push({ candidate: cand.content, reason: "max-facts-cap" });
 361        continue;
 362      }
 363      try {
 364        // Entity-registry drift between prepare() and the commit transaction
 365        // retries the whole candidate: the transaction rolled back, so a
 366        // fresh preparation inside processCandidate re-reads current state
 367        // and (normally) resolves the colliding mention as an exact match.
 368        // Any other failure - and a conflict that persists past the bound -
 369        // propagates to the handler below unchanged.
 370        for (let entityAttempt = 1; ; entityAttempt++) {
 371          try {
 372            await this.processCandidate(req, policy, agentId, now, cand, verdicts[index] ?? false, result, hooks);
 373            break;
 374          } catch (err) {
 375            if (
 376              err instanceof EntityResolutionConflictError &&
 377              entityAttempt < MAX_ENTITY_RESOLUTION_ATTEMPTS
 378            ) {
 379              continue;
 380            }
 381            throw err;
 382          }
 383        }
 384      } catch (err) {
 385        // An LLM circuit that opens MID-write — after earlier candidates
 386        // were already committed — must never surface as a whole-write
 387        // failure: the caller's verbatim fallback would re-run the ENTIRE
 388        // write, duplicating the committed facts (plus their supersede/
 389        // audit/cost rows) and dropping their ids from the returned result.
 390        // Record the outage per remaining candidate and keep the partial
 391        // WriteResult accurate instead. With ZERO progress the error still
 392        // propagates: the verbatim fallback is safe then (nothing committed
 393        // to duplicate).
 394        const progress =
 395          result.stored.length +
 396            result.superseded.length +
 397            result.rejected.length +
 398            (result.disputed?.length ?? 0) +
 399            result.deduplicated >
 400          0;
 401        if (err instanceof LlmUnavailableError && progress) {
 402          result.rejected.push({ candidate: cand.content, reason: "llm-unavailable" });
 403          await this.audit(
 404            req,
 405            agentId,
 406            now,
 407            "reject",
 408            null,
 409            "error",
 410            "llm-unavailable: circuit opened mid-write; earlier candidates already committed",
 411          );
 412          continue;
 413        }
 414        throw err;
 415      }
 416    }
 417
 418    return result;
src/write/index.ts:421-520source
 421  /** Steps b–f for one extracted candidate (mutates `result` in place). */
 422  private async processCandidate(
 423    req: WriteRequest,
 424    policy: WritePolicy,
 425    agentId: string,
 426    now: string,
 427    cand: Candidate,
 428    /** This candidate's verdict from the hoisted batch verification. */
 429    verdictOk: boolean,
 430    result: WriteResult,
 431    hooks?: WritePathHooks,
 432  ): Promise<void> {
 433    if (req.provenance !== undefined && !req.hints?.verbatim && isLowInformationFact(cand.content)) {
 434      result.rejected.push({ candidate: cand.content, reason: "low-information-fact" });
 435      await this.audit(req, agentId, now, "reject", null, "denied", "low-information-fact");
 436      return;
 437    }
 438
 439    // b. Verifier verdict (computed once for the whole write in
 440    // verifyCandidates) — SKIPPED for verbatim requests: verbatim is the
 441    // no-LLM lane (budget brownout / LLM-outage fallback), so it must not
 442    // call the LLM again. The candidate is the caller's own content stored
 443    // as-is; there is nothing extracted to verify against the transcript.
 444    if (policy.verifierEnabled && !req.hints?.verbatim && !verdictOk) {
 445      result.rejected.push({
 446        candidate: cand.content,
 447        reason: "failed-verification",
 448      });
 449      await this.audit(req, agentId, now, "reject", null, "denied", "failed-verification");
 450      return;
 451    }
 452
 453    // c. Entity resolution — canonical names appended as an annotation;
 454    // the fact text itself stays verbatim.
 455    //
 456    // `[entities: …]` is a RESERVED write-path marker: the read side
 457    // (DbFragmentationReader) parses canonical entities out of a trailing
 458    // annotation. Source-controlled content (a raw doc or verbatim import) that
 459    // legitimately ends in that bracket would otherwise inject FORGED canonical
 460    // entities the write path never resolved. Strip any trailing occurrence off
 461    // the base text first, so the only `[entities: …]` the read side can ever
 462    // see is the one the write path itself appends below after real resolution.
 463    const base = stripEntitiesAnnotation(cand.content);
 464    // A governed localOnly ingestion write (DLP brownout: the document's
 465    // classification denied llm-extraction / embedding-provider) must NOT
 466    // resolve mentions through the entity registry — EntityRegistry.prepare()
 467    // embeds with the external embedder and can call the external LLM
 468    // tiebreak, exactly the egress localOnly exists to prevent. But landing
 469    // with NO entity metadata makes the fact invisible to entity-grouped
 470    // contradiction detection (organization conflict scans report
 471    // entity_metadata_unavailable and skip it), so annotate the deterministic
 472    // proper-noun mentions directly: zero egress, no registry mutation, same
 473    // marker shape the read side parses. Non-ingestion localOnly writes stay
 474    // exactly as-is, mirroring the extract() lane's provenance gate.
 475    const deterministicMentions =
 476      (req.hints?.localOnly || req.hints?.deterministicEntities) &&
 477        req.provenance !== undefined
 478        ? capitalizedWords(base)
 479        : [];
 480    // Entity resolution itself can call the embedding provider. Report the
 481    // boundary before that work begins, not only before the final fact vector,
 482    // so a provider failure is attributed to the real stage.
 483    await hooks?.onStage?.("embedding");
 484    const preparedEntities: PreparedEntityResolution[] = [];
 485    if (this.deps.transact && deterministicMentions.length === 0) {
 486      for (const mention of cand.entities) {
 487        preparedEntities.push(await this.entities.prepare(req.namespace, mention));
 488      }
 489    }
 490    // Production writes resolve entities only after entering deps.transact so
 491    // entity rows, alias changes, erasure fences, the memory row, and its D4
 492    // audit evidence share one commit boundary. The non-transactional lane is
 493    // retained for small structural unit-test implementations.
 494    let content = deterministicMentions.length > 0
 495      ? `${base} [entities: ${deterministicMentions.join(", ")}]`
 496      : this.deps.transact
 497      ? base
 498      : await this.annotateEntities(base, req.namespace, cand.entities, this.entities);
 499
 500    // Embed the fact TEXT (un-annotated base), not the `[entities: …]` marker:
 501    // the annotation is derived read-side metadata, so dedupe / supersedence /
 502    // search similarity must depend only on the fact itself. This also keeps
 503    // the verbatim lane's newly-added entity annotation from perturbing
 504    // supersede matching between two near-identical facts.
 505    const classification = governedClassification(content, req, policy);
 506    if (!req.hints?.localOnly) {
 507      assertDestinationAllowed(classification, "embedding-provider", {
 508        region: policy.dataRegion,
 509      });
 510    }
 511    assertDestinationAllowed(classification, "canonical-l2", {
 512      region: policy.dataRegion,
 513    });
 514    await hooks?.onStage?.("embedding");
 515    const [embedding] = await (
 516      req.hints?.localOnly ? this.localEmbedder : this.deps.embedder
 517    ).embed([base]);
 518    if (!embedding) return; // defensive; embedders return 1:1
 519    const sourceAccess = req.provenance?.sourceAccess ?? {
 520      principals: [],
02 / audit

审计链把事件顺序纳入哈希

源码判断

src/audit/index.ts:92 auditDigest 把事件字段和 prevHash 一起哈希,事件顺序进入证明。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/audit/index.ts:109 appendAuditEventTx 锁链头后插入并更新 audit_chain_state。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/audit/index.ts:180 verifyChain 能重放整条链并发现断裂。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/audit/index.ts:225 audit search 用精确 namespace IN,而不是 LIKE。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/audit/index.ts:92-144source
  92/** Low-level append primitive used by transactional and recovery paths. */
  93function auditDigest(event: AuditEvent, prevHash: string | null): string {
  94  const canonical = JSON.stringify({
  95    prevHash,
  96    ts: event.ts,
  97    actor: event.actor,
  98    agentId: event.agentId,
  99    op: event.op,
 100    memoryId: event.memoryId,
 101    namespace: event.namespace,
 102    purpose: event.purpose,
 103    result: event.result,
 104    detail: event.detail ?? null,
 105  });
 106  return createHash("sha256").update(canonical).digest("hex");
 107}
 108
 109async function appendAuditEventTx(db: Db, event: AuditEvent): Promise<number> {
 110  const head = await db.query(
 111    `SELECT head_hash FROM audit_chain_state WHERE singleton = TRUE FOR UPDATE`,
 112  );
 113  const prevHash = head.rows[0]?.head_hash == null ? null : String(head.rows[0].head_hash);
 114  const eventHash = auditDigest(event, prevHash);
 115  const inserted = await db.query(
 116    `INSERT INTO audit_log
 117       (ts, actor, agent_id, op, memory_id, namespace, purpose, result, detail, prev_hash, event_hash)
 118     VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
 119     RETURNING seq`,
 120    [
 121      event.ts,
 122      event.actor,
 123      event.agentId,
 124      event.op,
 125      event.memoryId,
 126      event.namespace,
 127      event.purpose,
 128      event.result,
 129      event.detail ?? null,
 130      prevHash,
 131      eventHash,
 132    ],
 133  );
 134  await db.query(
 135    `UPDATE audit_chain_state SET head_hash = $1, updated_at = $2 WHERE singleton = TRUE`,
 136    [eventHash, event.ts],
 137  );
 138  // seq is BIGINT: pg binds it as text, PGlite as number — coerce either way.
 139  return Number((inserted.rows[0] as { seq: unknown }).seq);
 140}
 141
 142/** Standalone append: the chain-head lock and event commit atomically. */
 143export async function appendAuditEvent(db: Db, event: AuditEvent): Promise<void> {
 144  await db.transaction((tx) => appendAuditEventTx(tx, event));
src/audit/index.ts:180-209source
 180  async verifyChain(): Promise<{ ok: boolean; checked: number; brokenSeq?: number }> {
 181    const res = await this.db.query(
 182      `SELECT seq, ts, actor, agent_id, op, memory_id, namespace, purpose, result,
 183              detail, prev_hash, event_hash
 184         FROM audit_log WHERE event_hash IS NOT NULL ORDER BY seq`,
 185    );
 186    let expectedPrev: string | null = null;
 187    let checked = 0;
 188    for (const raw of res.rows) {
 189      const row = raw as Record<string, unknown>;
 190      checked += 1;
 191      const event = rowToAuditEvent(row);
 192      const storedPrev = row.prev_hash == null ? null : String(row.prev_hash);
 193      const storedHash = String(row.event_hash);
 194      const expectedHash = auditDigest(event, expectedPrev);
 195      if (storedPrev !== expectedPrev || storedHash !== expectedHash) {
 196        return { ok: false, checked, brokenSeq: Number(row.seq) };
 197      }
 198      expectedPrev = storedHash;
 199    }
 200    const state = await this.db.query(
 201      `SELECT head_hash FROM audit_chain_state WHERE singleton = TRUE`,
 202    );
 203    const storedHead = state.rows[0]?.head_hash == null
 204      ? null
 205      : String(state.rows[0].head_hash);
 206    if (storedHead !== expectedPrev) {
 207      return { ok: false, checked };
 208    }
 209    return { ok: true, checked };
03 / erasure

硬删除必须带 tombstone 与签名证书

源码判断

src/erasure/index.ts:4 forget 是唯一硬删除例外,但 audit_log 保持 append-only。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/erasure/index.ts:117 isInSubtree 让删除范围与 namespace ancestor 规则一致。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/erasure/index.ts:201 resolveTargets 对不同 target kind 先解析具体行再过滤 scope。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/erasure/index.ts:306 entity registry 只在 caller subtree 中解析,避免 sibling alias 污染。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/erasure/index.ts:374 usage/query trace 派生行也纳入删除证书范围。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/erasure/certificate.ts:24 stableStringify 保证 payload 的签名字节稳定。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/erasure/certificate.ts:75 verifyCertificate 要 out-of-band trusted key,证书内 publicKey 不是信任根。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

test/m7-audit-chain.test.ts:35 审计链测试验证正常 append 的 hash 链。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

test/m7-audit-chain.test.ts:48 篡改测试证明 verifyChain 会发现字段变动。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

test/erasure.test.ts:165 actor 删除测试确认只删目标 actor 的事实。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

test/erasure.test.ts:467 audit survives erasure 测试证明 erase 后审计仍可查。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

test/erasure.test.ts:624 sibling alias regression 防止跨租户实体别名扩大删除面。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。

src/erasure/index.ts:1-28source
   1/**
   2 * Module B (M4 / D3) — mem.forget erasure engine.
   3 *
   4 * `forget(deps, target, identity)` is the ONE governed exception to D7's "never
   5 * delete": a compliance erasure. It resolves the target to a concrete id set
   6 * INSIDE the caller's subtree, then in ONE database transaction:
   7 *   1. nulls any dangling `supersedes` links from surviving rows into the
   8 *      to-be-deleted set (the FK `memory_objects.supersedes -> memory_objects.id`
   9 *      would otherwise reject the DELETE);
  10 *   2. HARD-DELETEs the matching rows (their inline embeddings go with them);
  11 *   3. inserts one tombstone per erased id;
  12 *   4. builds + ed25519-signs a DeletionCertificate (public key inline) and
  13 *      inserts it.
  14 * E6: derived usage rows (usage_events, query_traces for actor/namespace
  15 * targets) are scrubbed in the same transaction — no tombstones (they are not
  16 * memory objects), but the certificate enumerates their ids.
  17 * It NEVER touches `audit_log` — the audit trail is append-only (D4); the
  18 * syscall layer appends the `forget` audit row.
  19 *
  20 * Subtree scoping (isolation = the existing ancestor-prefix ACL, downward):
  21 *   - the caller may only erase within its OWN subtree — a target whose
  22 *     namespace is an ANCESTOR or a SIBLING of the caller's namespace resolves
  23 *     to zero rows;
  24 *   - resolution runs DOWNWARD: a namespace target matches that namespace and
  25 *     its descendants (again constrained to the caller's subtree).
  26 *
  27 * `deps` is a STRUCTURAL slice `{ db, audit }` — no kernel import.
  28 */
src/erasure/index.ts:374-425source
 374async function resolveUsageTargets(
 375  db: Db,
 376  target: ForgetTarget,
 377  caller: Namespace,
 378  global: boolean,
 379): Promise<{
 380  usageEventIds: string[];
 381  queryTraceIds: string[];
 382  queryTraceKeys: { namespace: string; traceId: string }[];
 383}> {
 384  const none: {
 385    usageEventIds: string[];
 386    queryTraceIds: string[];
 387    queryTraceKeys: { namespace: string; traceId: string }[];
 388  } = { usageEventIds: [], queryTraceIds: [], queryTraceKeys: [] };
 389  if (target.kind !== "actor" && target.kind !== "namespace") return none;
 390  if (target.kind === "namespace" && !isInSubtree(target.value, caller, global)) return none;
 391  const collect = async (
 392    table: "usage_events" | "query_traces",
 393    idColumn: "id" | "trace_id",
 394  ): Promise<Record<string, unknown>[]> => {
 395    const distinct = await db.query(`SELECT DISTINCT namespace FROM ${table}`, []);
 396    const candidateNs = (distinct.rows as Record<string, unknown>[])
 397      .map((row) => row.namespace as Namespace)
 398      .filter((ns) => isInSubtree(ns, caller, global));
 399    if (candidateNs.length === 0) return [];
 400    const ph = candidateNs.map((_, i) => `$${i + 1}`).join(", ");
 401    const params: unknown[] = [...candidateNs];
 402    // Exact IN binds only, never LIKE. The actor predicate rides the query.
 403    let sql = `SELECT ${idColumn} AS id, namespace, actor FROM ${table} WHERE namespace IN (${ph})`;
 404    if (target.kind === "actor") {
 405      params.push(target.value);
 406      sql += ` AND actor = $${params.length}`;
 407    }
 408    const res = await db.query(sql, params);
 409    return (res.rows as Record<string, unknown>[]).filter((row) =>
 410      target.kind === "namespace"
 411        ? isInSubtree(row.namespace as Namespace, target.value, false)
 412        : true,
 413    );
 414  };
 415  const eventRows = await collect("usage_events", "id");
 416  const traceRows = await collect("query_traces", "trace_id");
 417  const queryTraceKeys = traceRows.map((row) => ({
 418    namespace: row.namespace as string,
 419    traceId: String(row.id),
 420  }));
 421  return {
 422    usageEventIds: eventRows.map((row) => String(row.id)).sort(),
 423    queryTraceIds: [...new Set(queryTraceKeys.map((key) => key.traceId))].sort(),
 424    queryTraceKeys,
 425  };
src/erasure/certificate.ts:24-103source
  24 * Deterministic JSON of an arbitrary JSON value with object keys sorted at
  25 * every depth. Arrays keep their order (erasedIds ordering is meaningful and
  26 * fixed by the resolver); objects (including the nested ForgetTarget) sort.
  27 */
  28function stableStringify(value: unknown): string {
  29  if (value === null || typeof value !== "object") {
  30    return JSON.stringify(value);
  31  }
  32  if (Array.isArray(value)) {
  33    return `[${value.map((v) => stableStringify(v)).join(",")}]`;
  34  }
  35  const obj = value as Record<string, unknown>;
  36  const keys = Object.keys(obj).sort();
  37  const parts = keys.map(
  38    (k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`,
  39  );
  40  return `{${parts.join(",")}}`;
  41}
  42
  43/**
  44 * Canonical, signature-independent JSON string of a certificate. Given a full
  45 * certificate or a payload, the `signature` field is always excluded, so the
  46 * signer and the verifier hash exactly the same bytes.
  47 */
  48export function canonicalPayload(
  49  cert: CertificatePayload | DeletionCertificate,
  50): string {
  51  const { signature: _signature, ...payload } = cert as DeletionCertificate;
  52  return stableStringify(payload);
  53}
  54
  55/**
  56 * Sign a certificate payload with the kernel's ed25519 key; returns the base64
  57 * signature over the canonical payload bytes (UTF-8).
  58 */
  59export function signCertificate(
  60  payload: CertificatePayload,
  61  key: SigningKey,
  62): string {
  63  const bytes = Buffer.from(canonicalPayload(payload), "utf8");
  64  return key.sign(bytes).toString("base64");
  65}
  66
  67/**
  68 * Verify a certificate against an out-of-band trusted public key. The inline
  69 * key is signed descriptive metadata and must identify that same trusted key;
  70 * it is never itself a trust anchor. Rebuilds the canonical payload (dropping
  71 * `signature`) and checks the ed25519 signature.
  72 * Returns false — never throws — for a key mismatch, tampered field, bad
  73 * signature, malformed key, or non-ed25519 algorithm.
  74 */
  75export function verifyCertificate(
  76  cert: DeletionCertificate,
  77  trustedPublicKeyBase64: string,
  78): boolean {
  79  try {
  80    if (cert.algorithm !== "ed25519") return false;
  81    const inlineKeyDer = Buffer.from(cert.publicKey, "base64");
  82    const trustedKeyDer = Buffer.from(trustedPublicKeyBase64, "base64");
  83    if (
  84      inlineKeyDer.length === 0 ||
  85      inlineKeyDer.length !== trustedKeyDer.length ||
  86      !timingSafeEqual(inlineKeyDer, trustedKeyDer)
  87    ) {
  88      return false;
  89    }
  90    const publicKey = createPublicKey({
  91      key: trustedKeyDer,
  92      format: "der",
  93      type: "spki",
  94    });
  95    if (publicKey.asymmetricKeyType !== "ed25519") return false;
  96    const bytes = Buffer.from(canonicalPayload(cert), "utf8");
  97    const signature = Buffer.from(cert.signature, "base64");
  98    // ed25519: algorithm is null (the scheme fixes the digest).
  99    return cryptoVerify(null, bytes, publicKey, signature);
 100  } catch {
 101    return false;
 102  }
 103}