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
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:
36
packages/shade-key-transparency/src/errors.ts
Normal file
36
packages/shade-key-transparency/src/errors.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
137
packages/shade-key-transparency/src/hashes.ts
Normal file
137
packages/shade-key-transparency/src/hashes.ts
Normal 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);
|
||||
}
|
||||
339
packages/shade-key-transparency/src/index-tree.ts
Normal file
339
packages/shade-key-transparency/src/index-tree.ts
Normal 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 };
|
||||
100
packages/shade-key-transparency/src/index.ts
Normal file
100
packages/shade-key-transparency/src/index.ts
Normal 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';
|
||||
273
packages/shade-key-transparency/src/log.ts
Normal file
273
packages/shade-key-transparency/src/log.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
274
packages/shade-key-transparency/src/manager.ts
Normal file
274
packages/shade-key-transparency/src/manager.ts
Normal 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 };
|
||||
149
packages/shade-key-transparency/src/memory-store.ts
Normal file
149
packages/shade-key-transparency/src/memory-store.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
453
packages/shade-key-transparency/src/proof.ts
Normal file
453
packages/shade-key-transparency/src/proof.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
12
packages/shade-key-transparency/src/sha256.ts
Normal file
12
packages/shade-key-transparency/src/sha256.ts
Normal 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);
|
||||
}
|
||||
120
packages/shade-key-transparency/src/sth.ts
Normal file
120
packages/shade-key-transparency/src/sth.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
59
packages/shade-key-transparency/src/store.ts
Normal file
59
packages/shade-key-transparency/src/store.ts
Normal 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[]>;
|
||||
}
|
||||
42
packages/shade-key-transparency/src/util.ts
Normal file
42
packages/shade-key-transparency/src/util.ts
Normal 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;
|
||||
}
|
||||
239
packages/shade-key-transparency/src/witness.ts
Normal file
239
packages/shade-key-transparency/src/witness.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user