121 lines
3.6 KiB
TypeScript
121 lines
3.6 KiB
TypeScript
|
|
/**
|
||
|
|
* 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),
|
||
|
|
};
|
||
|
|
}
|