可修正、可审计、可证明删除的 memory store
Halofy 写入治理源码拆解:事实抽取与验证、supersede、append-only audit chain、合规 erasure 与 ed25519 删除证书。
观察结论
它不是又一个 prompt memory 插件,而是把身份、ACL、写入验证、检索驱动、审计链和合规删除放进同一个内核边界。
写入是候选事实的治理队列
源码判断
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 对低信息事实、实体注解和目的地做过滤。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
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; 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: [],审计链把事件顺序纳入哈希
源码判断
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。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
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)); 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 };硬删除必须带 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 防止跨租户实体别名扩大删除面。 这处引用用于判断实现边界,而不是只给页面堆行号;它把设计说法落到可检查的控制流、数据模型或测试断言上,读者可以沿着同一文件继续追踪上下游。
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 */ 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 }; 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}