release(v4.0.0): Shade GA — V3.x consolidation + audit prep
Some checks failed
Test / test (push) Has been cancelled
Cross-platform vectors / TypeScript vectors (bun) (push) Has been cancelled
Cross-platform vectors / Kotlin vectors (gradle) (push) Has been cancelled
Docker build and publish / docker (push) Has been cancelled
Publish / publish (push) Has been cancelled

V3.1 → V3.12 consolidated and tagged for the first GA release. Wire
format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers
byte-for-byte. The version bump is semantic: audit-cycle complete,
opt-in surface fully exposed, threat model refreshed for every new
surface.

Highlights:
- All 24 @shade/* packages bumped to 4.0.0 in lockstep.
- CHANGELOG 4.0.0 section is the canonical manifest of what landed.
- THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12
  Web-Worker boundary) + residual-risks table refreshed.
- OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox,
  bridge, observer, /metrics, /healthz, /ready.
- MIGRATION 0.3.x → 4.0 documented + smoke-tested against
  shade migrate-storage on a real SQLite DB.
- docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer.
- scripts/soak.ts harness for the GA-stable 2-week soak window.
- All V*.md plans archived under docs/archive/ with Status: Done.
- Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen
  non-realtime stack.

Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green.
Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports
  version 4.0.0 on /health.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 18:35:35 +02:00
parent 8b055912b7
commit e6fdf31b49
298 changed files with 37909 additions and 256 deletions

View File

@@ -0,0 +1,36 @@
import { ShadeError } from '@shade/core';
export class KTError extends ShadeError {
constructor(code: string, message: string) {
super(code, message);
this.name = 'KTError';
}
}
export class KTVerificationError extends KTError {
constructor(message: string) {
super('SHADE_KT_VERIFICATION', message);
this.name = 'KTVerificationError';
}
}
export class KTSplitViewError extends KTError {
constructor(message: string) {
super('SHADE_KT_SPLIT_VIEW', message);
this.name = 'KTSplitViewError';
}
}
export class KTStaleSTHError extends KTError {
constructor(message: string) {
super('SHADE_KT_STALE_STH', message);
this.name = 'KTStaleSTHError';
}
}
export class KTLogIdMismatchError extends KTError {
constructor(message: string) {
super('SHADE_KT_LOG_ID_MISMATCH', message);
this.name = 'KTLogIdMismatchError';
}
}

View File

@@ -0,0 +1,137 @@
/**
* RFC 6962 §2.1 hash construction for an append-only Merkle log.
*
* leaf_hash(d) = SHA-256(0x00 || d)
* node_hash(left, r) = SHA-256(0x01 || left || r)
*
* The 0x00 / 0x01 prefixes are critical — they make leaf-hashes and
* node-hashes distinct, which prevents second-preimage attacks where
* an attacker re-interprets a leaf as an internal node.
*/
import { sha256Sync } from './sha256.js';
const DOMAIN_LEAF = 0x00;
const DOMAIN_NODE = 0x01;
/** Bundle commitment domain prefix. Must be stable across versions. */
export const DOMAIN_BUNDLE = 0x01;
/** STH canonical-bytes domain prefix. */
export const DOMAIN_STH = 0x02;
/** Operation byte values inside a leaf. */
export const OP_REGISTER = 0x01;
export const OP_REPLENISH = 0x02;
export const OP_DELETE = 0x03;
export function leafHash(data: Uint8Array): Uint8Array {
const buf = new Uint8Array(1 + data.length);
buf[0] = DOMAIN_LEAF;
buf.set(data, 1);
return sha256Sync(buf);
}
export function nodeHash(left: Uint8Array, right: Uint8Array): Uint8Array {
const buf = new Uint8Array(1 + left.length + right.length);
buf[0] = DOMAIN_NODE;
buf.set(left, 1);
buf.set(right, 1 + left.length);
return sha256Sync(buf);
}
/** RFC 6962 MTH for an empty list: SHA-256 of the empty string. */
export function emptyRootHash(): Uint8Array {
return sha256Sync(new Uint8Array(0));
}
/**
* Encode a leaf describing an `address → bundle_hash` event.
*
* Layout:
* uint64_be timestamp_ms
* byte operation
* uint16_be addr_len
* bytes address (utf-8)
* uint16_be hash_len
* bytes bundle_hash (32 bytes for register/replenish, may be 0 for delete)
*/
export function encodeLeafData(
timestampMs: number,
operation: number,
address: string,
bundleHash: Uint8Array,
): Uint8Array {
const addrBytes = new TextEncoder().encode(address);
if (addrBytes.length > 0xffff) {
throw new Error('address too long for KT leaf encoding');
}
if (bundleHash.length > 0xffff) {
throw new Error('bundleHash too long for KT leaf encoding');
}
const len = 8 + 1 + 2 + addrBytes.length + 2 + bundleHash.length;
const out = new Uint8Array(len);
const view = new DataView(out.buffer);
let off = 0;
// uint64 BE — split into two uint32 halves
view.setUint32(off, Math.floor(timestampMs / 0x100000000));
view.setUint32(off + 4, timestampMs >>> 0);
off += 8;
out[off++] = operation;
view.setUint16(off, addrBytes.length);
off += 2;
out.set(addrBytes, off);
off += addrBytes.length;
view.setUint16(off, bundleHash.length);
off += 2;
out.set(bundleHash, off);
return out;
}
/**
* Compute the canonical bundle commitment hash.
*
* bundle_hash = SHA-256(
* 0x01 ||
* identitySigningKey (32) ||
* identityDHKey (32) ||
* uint32_be signedPreKey.keyId ||
* signedPreKey.publicKey (32) ||
* signedPreKey.signature (64)
* )
*
* One-time prekeys are NOT included — they are ephemeral and including
* them would force a log mutation per OTP rotation.
*/
export function computeBundleHash(input: {
identitySigningKey: Uint8Array;
identityDHKey: Uint8Array;
signedPreKey: { keyId: number; publicKey: Uint8Array; signature: Uint8Array };
}): Uint8Array {
if (input.identitySigningKey.length !== 32) {
throw new Error('identitySigningKey must be 32 bytes');
}
if (input.identityDHKey.length !== 32) {
throw new Error('identityDHKey must be 32 bytes');
}
if (input.signedPreKey.publicKey.length !== 32) {
throw new Error('signedPreKey.publicKey must be 32 bytes');
}
if (input.signedPreKey.signature.length !== 64) {
throw new Error('signedPreKey.signature must be 64 bytes');
}
const buf = new Uint8Array(1 + 32 + 32 + 4 + 32 + 64);
let off = 0;
buf[off++] = DOMAIN_BUNDLE;
buf.set(input.identitySigningKey, off);
off += 32;
buf.set(input.identityDHKey, off);
off += 32;
new DataView(buf.buffer).setUint32(off, input.signedPreKey.keyId >>> 0);
off += 4;
buf.set(input.signedPreKey.publicKey, off);
off += 32;
buf.set(input.signedPreKey.signature, off);
return sha256Sync(buf);
}

View File

@@ -0,0 +1,339 @@
/**
* Address-index commitment.
*
* The Merkle log itself records mutation events (`address → bundle_hash`
* at time T), but doesn't natively answer "what's the *current* state for
* `address`?" or "does `address` exist?".
*
* The address index is a **lexicographically sorted snapshot** of the
* current `(address, latest_leaf_index)` mapping. Its commitment hash —
* `index_root` — is part of every Signed Tree Head.
*
* Inclusion proof: the entry exists at sorted index `i`, prove it via
* audit path (same Merkle construction as the main log).
*
* Absence proof: the address would sort between two adjacent existing
* entries; prove inclusion of those two adjacent entries
* and that the queried address sorts strictly between them.
*
* V1 representation: a flat sorted array. We re-hash the whole index per
* STH (cheap up to ~1M entries). V2 will move to a sparse Merkle tree if
* the dataset grows enough that flat re-hash becomes a bottleneck.
*/
import { leafHash, nodeHash, emptyRootHash } from './hashes.js';
import { sha256Sync } from './sha256.js';
import { constantTimeEqual } from './util.js';
import { mth, auditPath, recomputeRootFromAuditPath } from './log.js';
export interface AddressIndexEntry {
/** The address (UTF-8 string). */
address: string;
/** Most recent log leaf index that mutated this address. */
latestLeafIndex: number;
/** Most recent bundle hash committed for this address. */
bundleHash: Uint8Array;
/** Whether the latest event was a delete (tombstone). */
deleted: boolean;
}
/** Encode an index entry into the bytes that go into a Merkle leaf. */
export function encodeIndexEntry(entry: AddressIndexEntry): Uint8Array {
const addrBytes = new TextEncoder().encode(entry.address);
if (addrBytes.length > 0xffff) throw new Error('address too long');
if (entry.bundleHash.length > 0xffff) throw new Error('bundleHash too long');
const len = 2 + addrBytes.length + 4 + 1 + 2 + entry.bundleHash.length;
const out = new Uint8Array(len);
const view = new DataView(out.buffer);
let off = 0;
view.setUint16(off, addrBytes.length);
off += 2;
out.set(addrBytes, off);
off += addrBytes.length;
view.setUint32(off, entry.latestLeafIndex >>> 0);
off += 4;
out[off++] = entry.deleted ? 1 : 0;
view.setUint16(off, entry.bundleHash.length);
off += 2;
out.set(entry.bundleHash, off);
return out;
}
/** Compute index_root over a sorted entry list. */
export function computeIndexRoot(sortedEntries: AddressIndexEntry[]): Uint8Array {
if (sortedEntries.length === 0) return emptyRootHash();
const leaves = sortedEntries.map((e) => leafHash(encodeIndexEntry(e)));
return mth(leaves, 0, leaves.length);
}
/** Compare two addresses lexicographically (by UTF-8 byte order). */
export function compareAddresses(a: string, b: string): number {
const ab = new TextEncoder().encode(a);
const bb = new TextEncoder().encode(b);
const len = Math.min(ab.length, bb.length);
for (let i = 0; i < len; i++) {
if (ab[i]! !== bb[i]!) return ab[i]! - bb[i]!;
}
return ab.length - bb.length;
}
/**
* In-memory address index. Maintains the canonical sorted ordering; on
* mutate, the operator re-computes index_root for the next STH.
*/
export class AddressIndex {
private entries: AddressIndexEntry[] = [];
private positionByAddress = new Map<string, number>();
get size(): number {
return this.entries.length;
}
/** Idempotently set an entry; re-sorts only when a new address is added. */
upsert(entry: AddressIndexEntry): void {
const existingPos = this.positionByAddress.get(entry.address);
if (existingPos !== undefined) {
this.entries[existingPos] = { ...entry };
return;
}
// Insert keeping sort order
let lo = 0;
let hi = this.entries.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (compareAddresses(this.entries[mid]!.address, entry.address) < 0) lo = mid + 1;
else hi = mid;
}
this.entries.splice(lo, 0, { ...entry });
// Rebuild position map (positions shift after insert)
this.positionByAddress.clear();
for (let i = 0; i < this.entries.length; i++) {
this.positionByAddress.set(this.entries[i]!.address, i);
}
}
/** Mark an address tombstoned. Keeps the entry in sorted order. */
tombstone(address: string, latestLeafIndex: number): void {
const pos = this.positionByAddress.get(address);
if (pos === undefined) return;
const e = this.entries[pos]!;
this.entries[pos] = {
...e,
deleted: true,
latestLeafIndex,
bundleHash: new Uint8Array(0),
};
}
/** Snapshot ordered list (defensive copy). */
snapshot(): AddressIndexEntry[] {
return this.entries.map((e) => ({ ...e, bundleHash: new Uint8Array(e.bundleHash) }));
}
/** Compute the index commitment root over the current sorted list. */
rootHash(): Uint8Array {
return computeIndexRoot(this.entries);
}
/** Look up an entry. */
get(address: string): AddressIndexEntry | undefined {
const pos = this.positionByAddress.get(address);
if (pos === undefined) return undefined;
return { ...this.entries[pos]!, bundleHash: new Uint8Array(this.entries[pos]!.bundleHash) };
}
/** Build inclusion proof: returns sorted-position + audit path. */
inclusionProof(address: string): IndexInclusionProof | null {
const pos = this.positionByAddress.get(address);
if (pos === undefined) return null;
const leaves = this.entries.map((e) => leafHash(encodeIndexEntry(e)));
return {
kind: 'inclusion',
position: pos,
treeSize: this.entries.length,
entry: { ...this.entries[pos]!, bundleHash: new Uint8Array(this.entries[pos]!.bundleHash) },
auditPath: auditPath(leaves, pos, leaves.length),
};
}
/**
* Build absence proof: returns the two adjacent entries that bracket the
* queried address (or boundary case for first/last).
*/
absenceProof(address: string): IndexAbsenceProof | null {
if (this.positionByAddress.has(address)) return null;
if (this.entries.length === 0) {
return {
kind: 'absence',
treeSize: 0,
queryAddress: address,
prev: null,
next: null,
};
}
// Find insertion position
let lo = 0;
let hi = this.entries.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (compareAddresses(this.entries[mid]!.address, address) < 0) lo = mid + 1;
else hi = mid;
}
const leaves = this.entries.map((e) => leafHash(encodeIndexEntry(e)));
const prevPos = lo - 1;
const nextPos = lo;
const prev =
prevPos >= 0
? {
position: prevPos,
entry: {
...this.entries[prevPos]!,
bundleHash: new Uint8Array(this.entries[prevPos]!.bundleHash),
},
auditPath: auditPath(leaves, prevPos, leaves.length),
}
: null;
const next =
nextPos < this.entries.length
? {
position: nextPos,
entry: {
...this.entries[nextPos]!,
bundleHash: new Uint8Array(this.entries[nextPos]!.bundleHash),
},
auditPath: auditPath(leaves, nextPos, leaves.length),
}
: null;
return {
kind: 'absence',
treeSize: this.entries.length,
queryAddress: address,
prev,
next,
};
}
/** Hot-load from a sorted entry array (used by persistent stores). */
static fromEntries(sortedEntries: AddressIndexEntry[]): AddressIndex {
const idx = new AddressIndex();
for (const e of sortedEntries) {
idx.entries.push({ ...e, bundleHash: new Uint8Array(e.bundleHash) });
}
for (let i = 0; i < idx.entries.length; i++) {
idx.positionByAddress.set(idx.entries[i]!.address, i);
}
return idx;
}
}
export interface IndexInclusionProof {
kind: 'inclusion';
position: number;
treeSize: number;
entry: AddressIndexEntry;
auditPath: Uint8Array[];
}
export interface IndexAbsenceProof {
kind: 'absence';
treeSize: number;
queryAddress: string;
/**
* Largest existing entry less than the query (null if the query would
* be the first entry).
*/
prev: { position: number; entry: AddressIndexEntry; auditPath: Uint8Array[] } | null;
/**
* Smallest existing entry greater than the query (null if the query
* would be appended after the last entry).
*/
next: { position: number; entry: AddressIndexEntry; auditPath: Uint8Array[] } | null;
}
export type IndexProof = IndexInclusionProof | IndexAbsenceProof;
/**
* Verify an inclusion proof against an `index_root` commitment.
*/
export function verifyInclusionProof(
proof: IndexInclusionProof,
indexRoot: Uint8Array,
): boolean {
const lh = leafHash(encodeIndexEntry(proof.entry));
let recomputed: Uint8Array;
try {
recomputed = recomputeRootFromAuditPath(lh, proof.position, proof.treeSize, proof.auditPath);
} catch {
return false;
}
return constantTimeEqual(recomputed, indexRoot);
}
/**
* Verify an absence proof:
* - prev exists and address(prev) < query
* - next exists and query < address(next)
* - prev.position + 1 === next.position (they are adjacent)
* - both inclusion sub-proofs verify against indexRoot
*
* Boundary cases:
* - tree empty (treeSize === 0): valid if both prev and next are null
* - query smaller than all entries: prev is null, next.position === 0
* - query larger than all entries: next is null, prev.position === treeSize - 1
*/
export function verifyAbsenceProof(
proof: IndexAbsenceProof,
indexRoot: Uint8Array,
): boolean {
if (proof.treeSize === 0) {
if (proof.prev !== null || proof.next !== null) return false;
return constantTimeEqual(emptyRootHash(), indexRoot);
}
const queryAddr = proof.queryAddress;
if (proof.prev) {
if (compareAddresses(proof.prev.entry.address, queryAddr) >= 0) return false;
const lh = leafHash(encodeIndexEntry(proof.prev.entry));
let r: Uint8Array;
try {
r = recomputeRootFromAuditPath(lh, proof.prev.position, proof.treeSize, proof.prev.auditPath);
} catch {
return false;
}
if (!constantTimeEqual(r, indexRoot)) return false;
}
if (proof.next) {
if (compareAddresses(queryAddr, proof.next.entry.address) >= 0) return false;
const lh = leafHash(encodeIndexEntry(proof.next.entry));
let r: Uint8Array;
try {
r = recomputeRootFromAuditPath(lh, proof.next.position, proof.treeSize, proof.next.auditPath);
} catch {
return false;
}
if (!constantTimeEqual(r, indexRoot)) return false;
}
// Boundary checks
if (proof.prev === null) {
if (proof.next === null) return false; // already handled treeSize===0
if (proof.next.position !== 0) return false;
} else if (proof.next === null) {
if (proof.prev.position !== proof.treeSize - 1) return false;
} else {
if (proof.prev.position + 1 !== proof.next.position) return false;
}
return true;
}
/** sha256 helper export for callers that need the same hash function. */
export { sha256Sync };

View File

@@ -0,0 +1,100 @@
/**
* `@shade/key-transparency` — verifiable prekey distribution (V3.12).
*
* Public surface:
* - Hash primitives: `leafHash`, `nodeHash`, `computeBundleHash`, `encodeLeafData`.
* - Merkle log: `MerkleLog`, `auditPath`, `recomputeRootFromAuditPath`,
* `consistencyProof`, `verifyConsistencyProof`.
* - Address index: `AddressIndex`, `verifyInclusionProof`, `verifyAbsenceProof`.
* - Signed Tree Head: `SignedTreeHead`, `signSth`, `verifySthSignature`,
* `canonicalSthBytes`, `computeLogId`, `STHWire`.
* - Bundle proofs: `KTProof`, `verifyBundleInclusion`, `verifyBundleAbsence`,
* `verifyBundleTombstone`, `ktProofToWire`, `ktProofFromWire`.
* - Manager (server-side orchestration): `KTLogManager`.
* - Stores: `KTLogStore` interface + `MemoryKTLogStore`.
* - Witness: `LightWitness`, `WitnessFetcher`.
* - Errors: `KTError` and subclasses.
*/
export {
DOMAIN_BUNDLE,
DOMAIN_STH,
OP_DELETE,
OP_REGISTER,
OP_REPLENISH,
computeBundleHash,
emptyRootHash,
encodeLeafData,
leafHash,
nodeHash,
} from './hashes.js';
export {
MerkleLog,
auditPath,
consistencyProof,
mth,
recomputeRootFromAuditPath,
verifyConsistencyProof,
} from './log.js';
export {
AddressIndex,
compareAddresses,
computeIndexRoot,
encodeIndexEntry,
verifyAbsenceProof,
verifyInclusionProof,
} from './index-tree.js';
export type {
AddressIndexEntry,
IndexAbsenceProof,
IndexInclusionProof,
IndexProof,
} from './index-tree.js';
export {
canonicalSthBytes,
computeLogId,
signSth,
sthFromWire,
sthToWire,
verifySthSignature,
} from './sth.js';
export type { SignedTreeHead, STHWire } from './sth.js';
export {
ktProofFromWire,
ktProofToWire,
verifyBundleAbsence,
verifyBundleInclusion,
verifyBundleTombstone,
} from './proof.js';
export type {
KTBundleAbsenceProof,
KTBundleInclusionProof,
KTBundleTombstoneProof,
KTProof,
KTProofBody,
KTProofWire,
KTVerifyOptions,
} from './proof.js';
export { KTLogManager } from './manager.js';
export type { KTLogManagerOptions } from './manager.js';
export { MemoryKTLogStore } from './memory-store.js';
export type { KTLogLeaf, KTLogStore } from './store.js';
export { LightWitness } from './witness.js';
export type { LightWitnessOptions, WitnessFetcher, WitnessObservation } from './witness.js';
export {
KTError,
KTLogIdMismatchError,
KTSplitViewError,
KTStaleSTHError,
KTVerificationError,
} from './errors.js';
export { fromBase64 as ktFromBase64, toBase64 as ktToBase64 } from './util.js';

View File

@@ -0,0 +1,273 @@
/**
* RFC 6962-compatible Merkle Hash Tree (MTH) over an append-only list
* of pre-hashed leaves.
*
* Recurrence (RFC 6962 §2.1):
*
* MTH({}) = SHA-256()
* MTH({d(0)}) = leaf_hash(d(0))
* MTH(D[n]) = node_hash( MTH(D[0:k]), MTH(D[k:n]) )
* where k = largest power of 2 < n
*
* `MerkleLog` stores **leaf hashes** (already prefixed with 0x00) and
* recomputes the tree on demand. Storage is O(N); audit-path / consistency
* computation is O(log N) per leaf. This is acceptable for prekey-server
* scale (≤ ~5M leaves over a decade for 100k addresses).
*/
import { emptyRootHash, leafHash, nodeHash } from './hashes.js';
import { constantTimeEqual } from './util.js';
/** Largest power of two strictly less than n (for n ≥ 2). */
function largestPow2LessThan(n: number): number {
if (n < 2) throw new Error('largestPow2LessThan requires n >= 2');
let k = 1;
while (k < n) k <<= 1;
return k >>> 1;
}
/**
* Compute the Merkle Tree Hash (MTH) over a slice of pre-hashed leaves.
* Used internally by audit-path / consistency-proof builders.
*/
export function mth(leaves: Uint8Array[], lo: number, hi: number): Uint8Array {
const n = hi - lo;
if (n === 0) return emptyRootHash();
if (n === 1) return leaves[lo]!;
const k = largestPow2LessThan(n);
return nodeHash(mth(leaves, lo, lo + k), mth(leaves, lo + k, hi));
}
/**
* Build the audit path for the leaf at index `m` in a tree of size `n`.
*
* RFC 6962 §2.1.1:
* PATH(m, D[n]) = PATH(m, D[0:k]) : MTH(D[k:n]) if m < k
* PATH(m, D[n]) = PATH(m-k, D[k:n]) : MTH(D[0:k]) if m >= k
*
* The returned array is ordered from leaf-sibling outward to root-sibling.
* Each entry is the *hash of the sibling subtree*; the verifier reconstructs
* the root using `audit_path_hash` below.
*/
export function auditPath(leaves: Uint8Array[], m: number, n: number): Uint8Array[] {
if (n <= 0) throw new Error('auditPath requires n > 0');
if (m < 0 || m >= n) throw new Error(`m out of range: ${m} of ${n}`);
return auditPathInner(leaves, m, 0, n);
}
function auditPathInner(
leaves: Uint8Array[],
m: number,
lo: number,
hi: number,
): Uint8Array[] {
const n = hi - lo;
if (n === 1) return [];
const k = largestPow2LessThan(n);
if (m < k) {
return [...auditPathInner(leaves, m, lo, lo + k), mth(leaves, lo + k, hi)];
}
return [...auditPathInner(leaves, m - k, lo + k, hi), mth(leaves, lo, lo + k)];
}
/**
* Reconstruct the Merkle root from a leaf, its index, the tree size, and
* an audit path. RFC 6962 §2.1.1.
*
* Returns the recomputed root; the caller compares (constant-time) against
* the STH root to verify inclusion.
*/
export function recomputeRootFromAuditPath(
leaf: Uint8Array,
m: number,
n: number,
path: Uint8Array[],
): Uint8Array {
if (n <= 0) throw new Error('recomputeRoot requires n > 0');
if (m < 0 || m >= n) throw new Error(`m out of range: ${m} of ${n}`);
return recomputeRootInner(leaf, m, 0, n, path, 0).root;
}
function recomputeRootInner(
leaf: Uint8Array,
m: number,
lo: number,
hi: number,
path: Uint8Array[],
pathIdx: number,
): { root: Uint8Array; pathIdx: number } {
const n = hi - lo;
if (n === 1) return { root: leaf, pathIdx };
const k = largestPow2LessThan(n);
if (m < k) {
const left = recomputeRootInner(leaf, m, lo, lo + k, path, pathIdx);
const sibling = path[left.pathIdx];
if (!sibling) throw new Error('audit path too short');
return { root: nodeHash(left.root, sibling), pathIdx: left.pathIdx + 1 };
}
const right = recomputeRootInner(leaf, m - k, lo + k, hi, path, pathIdx);
const sibling = path[right.pathIdx];
if (!sibling) throw new Error('audit path too short');
return { root: nodeHash(sibling, right.root), pathIdx: right.pathIdx + 1 };
}
/**
* Build a consistency proof between tree sizes m (older) and n (newer).
* RFC 6962 §2.1.2.
*/
export function consistencyProof(
leaves: Uint8Array[],
m: number,
n: number,
): Uint8Array[] {
if (m < 0 || n < m) throw new Error(`invalid m,n: ${m},${n}`);
if (m === 0 || m === n) return [];
return subProof(leaves, m, 0, n, true);
}
function subProof(
leaves: Uint8Array[],
m: number,
lo: number,
hi: number,
isOriginalRoot: boolean,
): Uint8Array[] {
const n = hi - lo;
if (m === n) {
return isOriginalRoot ? [] : [mth(leaves, lo, hi)];
}
const k = largestPow2LessThan(n);
if (m <= k) {
return [...subProof(leaves, m, lo, lo + k, isOriginalRoot), mth(leaves, lo + k, hi)];
}
return [...subProof(leaves, m - k, lo + k, hi, false), mth(leaves, lo, lo + k)];
}
/**
* Verify a consistency proof. Given:
* - oldRoot = MTH(D[0:m])
* - newRoot = MTH(D[0:n]) with n >= m
* - proof = consistencyProof(leaves, m, n)
*
* Returns true if the proof is valid (i.e. D[0:n] really is an extension
* of D[0:m]). RFC 6962 §2.1.2.
*/
export function verifyConsistencyProof(
m: number,
n: number,
oldRoot: Uint8Array,
newRoot: Uint8Array,
proof: Uint8Array[],
): boolean {
if (m < 0 || n < m) return false;
if (m === 0) return true; // any newRoot is consistent with empty old tree
if (m === n) return proof.length === 0 && constantTimeEqual(oldRoot, newRoot);
// RFC 6962 verification recurrence
let path = proof;
if (isPowerOfTwo(m)) {
path = [oldRoot, ...path];
}
let fn = m - 1;
let sn = n - 1;
while ((fn & 1) === 1) {
fn >>= 1;
sn >>= 1;
}
if (path.length === 0) return false;
let fr = path[0]!;
let sr = path[0]!;
let i = 1;
while (sn > 0) {
if ((fn & 1) === 1 || fn === sn) {
if (i >= path.length) return false;
const c = path[i++]!;
fr = nodeHash(c, fr);
sr = nodeHash(c, sr);
while ((fn & 1) === 0 && fn !== 0) {
fn >>= 1;
sn >>= 1;
}
} else {
if (i >= path.length) return false;
sr = nodeHash(sr, path[i++]!);
}
fn >>= 1;
sn >>= 1;
}
if (i !== path.length) return false;
return constantTimeEqual(fr, oldRoot) && constantTimeEqual(sr, newRoot);
}
function isPowerOfTwo(n: number): boolean {
return n > 0 && (n & (n - 1)) === 0;
}
/**
* Append-only Merkle log used server-side. Holds leaf hashes in memory
* and recomputes paths on demand.
*
* For production deployments the caller wraps this with a persistent
* `KTLogStore` (see `store.ts`) — this class is the algorithmic core.
*/
export class MerkleLog {
private readonly leaves: Uint8Array[] = [];
/** Number of leaves currently in the tree. */
get size(): number {
return this.leaves.length;
}
/** Append a *raw leaf data* (will be domain-separated and hashed). */
appendData(data: Uint8Array): { index: number; leafHash: Uint8Array } {
const lh = leafHash(data);
const index = this.leaves.length;
this.leaves.push(lh);
return { index, leafHash: lh };
}
/** Append a leaf that has *already been hashed* (rebuild path). */
appendLeafHash(lh: Uint8Array): number {
const index = this.leaves.length;
this.leaves.push(lh);
return index;
}
/** Current root hash (MTH of all leaves). */
rootHash(): Uint8Array {
return mth(this.leaves, 0, this.leaves.length);
}
/** Audit path for leaf at `index`. */
auditPath(index: number): Uint8Array[] {
return auditPath(this.leaves, index, this.leaves.length);
}
/** Consistency proof from `oldSize` to current size. */
consistencyProof(oldSize: number): Uint8Array[] {
return consistencyProof(this.leaves, oldSize, this.leaves.length);
}
/** Snapshot the leaf hash at `index` (read-only). */
leafHashAt(index: number): Uint8Array {
const lh = this.leaves[index];
if (!lh) throw new Error(`no leaf at index ${index}`);
return lh;
}
/** Defensive copy of all leaves (used by persistent stores on hot-load). */
exportLeaves(): Uint8Array[] {
return this.leaves.map((l) => new Uint8Array(l));
}
/** Hot-load from persisted leaf hashes. */
static fromLeaves(leaves: Uint8Array[]): MerkleLog {
const log = new MerkleLog();
for (const l of leaves) log.appendLeafHash(l);
return log;
}
}

View File

@@ -0,0 +1,274 @@
/**
* KTLogManager — server-side orchestration of the log + address index.
*
* Wraps a `KTLogStore` with the algorithmic primitives so that callers
* (the prekey-server integration layer) never have to think about Merkle
* paths, index commitments, or STH signing in isolation.
*
* Lifecycle:
* const mgr = await KTLogManager.create({ crypto, store, signingKey });
* await mgr.recordRegister(address, bundleHash, timestampMs);
* const sth = await mgr.publishSTH();
* const proof = await mgr.buildBundleInclusionProof(address);
*
* Concurrency: the manager is single-writer. Server callers must serialize
* mutations behind a mutex (the integration layer does this with an in-process
* lock that is sufficient for the single-instance default deployment;
* multi-instance HA requires external coordination — documented in
* docs/key-transparency.md §"Recovery and HA").
*/
import type { CryptoProvider } from '@shade/core';
import { OP_DELETE, OP_REGISTER, OP_REPLENISH, encodeLeafData, leafHash } from './hashes.js';
import { MerkleLog, auditPath } from './log.js';
import {
AddressIndex,
type AddressIndexEntry,
type IndexAbsenceProof,
type IndexInclusionProof,
} from './index-tree.js';
import {
type SignedTreeHead,
computeLogId,
signSth,
} from './sth.js';
import type { KTLogStore } from './store.js';
import type {
KTBundleAbsenceProof,
KTBundleInclusionProof,
KTBundleTombstoneProof,
KTProof,
} from './proof.js';
import { consistencyProof, verifyConsistencyProof } from './log.js';
export interface KTLogManagerOptions {
crypto: CryptoProvider;
store: KTLogStore;
/** Operator's Ed25519 signing key (private, 32-byte seed). */
signingPrivateKey: Uint8Array;
/** Operator's Ed25519 signing public key (pinned by clients). */
signingPublicKey: Uint8Array;
/** Time source — defaults to `Date.now()`. */
now?: () => number;
}
export class KTLogManager {
private readonly crypto: CryptoProvider;
private readonly store: KTLogStore;
private readonly signingPrivateKey: Uint8Array;
private readonly signingPublicKey: Uint8Array;
private readonly logId: Uint8Array;
private readonly now: () => number;
// In-memory mirror of the persistent state for fast proof generation.
private merkleLog: MerkleLog;
private addressIndex: AddressIndex;
private constructor(opts: KTLogManagerOptions, log: MerkleLog, idx: AddressIndex) {
this.crypto = opts.crypto;
this.store = opts.store;
this.signingPrivateKey = opts.signingPrivateKey;
this.signingPublicKey = opts.signingPublicKey;
this.logId = computeLogId(opts.signingPublicKey);
this.now = opts.now ?? (() => Date.now());
this.merkleLog = log;
this.addressIndex = idx;
}
static async create(opts: KTLogManagerOptions): Promise<KTLogManager> {
const size = await opts.store.size();
const leaves = await opts.store.getLeaves(0, size);
const log = MerkleLog.fromLeaves(leaves.map((l) => l.leafHash));
const idx = AddressIndex.fromEntries(await opts.store.getAllIndexEntries());
return new KTLogManager(opts, log, idx);
}
/** Operator's pinned signing public key, for callers to ship to clients. */
getSigningPublicKey(): Uint8Array {
return new Uint8Array(this.signingPublicKey);
}
/** log_id (== sha256(signingPublicKey)). */
getLogId(): Uint8Array {
return new Uint8Array(this.logId);
}
/** Current tree size (number of leaves). */
getTreeSize(): number {
return this.merkleLog.size;
}
/** Record a register/rotate event. Bundle hash is the canonical bundle commit. */
async recordRegister(
address: string,
bundleHash: Uint8Array,
timestampMs?: number,
): Promise<{ leafIndex: number }> {
return this.recordOperation(address, OP_REGISTER, bundleHash, timestampMs);
}
/**
* Record a "replenish" event. Per §11 of the design notat, replenish does
* NOT mutate bundle_hash (one-time prekeys aren't part of the commitment).
* In practice this method is rarely called — kept for cases where the
* operator wants liveness-evidence of OTP top-ups in the log. When used,
* the leaf's bundle_hash equals the current index entry's bundle_hash.
*/
async recordReplenish(
address: string,
timestampMs?: number,
): Promise<{ leafIndex: number } | null> {
const existing = await this.store.getIndexEntry(address);
if (!existing) return null;
return this.recordOperation(address, OP_REPLENISH, existing.bundleHash, timestampMs);
}
/** Record an unregister/tombstone event. */
async recordDelete(address: string, timestampMs?: number): Promise<{ leafIndex: number }> {
const result = await this.recordOperation(
address,
OP_DELETE,
new Uint8Array(0),
timestampMs,
);
await this.store.tombstoneIndexEntry(address, result.leafIndex);
this.addressIndex.tombstone(address, result.leafIndex);
return result;
}
private async recordOperation(
address: string,
operation: number,
bundleHash: Uint8Array,
timestampMsOpt?: number,
): Promise<{ leafIndex: number }> {
const timestampMs = timestampMsOpt ?? this.now();
const data = encodeLeafData(timestampMs, operation, address, bundleHash);
const lh = leafHash(data);
const index = await this.store.appendLeaf({
leafHash: lh,
timestampMs,
operation,
address,
bundleHash,
});
this.merkleLog.appendLeafHash(lh);
if (operation !== OP_DELETE) {
const entry: AddressIndexEntry = {
address,
latestLeafIndex: index,
bundleHash,
deleted: false,
};
await this.store.upsertIndexEntry(entry);
this.addressIndex.upsert(entry);
}
return { leafIndex: index };
}
/** Re-sign and persist the current STH. Idempotent if no change since last call. */
async publishSTH(timestampMsOpt?: number): Promise<SignedTreeHead> {
const treeSize = this.merkleLog.size;
const rootHash = this.merkleLog.rootHash();
const indexRoot = this.addressIndex.rootHash();
const timestampMs = timestampMsOpt ?? this.now();
const sth = await signSth(this.crypto, this.signingPrivateKey, {
treeSize,
timestampMs,
rootHash,
indexRoot,
logId: this.logId,
});
await this.store.saveSTH(sth);
return sth;
}
/** Build an inclusion proof for the address's *latest* event. */
async buildBundleInclusionProof(
address: string,
sth: SignedTreeHead,
): Promise<KTProof | null> {
const indexEntry = this.addressIndex.get(address);
if (!indexEntry) return null;
const leaf = await this.store.getLeaf(indexEntry.latestLeafIndex);
if (!leaf) return null;
if (sth.treeSize <= indexEntry.latestLeafIndex) return null;
// Audit path is over the snapshot at sth.treeSize. The current in-memory
// log is exactly that size when the manager produced this STH.
const path = this.auditPathAt(indexEntry.latestLeafIndex, sth.treeSize);
const indexProof = this.addressIndex.inclusionProof(address);
if (!indexProof) return null;
if (indexEntry.deleted) {
const body: KTBundleTombstoneProof = {
kind: 'tombstone',
leafIndex: indexEntry.latestLeafIndex,
leafTimestampMs: leaf.timestampMs,
operation: leaf.operation,
auditPath: path,
indexProof,
};
return { sth, body };
}
const body: KTBundleInclusionProof = {
kind: 'inclusion',
leafIndex: indexEntry.latestLeafIndex,
leafTimestampMs: leaf.timestampMs,
operation: leaf.operation,
auditPath: path,
indexProof,
};
return { sth, body };
}
/** Build an absence proof for an address that does not exist. */
buildBundleAbsenceProof(address: string, sth: SignedTreeHead): KTProof | null {
const indexProof = this.addressIndex.absenceProof(address);
if (!indexProof) return null; // address actually exists
const body: KTBundleAbsenceProof = { kind: 'absence', indexProof };
return { sth, body };
}
/**
* Compute a consistency proof from `oldTreeSize` to current. Works against
* the in-memory log; for very-large logs this becomes O(N) and a future
* persistent-only path may be needed.
*/
async buildConsistencyProof(oldTreeSize: number): Promise<{
proof: Uint8Array[];
fromTreeSize: number;
toTreeSize: number;
}> {
const size = this.merkleLog.size;
const leaves = this.merkleLog.exportLeaves();
const proof = consistencyProof(leaves, oldTreeSize, size);
return { proof, fromTreeSize: oldTreeSize, toTreeSize: size };
}
/**
* Compute a consistency proof between two arbitrary historical sizes.
* Reads leaves from the persistent store (so older snapshots can be proven
* even if the in-memory log has grown further).
*/
async buildHistoricalConsistencyProof(oldSize: number, newSize: number): Promise<Uint8Array[]> {
if (newSize > this.merkleLog.size) {
throw new Error(`newSize ${newSize} exceeds current tree size ${this.merkleLog.size}`);
}
const leaves = this.merkleLog.exportLeaves();
return consistencyProof(leaves, oldSize, newSize);
}
// ─── Helpers ──────────────────────────────────────────
private auditPathAt(leafIndex: number, treeSize: number): Uint8Array[] {
const leaves = this.merkleLog.exportLeaves().slice(0, treeSize);
return auditPath(leaves, leafIndex, leaves.length);
}
}
export { verifyConsistencyProof };

View File

@@ -0,0 +1,149 @@
import type { KTLogLeaf, KTLogStore } from './store.js';
import type { AddressIndexEntry } from './index-tree.js';
import type { SignedTreeHead } from './sth.js';
import { compareAddresses } from './index-tree.js';
import { constantTimeEqual } from './util.js';
/**
* In-memory KTLogStore for testing and embedded servers.
*
* Maintains the same append-only invariants as a persistent store —
* `appendLeaf` cannot mutate prior entries, only push.
*/
export class MemoryKTLogStore implements KTLogStore {
private leaves: KTLogLeaf[] = [];
private indexByAddress = new Map<string, AddressIndexEntry>();
private sthsByTreeSize = new Map<number, SignedTreeHead[]>();
private latestSth: SignedTreeHead | null = null;
async appendLeaf(input: Omit<KTLogLeaf, 'index'>): Promise<number> {
const index = this.leaves.length;
this.leaves.push({
...input,
index,
leafHash: new Uint8Array(input.leafHash),
bundleHash: new Uint8Array(input.bundleHash),
});
return index;
}
async getLeaves(fromIndex: number, toIndex: number): Promise<KTLogLeaf[]> {
return this.leaves.slice(fromIndex, toIndex).map((l) => ({
...l,
leafHash: new Uint8Array(l.leafHash),
bundleHash: new Uint8Array(l.bundleHash),
}));
}
async getLeaf(index: number): Promise<KTLogLeaf | null> {
const l = this.leaves[index];
if (!l) return null;
return {
...l,
leafHash: new Uint8Array(l.leafHash),
bundleHash: new Uint8Array(l.bundleHash),
};
}
async size(): Promise<number> {
return this.leaves.length;
}
async upsertIndexEntry(entry: AddressIndexEntry): Promise<void> {
this.indexByAddress.set(entry.address, {
...entry,
bundleHash: new Uint8Array(entry.bundleHash),
});
}
async tombstoneIndexEntry(address: string, latestLeafIndex: number): Promise<void> {
const e = this.indexByAddress.get(address);
if (!e) return;
this.indexByAddress.set(address, {
...e,
deleted: true,
latestLeafIndex,
bundleHash: new Uint8Array(0),
});
}
async getAllIndexEntries(): Promise<AddressIndexEntry[]> {
const all = Array.from(this.indexByAddress.values()).map((e) => ({
...e,
bundleHash: new Uint8Array(e.bundleHash),
}));
all.sort((a, b) => compareAddresses(a.address, b.address));
return all;
}
async getIndexEntry(address: string): Promise<AddressIndexEntry | null> {
const e = this.indexByAddress.get(address);
if (!e) return null;
return { ...e, bundleHash: new Uint8Array(e.bundleHash) };
}
async saveSTH(sth: SignedTreeHead): Promise<void> {
const cloned: SignedTreeHead = {
treeSize: sth.treeSize,
timestampMs: sth.timestampMs,
rootHash: new Uint8Array(sth.rootHash),
indexRoot: new Uint8Array(sth.indexRoot),
logId: new Uint8Array(sth.logId),
signature: new Uint8Array(sth.signature),
};
const list = this.sthsByTreeSize.get(sth.treeSize) ?? [];
// De-duplicate (same root_hash + signature == same STH)
const dup = list.find(
(existing) =>
existing.timestampMs === cloned.timestampMs &&
constantTimeEqual(existing.rootHash, cloned.rootHash) &&
constantTimeEqual(existing.signature, cloned.signature),
);
if (!dup) list.push(cloned);
this.sthsByTreeSize.set(sth.treeSize, list);
if (
!this.latestSth ||
cloned.treeSize > this.latestSth.treeSize ||
(cloned.treeSize === this.latestSth.treeSize && cloned.timestampMs > this.latestSth.timestampMs)
) {
this.latestSth = cloned;
}
}
async getLatestSTH(): Promise<SignedTreeHead | null> {
return this.latestSth ? cloneSth(this.latestSth) : null;
}
async getSTHByTreeSize(treeSize: number): Promise<SignedTreeHead | null> {
const list = this.sthsByTreeSize.get(treeSize);
if (!list || list.length === 0) return null;
// Pick the most recent one for this tree size.
let best = list[0]!;
for (const s of list) if (s.timestampMs > best.timestampMs) best = s;
return cloneSth(best);
}
async listSTHs(fromTimestampMs?: number, toTimestampMs?: number): Promise<SignedTreeHead[]> {
const all: SignedTreeHead[] = [];
for (const list of this.sthsByTreeSize.values()) {
for (const s of list) {
if (fromTimestampMs !== undefined && s.timestampMs < fromTimestampMs) continue;
if (toTimestampMs !== undefined && s.timestampMs > toTimestampMs) continue;
all.push(cloneSth(s));
}
}
all.sort((a, b) => a.timestampMs - b.timestampMs);
return all;
}
}
function cloneSth(sth: SignedTreeHead): SignedTreeHead {
return {
treeSize: sth.treeSize,
timestampMs: sth.timestampMs,
rootHash: new Uint8Array(sth.rootHash),
indexRoot: new Uint8Array(sth.indexRoot),
logId: new Uint8Array(sth.logId),
signature: new Uint8Array(sth.signature),
};
}

View File

@@ -0,0 +1,453 @@
/**
* Combined KT proof attached to a bundle response.
*
* Wire shape (returned by GET /v1/keys/bundle/:address when KT is on):
*
* {
* bundle: { ... },
* ktProof: {
* sth: STHWire,
* inclusion: {
* leafIndex: number,
* leafTimestampMs: number,
* operation: number,
* auditPath: string[] // base64 sibling hashes
* },
* indexProof: IndexInclusionProof | IndexAbsenceProof (wire-encoded)
* }
* }
*
* The verifier:
* 1. Confirms STH signature against pinned log_public_key.
* 2. Confirms STH timestamp is fresh (<= maxStaleMs old).
* 3. Re-derives bundle_hash from the bundle and re-builds the leaf.
* 4. Verifies inclusion against sth.root_hash via auditPath.
* 5. Verifies index proof (inclusion w/ matching bundle hash, or absence
* proof for tombstoned address) against sth.index_root.
*/
import type { CryptoProvider } from '@shade/core';
import { computeBundleHash, encodeLeafData, leafHash, OP_DELETE } from './hashes.js';
import { recomputeRootFromAuditPath } from './log.js';
import {
type SignedTreeHead,
type STHWire,
sthFromWire,
sthToWire,
verifySthSignature,
} from './sth.js';
import {
type AddressIndexEntry,
type IndexAbsenceProof,
type IndexInclusionProof,
verifyInclusionProof,
verifyAbsenceProof,
} from './index-tree.js';
import {
KTLogIdMismatchError,
KTStaleSTHError,
KTVerificationError,
} from './errors.js';
import { computeLogId } from './sth.js';
import { constantTimeEqual, fromBase64, toBase64 } from './util.js';
/** Exists-style proof: leaf is in the log, address is in the index. */
export interface KTBundleInclusionProof {
kind: 'inclusion';
leafIndex: number;
leafTimestampMs: number;
operation: number;
auditPath: Uint8Array[];
indexProof: IndexInclusionProof;
}
/** Tombstone proof: latest leaf for the address is a delete; index entry is deleted. */
export interface KTBundleTombstoneProof {
kind: 'tombstone';
leafIndex: number;
leafTimestampMs: number;
/** operation will be OP_DELETE here. */
operation: number;
auditPath: Uint8Array[];
indexProof: IndexInclusionProof;
}
/** Absence proof: address has never been registered. */
export interface KTBundleAbsenceProof {
kind: 'absence';
indexProof: IndexAbsenceProof;
}
export type KTProofBody = KTBundleInclusionProof | KTBundleTombstoneProof | KTBundleAbsenceProof;
export interface KTProof {
sth: SignedTreeHead;
body: KTProofBody;
}
// ─── Wire encoding ────────────────────────────────────────
interface IndexInclusionWire {
kind: 'inclusion';
position: number;
treeSize: number;
entry: { address: string; latestLeafIndex: number; bundleHash: string; deleted: boolean };
auditPath: string[];
}
interface IndexAbsenceWire {
kind: 'absence';
treeSize: number;
queryAddress: string;
prev: {
position: number;
entry: { address: string; latestLeafIndex: number; bundleHash: string; deleted: boolean };
auditPath: string[];
} | null;
next: {
position: number;
entry: { address: string; latestLeafIndex: number; bundleHash: string; deleted: boolean };
auditPath: string[];
} | null;
}
type IndexProofWire = IndexInclusionWire | IndexAbsenceWire;
interface BundleInclusionWire {
kind: 'inclusion' | 'tombstone';
leafIndex: number;
leafTimestampMs: number;
operation: number;
auditPath: string[];
indexProof: IndexInclusionWire;
}
interface BundleAbsenceWire {
kind: 'absence';
indexProof: IndexAbsenceWire;
}
type KTProofBodyWire = BundleInclusionWire | BundleAbsenceWire;
export interface KTProofWire {
sth: STHWire;
body: KTProofBodyWire;
}
function entryToWire(e: AddressIndexEntry) {
return {
address: e.address,
latestLeafIndex: e.latestLeafIndex,
bundleHash: toBase64(e.bundleHash),
deleted: e.deleted,
};
}
function entryFromWire(w: {
address: string;
latestLeafIndex: number;
bundleHash: string;
deleted: boolean;
}): AddressIndexEntry {
return {
address: w.address,
latestLeafIndex: w.latestLeafIndex,
bundleHash: fromBase64(w.bundleHash),
deleted: w.deleted,
};
}
function indexInclusionToWire(p: IndexInclusionProof): IndexInclusionWire {
return {
kind: 'inclusion',
position: p.position,
treeSize: p.treeSize,
entry: entryToWire(p.entry),
auditPath: p.auditPath.map(toBase64),
};
}
function indexInclusionFromWire(w: IndexInclusionWire): IndexInclusionProof {
return {
kind: 'inclusion',
position: w.position,
treeSize: w.treeSize,
entry: entryFromWire(w.entry),
auditPath: w.auditPath.map(fromBase64),
};
}
function indexAbsenceToWire(p: IndexAbsenceProof): IndexAbsenceWire {
return {
kind: 'absence',
treeSize: p.treeSize,
queryAddress: p.queryAddress,
prev: p.prev
? {
position: p.prev.position,
entry: entryToWire(p.prev.entry),
auditPath: p.prev.auditPath.map(toBase64),
}
: null,
next: p.next
? {
position: p.next.position,
entry: entryToWire(p.next.entry),
auditPath: p.next.auditPath.map(toBase64),
}
: null,
};
}
function indexAbsenceFromWire(w: IndexAbsenceWire): IndexAbsenceProof {
return {
kind: 'absence',
treeSize: w.treeSize,
queryAddress: w.queryAddress,
prev: w.prev
? {
position: w.prev.position,
entry: entryFromWire(w.prev.entry),
auditPath: w.prev.auditPath.map(fromBase64),
}
: null,
next: w.next
? {
position: w.next.position,
entry: entryFromWire(w.next.entry),
auditPath: w.next.auditPath.map(fromBase64),
}
: null,
};
}
export function ktProofToWire(proof: KTProof): KTProofWire {
const sth = sthToWire(proof.sth, toBase64);
let body: KTProofBodyWire;
if (proof.body.kind === 'absence') {
body = { kind: 'absence', indexProof: indexAbsenceToWire(proof.body.indexProof) };
} else {
body = {
kind: proof.body.kind,
leafIndex: proof.body.leafIndex,
leafTimestampMs: proof.body.leafTimestampMs,
operation: proof.body.operation,
auditPath: proof.body.auditPath.map(toBase64),
indexProof: indexInclusionToWire(proof.body.indexProof),
};
}
return { sth, body };
}
export function ktProofFromWire(wire: KTProofWire): KTProof {
const sth = sthFromWire(wire.sth, fromBase64);
let body: KTProofBody;
if (wire.body.kind === 'absence') {
body = { kind: 'absence', indexProof: indexAbsenceFromWire(wire.body.indexProof) };
} else if (wire.body.kind === 'tombstone') {
body = {
kind: 'tombstone',
leafIndex: wire.body.leafIndex,
leafTimestampMs: wire.body.leafTimestampMs,
operation: wire.body.operation,
auditPath: wire.body.auditPath.map(fromBase64),
indexProof: indexInclusionFromWire(wire.body.indexProof),
};
} else {
body = {
kind: 'inclusion',
leafIndex: wire.body.leafIndex,
leafTimestampMs: wire.body.leafTimestampMs,
operation: wire.body.operation,
auditPath: wire.body.auditPath.map(fromBase64),
indexProof: indexInclusionFromWire(wire.body.indexProof),
};
}
return { sth, body };
}
// ─── Verifier ────────────────────────────────────────────
export interface KTVerifyOptions {
crypto: CryptoProvider;
/** Pinned log signing public key (Ed25519, 32 bytes). */
logPublicKey: Uint8Array;
/** Reject STH older than this many milliseconds. Default 24h. */
maxStaleMs?: number;
/** `now` for time-checks. Defaults to `Date.now()`. */
nowMs?: number;
/** Allow STH timestamps slightly in the future (clock-skew). Default 60 s. */
futureSkewMs?: number;
}
const DEFAULT_MAX_STALE_MS = 24 * 60 * 60 * 1000;
const DEFAULT_FUTURE_SKEW_MS = 60_000;
/**
* Verify an inclusion KT proof for a freshly fetched bundle.
*
* Throws on any failure (signature mismatch, stale STH, broken audit
* path, address-mismatch, bundle-hash mismatch). On success returns the
* STH so callers can cache it for split-view detection.
*/
export async function verifyBundleInclusion(
options: KTVerifyOptions,
address: string,
bundle: {
identitySigningKey: Uint8Array;
identityDHKey: Uint8Array;
signedPreKey: { keyId: number; publicKey: Uint8Array; signature: Uint8Array };
},
proof: KTProof,
): Promise<SignedTreeHead> {
if (proof.body.kind !== 'inclusion') {
throw new KTVerificationError(`expected inclusion proof, got ${proof.body.kind}`);
}
await verifyStaticSth(options, proof.sth);
// 1. Re-derive bundle_hash and confirm it matches the index entry.
const bundleHash = computeBundleHash(bundle);
const indexEntry = proof.body.indexProof.entry;
if (indexEntry.address !== address) {
throw new KTVerificationError(`index entry address mismatch: ${indexEntry.address} != ${address}`);
}
if (indexEntry.deleted) {
throw new KTVerificationError('index entry marked deleted, but inclusion proof claims live');
}
if (!constantTimeEqual(indexEntry.bundleHash, bundleHash)) {
throw new KTVerificationError('bundle hash does not match committed index entry');
}
// 2. Verify log inclusion.
const leafBytes = encodeLeafData(
proof.body.leafTimestampMs,
proof.body.operation,
address,
bundleHash,
);
const expectedLeafHash = leafHash(leafBytes);
let recomputedRoot: Uint8Array;
try {
recomputedRoot = recomputeRootFromAuditPath(
expectedLeafHash,
proof.body.leafIndex,
proof.sth.treeSize,
proof.body.auditPath,
);
} catch (err) {
throw new KTVerificationError(`audit path malformed: ${(err as Error).message}`);
}
if (!constantTimeEqual(recomputedRoot, proof.sth.rootHash)) {
throw new KTVerificationError('audit path does not yield STH root_hash');
}
// 3. Verify the index inclusion proof.
if (proof.body.indexProof.entry.latestLeafIndex !== proof.body.leafIndex) {
throw new KTVerificationError('index entry latestLeafIndex mismatch with log leaf');
}
if (!verifyInclusionProof(proof.body.indexProof, proof.sth.indexRoot)) {
throw new KTVerificationError('index inclusion proof failed');
}
return proof.sth;
}
/**
* Verify an absence KT proof — used when the server replies "no such
* address". The verifier returns `null` to indicate absence (vs. a
* verified live STH). On any failure it throws.
*/
export async function verifyBundleAbsence(
options: KTVerifyOptions,
address: string,
proof: KTProof,
): Promise<SignedTreeHead> {
if (proof.body.kind !== 'absence') {
throw new KTVerificationError(`expected absence proof, got ${proof.body.kind}`);
}
await verifyStaticSth(options, proof.sth);
if (proof.body.indexProof.queryAddress !== address) {
throw new KTVerificationError('absence proof query address mismatch');
}
if (!verifyAbsenceProof(proof.body.indexProof, proof.sth.indexRoot)) {
throw new KTVerificationError('absence proof failed');
}
return proof.sth;
}
/**
* Verify a tombstone proof — the address used to exist but has been
* deleted. The verifier returns the STH so split-view caching still
* applies; callers treat the bundle as "not available".
*/
export async function verifyBundleTombstone(
options: KTVerifyOptions,
address: string,
proof: KTProof,
): Promise<SignedTreeHead> {
if (proof.body.kind !== 'tombstone') {
throw new KTVerificationError(`expected tombstone proof, got ${proof.body.kind}`);
}
await verifyStaticSth(options, proof.sth);
if (proof.body.operation !== OP_DELETE) {
throw new KTVerificationError('tombstone proof must reference an OP_DELETE leaf');
}
if (proof.body.indexProof.entry.address !== address) {
throw new KTVerificationError('tombstone index entry address mismatch');
}
if (!proof.body.indexProof.entry.deleted) {
throw new KTVerificationError('tombstone proof index entry is not marked deleted');
}
// Deleted entries have empty bundleHash but the leaf data still uses an
// empty hash too; encode and verify the audit path with that.
const leafBytes = encodeLeafData(
proof.body.leafTimestampMs,
proof.body.operation,
address,
proof.body.indexProof.entry.bundleHash,
);
const expectedLeafHash = leafHash(leafBytes);
let recomputedRoot: Uint8Array;
try {
recomputedRoot = recomputeRootFromAuditPath(
expectedLeafHash,
proof.body.leafIndex,
proof.sth.treeSize,
proof.body.auditPath,
);
} catch (err) {
throw new KTVerificationError(`audit path malformed: ${(err as Error).message}`);
}
if (!constantTimeEqual(recomputedRoot, proof.sth.rootHash)) {
throw new KTVerificationError('tombstone audit path does not yield STH root_hash');
}
if (!verifyInclusionProof(proof.body.indexProof, proof.sth.indexRoot)) {
throw new KTVerificationError('tombstone index inclusion proof failed');
}
return proof.sth;
}
async function verifyStaticSth(options: KTVerifyOptions, sth: SignedTreeHead): Promise<void> {
const expectedLogId = computeLogId(options.logPublicKey);
if (!constantTimeEqual(expectedLogId, sth.logId)) {
throw new KTLogIdMismatchError('STH log_id does not match pinned log_public_key');
}
const sigOk = await verifySthSignature(options.crypto, sth, options.logPublicKey);
if (!sigOk) {
throw new KTVerificationError('STH signature did not verify');
}
// Encode entry into the in-memory canonical bytes used by the leaf and
// confirm the leaf actually re-encodes to the right bytes when the entry
// says it's present (we let the caller do this to keep per-mode flow
// small).
const now = options.nowMs ?? Date.now();
const maxStale = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
const futureSkew = options.futureSkewMs ?? DEFAULT_FUTURE_SKEW_MS;
if (sth.timestampMs > now + futureSkew) {
throw new KTStaleSTHError(`STH timestamp in the future: ${sth.timestampMs} > ${now + futureSkew}`);
}
if (sth.timestampMs + maxStale < now) {
throw new KTStaleSTHError(`STH older than maxStale: ${now - sth.timestampMs}ms`);
}
}

View File

@@ -0,0 +1,12 @@
import { sha256 } from '@noble/hashes/sha2.js';
/**
* Synchronous SHA-256 — required because every Merkle hash composes
* many leaf/node hashes and an async API would force callers into
* promise chains for hot paths. `@noble/hashes` is the same dependency
* used elsewhere in the workspace (see `@shade/files`,
* `@shade/observability`).
*/
export function sha256Sync(data: Uint8Array): Uint8Array {
return sha256(data);
}

View File

@@ -0,0 +1,120 @@
/**
* Signed Tree Head (STH) — server's commitment to a tree state.
*
* canonical layout for signing:
* 0x02 (DOMAIN_STH) ||
* uint64_be tree_size ||
* uint64_be timestamp_ms ||
* root_hash (32 bytes) ||
* index_root (32 bytes) ||
* log_id (32 bytes)
*
* `log_id` is `SHA-256(log_public_key)` — a stable identifier that
* doesn't change unless the operator rotates the signing key.
*/
import type { CryptoProvider } from '@shade/core';
import { DOMAIN_STH } from './hashes.js';
import { sha256Sync } from './sha256.js';
import { constantTimeEqual } from './util.js';
export interface SignedTreeHead {
treeSize: number;
timestampMs: number;
rootHash: Uint8Array;
indexRoot: Uint8Array;
logId: Uint8Array;
signature: Uint8Array;
}
/** Compute log_id = SHA-256(public_key). */
export function computeLogId(logPublicKey: Uint8Array): Uint8Array {
return sha256Sync(logPublicKey);
}
/** Canonical bytes covered by the STH signature. */
export function canonicalSthBytes(sth: Omit<SignedTreeHead, 'signature'>): Uint8Array {
if (sth.rootHash.length !== 32) throw new Error('rootHash must be 32 bytes');
if (sth.indexRoot.length !== 32) throw new Error('indexRoot must be 32 bytes');
if (sth.logId.length !== 32) throw new Error('logId must be 32 bytes');
if (sth.treeSize < 0 || !Number.isFinite(sth.treeSize)) {
throw new Error('treeSize must be a non-negative integer');
}
const buf = new Uint8Array(1 + 8 + 8 + 32 + 32 + 32);
const view = new DataView(buf.buffer);
let off = 0;
buf[off++] = DOMAIN_STH;
view.setUint32(off, Math.floor(sth.treeSize / 0x100000000));
view.setUint32(off + 4, sth.treeSize >>> 0);
off += 8;
view.setUint32(off, Math.floor(sth.timestampMs / 0x100000000));
view.setUint32(off + 4, sth.timestampMs >>> 0);
off += 8;
buf.set(sth.rootHash, off);
off += 32;
buf.set(sth.indexRoot, off);
off += 32;
buf.set(sth.logId, off);
return buf;
}
/** Sign an STH with the operator's Ed25519 signing key. */
export async function signSth(
crypto: CryptoProvider,
signingPrivateKey: Uint8Array,
sth: Omit<SignedTreeHead, 'signature'>,
): Promise<SignedTreeHead> {
const message = canonicalSthBytes(sth);
const signature = await crypto.sign(signingPrivateKey, message);
return { ...sth, signature };
}
/**
* Verify the STH signature against a pinned `logPublicKey`.
*
* Also checks `logId === SHA-256(logPublicKey)` so a forged STH that
* claims a different log_id is rejected.
*/
export async function verifySthSignature(
crypto: CryptoProvider,
sth: SignedTreeHead,
logPublicKey: Uint8Array,
): Promise<boolean> {
const expectedLogId = computeLogId(logPublicKey);
if (!constantTimeEqual(expectedLogId, sth.logId)) return false;
const message = canonicalSthBytes(sth);
return crypto.verify(logPublicKey, message, sth.signature);
}
/** JSON-friendly STH for the wire (base64-encoded byte fields). */
export interface STHWire {
treeSize: number;
timestampMs: number;
rootHash: string;
indexRoot: string;
logId: string;
signature: string;
}
export function sthToWire(sth: SignedTreeHead, b64: (b: Uint8Array) => string): STHWire {
return {
treeSize: sth.treeSize,
timestampMs: sth.timestampMs,
rootHash: b64(sth.rootHash),
indexRoot: b64(sth.indexRoot),
logId: b64(sth.logId),
signature: b64(sth.signature),
};
}
export function sthFromWire(wire: STHWire, fromB64: (s: string) => Uint8Array): SignedTreeHead {
return {
treeSize: wire.treeSize,
timestampMs: wire.timestampMs,
rootHash: fromB64(wire.rootHash),
indexRoot: fromB64(wire.indexRoot),
logId: fromB64(wire.logId),
signature: fromB64(wire.signature),
};
}

View File

@@ -0,0 +1,59 @@
/**
* Persistent store interface for the server-side KT log + address index.
*
* Append-only invariant: implementations MUST NEVER overwrite or delete a
* row in the leaf table. The only mutation allowed is `appendLeaf`. Index
* entries (`upsertIndex`, `tombstoneIndex`) replace in place because the
* sorted index is a *projection* of the log — its history lives in the
* log itself.
*/
import type { SignedTreeHead } from './sth.js';
import type { AddressIndexEntry } from './index-tree.js';
export interface KTLogLeaf {
index: number;
leafHash: Uint8Array;
timestampMs: number;
operation: number;
address: string;
bundleHash: Uint8Array;
}
export interface KTLogStore {
/** Append a leaf. Returns assigned `index` (= prior size). */
appendLeaf(input: Omit<KTLogLeaf, 'index'>): Promise<number>;
/** Fetch leaves in [fromIndex, toIndex). For audit-path / consistency-proof builds. */
getLeaves(fromIndex: number, toIndex: number): Promise<KTLogLeaf[]>;
/** Fetch a single leaf. */
getLeaf(index: number): Promise<KTLogLeaf | null>;
/** Number of leaves currently in the log. */
size(): Promise<number>;
/** Upsert an address-index entry. */
upsertIndexEntry(entry: AddressIndexEntry): Promise<void>;
/** Tombstone an index entry (mark deleted). */
tombstoneIndexEntry(address: string, latestLeafIndex: number): Promise<void>;
/** Fetch the entire sorted index snapshot. */
getAllIndexEntries(): Promise<AddressIndexEntry[]>;
/** Fetch a single index entry by address. */
getIndexEntry(address: string): Promise<AddressIndexEntry | null>;
/** Persist a freshly-signed STH. */
saveSTH(sth: SignedTreeHead): Promise<void>;
/** Latest STH (most recent treeSize, most recent timestamp). */
getLatestSTH(): Promise<SignedTreeHead | null>;
/** STH at a specific tree size (for consistency proofs). */
getSTHByTreeSize(treeSize: number): Promise<SignedTreeHead | null>;
/** All STHs in (fromTimestamp, toTimestamp], sorted ascending. */
listSTHs(fromTimestampMs?: number, toTimestampMs?: number): Promise<SignedTreeHead[]>;
}

View File

@@ -0,0 +1,42 @@
/**
* Constant-time byte comparison. Mirrors `@shade/core` `constantTimeEqual`
* but is duplicated here so the KT package has no runtime dependency on
* `@shade/core` for primitive comparisons (it depends on it for error
* types only).
*/
export function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) {
diff |= a[i]! ^ b[i]!;
}
return diff === 0;
}
/**
* Encode bytes to standard base64 (no URL variant). Avoids platform-specific
* `Buffer` so the KT package works in browsers, Bun, and Workers.
*/
export function toBase64(bytes: Uint8Array): string {
if (typeof Buffer !== 'undefined') return Buffer.from(bytes).toString('base64');
let str = '';
for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]!);
return btoa(str);
}
export function fromBase64(s: string): Uint8Array {
if (typeof Buffer !== 'undefined') return new Uint8Array(Buffer.from(s, 'base64'));
const str = atob(s);
const out = new Uint8Array(str.length);
for (let i = 0; i < str.length; i++) out[i] = str.charCodeAt(i);
return out;
}
/** Stable hex encoding for log_id rendering. */
export function toHex(bytes: Uint8Array): string {
let s = '';
for (let i = 0; i < bytes.length; i++) {
s += bytes[i]!.toString(16).padStart(2, '0');
}
return s;
}

View File

@@ -0,0 +1,239 @@
/**
* Light-witness — a passive observer of one or more KT logs.
*
* Responsibilities (V1):
* 1. Pin a log_public_key.
* 2. Periodically poll the server's `/v1/kt/sth` endpoint.
* 3. Verify each new STH's signature.
* 4. Maintain a chain of observed STHs and verify consistency proofs
* between successive observations.
* 5. Expose a "compare" API: given an STH another party has seen
* (e.g. delivered with a bundle-fetch), return whether *we* have
* seen the same `tree_size → root_hash → index_root` triple. A
* mismatch is a *split-view alarm*.
*
* V1 is library-only; deployments embed it in long-running processes
* (CLI tools, security-research auditors, server-to-server). V2 will
* add an HTTP `GET /witness/sth` endpoint for peer-to-peer gossip.
*/
import type { CryptoProvider } from '@shade/core';
import {
type SignedTreeHead,
type STHWire,
computeLogId,
sthFromWire,
verifySthSignature,
} from './sth.js';
import { verifyConsistencyProof } from './log.js';
import { fromBase64 } from './util.js';
import { constantTimeEqual } from './util.js';
import {
KTLogIdMismatchError,
KTSplitViewError,
KTStaleSTHError,
KTVerificationError,
} from './errors.js';
export interface WitnessFetcher {
/** GET latest STH. */
fetchLatestSTH(): Promise<STHWire>;
/** GET consistency proof from `fromTreeSize` to `toTreeSize`. */
fetchConsistencyProof(fromTreeSize: number, toTreeSize: number): Promise<{ proof: string[] }>;
}
export interface LightWitnessOptions {
crypto: CryptoProvider;
/** Pinned log signing public key (Ed25519, 32 bytes). */
logPublicKey: Uint8Array;
/** Source of STHs and consistency proofs. */
fetcher: WitnessFetcher;
/** Reject STH older than this many ms. Default 24h. */
maxStaleMs?: number;
/** Allowed clock-skew for STH future timestamps. Default 60 s. */
futureSkewMs?: number;
/** Time source. Defaults to `Date.now()`. */
now?: () => number;
/** Cap on stored STHs (LRU on tree_size). Default 1024. */
maxStored?: number;
}
const DEFAULT_MAX_STALE_MS = 24 * 60 * 60 * 1000;
const DEFAULT_FUTURE_SKEW_MS = 60_000;
const DEFAULT_MAX_STORED = 1024;
/** A piece of evidence that a particular STH was observed. */
export interface WitnessObservation {
sth: SignedTreeHead;
observedAtMs: number;
}
export class LightWitness {
private readonly crypto: CryptoProvider;
private readonly logPublicKey: Uint8Array;
private readonly fetcher: WitnessFetcher;
private readonly logId: Uint8Array;
private readonly maxStaleMs: number;
private readonly futureSkewMs: number;
private readonly now: () => number;
private readonly maxStored: number;
/** STHs we've observed, indexed by tree_size. */
private observed = new Map<number, SignedTreeHead>();
private observedOrder: number[] = []; // insertion order for LRU eviction
constructor(opts: LightWitnessOptions) {
this.crypto = opts.crypto;
this.logPublicKey = opts.logPublicKey;
this.fetcher = opts.fetcher;
this.logId = computeLogId(opts.logPublicKey);
this.maxStaleMs = opts.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
this.futureSkewMs = opts.futureSkewMs ?? DEFAULT_FUTURE_SKEW_MS;
this.now = opts.now ?? (() => Date.now());
this.maxStored = opts.maxStored ?? DEFAULT_MAX_STORED;
}
/** Pinned log_id for callers that want to verify message routing. */
getLogId(): Uint8Array {
return new Uint8Array(this.logId);
}
/**
* Poll the server for the latest STH. Verifies signature, freshness, and
* consistency with the most recent observation we hold. Adds the STH to
* the local set on success.
*/
async pollOnce(): Promise<SignedTreeHead> {
const wire = await this.fetcher.fetchLatestSTH();
const sth = sthFromWire(wire, fromBase64);
await this.observe(sth);
return sth;
}
/**
* Ingest an STH supplied externally (e.g. embedded in a bundle-fetch
* response). Re-uses all the same verification & comparison logic so
* proofs returned by the bundle-fetch path also feed into split-view
* detection.
*/
async observe(sth: SignedTreeHead): Promise<void> {
if (!constantTimeEqual(sth.logId, this.logId)) {
throw new KTLogIdMismatchError(`STH log_id does not match pinned key`);
}
const ok = await verifySthSignature(this.crypto, sth, this.logPublicKey);
if (!ok) throw new KTVerificationError('STH signature did not verify');
const now = this.now();
if (sth.timestampMs > now + this.futureSkewMs) {
throw new KTStaleSTHError(`STH timestamp in the future`);
}
if (sth.timestampMs + this.maxStaleMs < now) {
throw new KTStaleSTHError(`STH older than maxStale`);
}
// Split-view check — same tree_size, different root or index_root → fork.
const prior = this.observed.get(sth.treeSize);
if (prior) {
if (
!constantTimeEqual(prior.rootHash, sth.rootHash) ||
!constantTimeEqual(prior.indexRoot, sth.indexRoot)
) {
throw new KTSplitViewError(
`Split view: two STHs at tree_size=${sth.treeSize} disagree`,
);
}
// Same STH content; nothing to insert (might just be a refreshed timestamp).
// Keep the freshest one for staleness checks.
if (sth.timestampMs > prior.timestampMs) {
this.observed.set(sth.treeSize, sth);
}
return;
}
// Consistency check against our most recent prior observation.
const latest = this.latestObserved();
if (latest && latest.treeSize !== sth.treeSize) {
const [oldSth, newSth] = latest.treeSize < sth.treeSize ? [latest, sth] : [sth, latest];
if (oldSth.treeSize > 0 && oldSth.treeSize < newSth.treeSize) {
const { proof } = await this.fetcher.fetchConsistencyProof(
oldSth.treeSize,
newSth.treeSize,
);
const proofBytes = proof.map(fromBase64);
const consistent = verifyConsistencyProof(
oldSth.treeSize,
newSth.treeSize,
oldSth.rootHash,
newSth.rootHash,
proofBytes,
);
if (!consistent) {
throw new KTVerificationError(
`Consistency proof failed: ${oldSth.treeSize}${newSth.treeSize}`,
);
}
}
}
this.insertObservation(sth);
}
/**
* Compare an external STH (e.g. one another client claims to have seen)
* against our stored set. Returns:
* - 'agree' if we've observed the same tree_size with the same roots,
* - 'unknown' if we have no STH at that tree_size (caller may want to
* poll once more before deciding),
* - 'split-view' if our roots differ.
*/
compare(sth: SignedTreeHead): 'agree' | 'unknown' | 'split-view' {
const ours = this.observed.get(sth.treeSize);
if (!ours) return 'unknown';
const sameRoot = constantTimeEqual(ours.rootHash, sth.rootHash);
const sameIndex = constantTimeEqual(ours.indexRoot, sth.indexRoot);
if (sameRoot && sameIndex) return 'agree';
return 'split-view';
}
/** Latest observed STH (highest tree_size). */
latestObserved(): SignedTreeHead | null {
let best: SignedTreeHead | null = null;
for (const sth of this.observed.values()) {
if (!best || sth.treeSize > best.treeSize) best = sth;
}
return best;
}
/** Snapshot all observed STHs (defensive copy). */
observations(): WitnessObservation[] {
return Array.from(this.observed.values()).map((sth) => ({
sth: {
treeSize: sth.treeSize,
timestampMs: sth.timestampMs,
rootHash: new Uint8Array(sth.rootHash),
indexRoot: new Uint8Array(sth.indexRoot),
logId: new Uint8Array(sth.logId),
signature: new Uint8Array(sth.signature),
},
observedAtMs: this.now(),
}));
}
private insertObservation(sth: SignedTreeHead): void {
if (!this.observed.has(sth.treeSize)) {
this.observedOrder.push(sth.treeSize);
}
this.observed.set(sth.treeSize, sth);
while (this.observedOrder.length > this.maxStored) {
const evict = this.observedOrder.shift();
if (evict !== undefined && evict !== sth.treeSize) {
// Always keep the latest STH even under aggressive eviction.
if (this.latestObserved()?.treeSize !== evict) {
this.observed.delete(evict);
}
}
}
}
}