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:
127
packages/shade-key-transparency/tests/hashes.test.ts
Normal file
127
packages/shade-key-transparency/tests/hashes.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
DOMAIN_BUNDLE,
|
||||
computeBundleHash,
|
||||
encodeLeafData,
|
||||
leafHash,
|
||||
nodeHash,
|
||||
OP_REGISTER,
|
||||
} from '../src/hashes.js';
|
||||
import { sha256Sync } from '../src/sha256.js';
|
||||
|
||||
describe('RFC 6962 hash primitives', () => {
|
||||
test('leafHash applies 0x00 prefix', () => {
|
||||
const data = new Uint8Array([1, 2, 3]);
|
||||
const expected = sha256Sync(new Uint8Array([0x00, 1, 2, 3]));
|
||||
expect(leafHash(data)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('nodeHash applies 0x01 prefix', () => {
|
||||
const left = new Uint8Array(32).fill(0xaa);
|
||||
const right = new Uint8Array(32).fill(0xbb);
|
||||
const concat = new Uint8Array(1 + 32 + 32);
|
||||
concat[0] = 0x01;
|
||||
concat.set(left, 1);
|
||||
concat.set(right, 33);
|
||||
const expected = sha256Sync(concat);
|
||||
expect(nodeHash(left, right)).toEqual(expected);
|
||||
});
|
||||
|
||||
test('leafHash and nodeHash never collide', () => {
|
||||
// Same content but different domain → different hash
|
||||
const x = new Uint8Array(32).fill(0x42);
|
||||
const lh = leafHash(x);
|
||||
const concat = new Uint8Array(64).fill(0x42);
|
||||
const nh = nodeHash(concat.slice(0, 32), concat.slice(32));
|
||||
expect(Buffer.from(lh).toString('hex')).not.toBe(Buffer.from(nh).toString('hex'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('encodeLeafData', () => {
|
||||
test('encodes timestamp + operation + address + bundleHash', () => {
|
||||
const buf = encodeLeafData(
|
||||
0x010203040506,
|
||||
OP_REGISTER,
|
||||
'alice',
|
||||
new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
|
||||
);
|
||||
// 8 + 1 + 2 + 5 + 2 + 4 = 22
|
||||
expect(buf.length).toBe(22);
|
||||
// last 4 bytes = bundleHash
|
||||
expect(buf[buf.length - 4]).toBe(0xde);
|
||||
expect(buf[buf.length - 1]).toBe(0xef);
|
||||
});
|
||||
|
||||
test('rejects address > 65535 bytes', () => {
|
||||
const huge = 'a'.repeat(0x10000);
|
||||
expect(() => encodeLeafData(0, OP_REGISTER, huge, new Uint8Array(0))).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeBundleHash', () => {
|
||||
test('deterministic over equal input', () => {
|
||||
const sk = new Uint8Array(32).fill(0x11);
|
||||
const dk = new Uint8Array(32).fill(0x22);
|
||||
const pk = new Uint8Array(32).fill(0x33);
|
||||
const sig = new Uint8Array(64).fill(0x44);
|
||||
const a = computeBundleHash({
|
||||
identitySigningKey: sk,
|
||||
identityDHKey: dk,
|
||||
signedPreKey: { keyId: 7, publicKey: pk, signature: sig },
|
||||
});
|
||||
const b = computeBundleHash({
|
||||
identitySigningKey: sk,
|
||||
identityDHKey: dk,
|
||||
signedPreKey: { keyId: 7, publicKey: pk, signature: sig },
|
||||
});
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
test('changing keyId changes hash', () => {
|
||||
const sk = new Uint8Array(32).fill(0x11);
|
||||
const dk = new Uint8Array(32).fill(0x22);
|
||||
const pk = new Uint8Array(32).fill(0x33);
|
||||
const sig = new Uint8Array(64).fill(0x44);
|
||||
const a = computeBundleHash({
|
||||
identitySigningKey: sk,
|
||||
identityDHKey: dk,
|
||||
signedPreKey: { keyId: 1, publicKey: pk, signature: sig },
|
||||
});
|
||||
const b = computeBundleHash({
|
||||
identitySigningKey: sk,
|
||||
identityDHKey: dk,
|
||||
signedPreKey: { keyId: 2, publicKey: pk, signature: sig },
|
||||
});
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
test('rejects wrong-length keys', () => {
|
||||
expect(() =>
|
||||
computeBundleHash({
|
||||
identitySigningKey: new Uint8Array(31),
|
||||
identityDHKey: new Uint8Array(32),
|
||||
signedPreKey: {
|
||||
keyId: 0,
|
||||
publicKey: new Uint8Array(32),
|
||||
signature: new Uint8Array(64),
|
||||
},
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test('uses domain prefix 0x01', () => {
|
||||
const sk = new Uint8Array(32);
|
||||
const dk = new Uint8Array(32);
|
||||
const pk = new Uint8Array(32);
|
||||
const sig = new Uint8Array(64);
|
||||
const expected = sha256Sync(
|
||||
Buffer.concat([Buffer.from([DOMAIN_BUNDLE]), sk, dk, Buffer.alloc(4), pk, sig]),
|
||||
);
|
||||
const got = computeBundleHash({
|
||||
identitySigningKey: sk,
|
||||
identityDHKey: dk,
|
||||
signedPreKey: { keyId: 0, publicKey: pk, signature: sig },
|
||||
});
|
||||
expect(Buffer.from(got).toString('hex')).toBe(Buffer.from(expected).toString('hex'));
|
||||
});
|
||||
});
|
||||
221
packages/shade-key-transparency/tests/index-tree.test.ts
Normal file
221
packages/shade-key-transparency/tests/index-tree.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
AddressIndex,
|
||||
compareAddresses,
|
||||
computeIndexRoot,
|
||||
emptyRootHash,
|
||||
verifyAbsenceProof,
|
||||
verifyInclusionProof,
|
||||
} from '../src/index.js';
|
||||
|
||||
describe('AddressIndex', () => {
|
||||
test('empty index has emptyRootHash root', () => {
|
||||
const idx = new AddressIndex();
|
||||
expect(idx.rootHash()).toEqual(emptyRootHash());
|
||||
});
|
||||
|
||||
test('upsert keeps entries lexicographically sorted', () => {
|
||||
const idx = new AddressIndex();
|
||||
idx.upsert({
|
||||
address: 'charlie',
|
||||
latestLeafIndex: 1,
|
||||
bundleHash: new Uint8Array(32).fill(3),
|
||||
deleted: false,
|
||||
});
|
||||
idx.upsert({
|
||||
address: 'alice',
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(1),
|
||||
deleted: false,
|
||||
});
|
||||
idx.upsert({
|
||||
address: 'bob',
|
||||
latestLeafIndex: 2,
|
||||
bundleHash: new Uint8Array(32).fill(2),
|
||||
deleted: false,
|
||||
});
|
||||
|
||||
const snap = idx.snapshot();
|
||||
expect(snap.map((e) => e.address)).toEqual(['alice', 'bob', 'charlie']);
|
||||
});
|
||||
|
||||
test('compareAddresses is byte-lex', () => {
|
||||
expect(compareAddresses('alice', 'bob') < 0).toBe(true);
|
||||
expect(compareAddresses('bob', 'alice') > 0).toBe(true);
|
||||
expect(compareAddresses('alice', 'alice')).toBe(0);
|
||||
expect(compareAddresses('alice', 'aliceb')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('inclusion proof verifies against rootHash', () => {
|
||||
const idx = new AddressIndex();
|
||||
for (const a of ['alice', 'bob', 'charlie', 'dave', 'eve']) {
|
||||
idx.upsert({
|
||||
address: a,
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(a.charCodeAt(0)),
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
const proof = idx.inclusionProof('charlie');
|
||||
expect(proof).not.toBeNull();
|
||||
expect(verifyInclusionProof(proof!, idx.rootHash())).toBe(true);
|
||||
});
|
||||
|
||||
test('inclusion proof fails against tampered root', () => {
|
||||
const idx = new AddressIndex();
|
||||
idx.upsert({
|
||||
address: 'alice',
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(1),
|
||||
deleted: false,
|
||||
});
|
||||
const proof = idx.inclusionProof('alice')!;
|
||||
const tampered = new Uint8Array(idx.rootHash());
|
||||
tampered[0] ^= 0xff;
|
||||
expect(verifyInclusionProof(proof, tampered)).toBe(false);
|
||||
});
|
||||
|
||||
test('absence proof: query between two entries', () => {
|
||||
const idx = new AddressIndex();
|
||||
idx.upsert({
|
||||
address: 'alice',
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(1),
|
||||
deleted: false,
|
||||
});
|
||||
idx.upsert({
|
||||
address: 'charlie',
|
||||
latestLeafIndex: 1,
|
||||
bundleHash: new Uint8Array(32).fill(3),
|
||||
deleted: false,
|
||||
});
|
||||
|
||||
const absence = idx.absenceProof('bob');
|
||||
expect(absence).not.toBeNull();
|
||||
expect(verifyAbsenceProof(absence!, idx.rootHash())).toBe(true);
|
||||
});
|
||||
|
||||
test('absence proof: query before first entry', () => {
|
||||
const idx = new AddressIndex();
|
||||
idx.upsert({
|
||||
address: 'm',
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(1),
|
||||
deleted: false,
|
||||
});
|
||||
idx.upsert({
|
||||
address: 'z',
|
||||
latestLeafIndex: 1,
|
||||
bundleHash: new Uint8Array(32).fill(2),
|
||||
deleted: false,
|
||||
});
|
||||
|
||||
const absence = idx.absenceProof('a');
|
||||
expect(absence!.prev).toBeNull();
|
||||
expect(absence!.next).not.toBeNull();
|
||||
expect(verifyAbsenceProof(absence!, idx.rootHash())).toBe(true);
|
||||
});
|
||||
|
||||
test('absence proof: query after last entry', () => {
|
||||
const idx = new AddressIndex();
|
||||
idx.upsert({
|
||||
address: 'a',
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(1),
|
||||
deleted: false,
|
||||
});
|
||||
idx.upsert({
|
||||
address: 'm',
|
||||
latestLeafIndex: 1,
|
||||
bundleHash: new Uint8Array(32).fill(2),
|
||||
deleted: false,
|
||||
});
|
||||
|
||||
const absence = idx.absenceProof('z');
|
||||
expect(absence!.prev).not.toBeNull();
|
||||
expect(absence!.next).toBeNull();
|
||||
expect(verifyAbsenceProof(absence!, idx.rootHash())).toBe(true);
|
||||
});
|
||||
|
||||
test('absence proof: empty tree', () => {
|
||||
const idx = new AddressIndex();
|
||||
const absence = idx.absenceProof('alice');
|
||||
expect(absence).not.toBeNull();
|
||||
expect(absence!.treeSize).toBe(0);
|
||||
expect(verifyAbsenceProof(absence!, idx.rootHash())).toBe(true);
|
||||
});
|
||||
|
||||
test('absence proof returns null for existing address', () => {
|
||||
const idx = new AddressIndex();
|
||||
idx.upsert({
|
||||
address: 'alice',
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(1),
|
||||
deleted: false,
|
||||
});
|
||||
expect(idx.absenceProof('alice')).toBeNull();
|
||||
});
|
||||
|
||||
test('absence proof can be forged-detected: claim adjacent but not adjacent', () => {
|
||||
const idx = new AddressIndex();
|
||||
idx.upsert({
|
||||
address: 'alice',
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(1),
|
||||
deleted: false,
|
||||
});
|
||||
idx.upsert({
|
||||
address: 'bob',
|
||||
latestLeafIndex: 1,
|
||||
bundleHash: new Uint8Array(32).fill(2),
|
||||
deleted: false,
|
||||
});
|
||||
idx.upsert({
|
||||
address: 'charlie',
|
||||
latestLeafIndex: 2,
|
||||
bundleHash: new Uint8Array(32).fill(3),
|
||||
deleted: false,
|
||||
});
|
||||
|
||||
const absence = idx.absenceProof('aaron')!;
|
||||
// Tamper: replace prev with non-adjacent neighbor (charlie)
|
||||
const charlieProof = idx.inclusionProof('charlie')!;
|
||||
const forged = {
|
||||
...absence,
|
||||
prev: {
|
||||
position: charlieProof.position,
|
||||
entry: charlieProof.entry,
|
||||
auditPath: charlieProof.auditPath,
|
||||
},
|
||||
};
|
||||
expect(verifyAbsenceProof(forged, idx.rootHash())).toBe(false);
|
||||
});
|
||||
|
||||
test('tombstone marks entry deleted', () => {
|
||||
const idx = new AddressIndex();
|
||||
idx.upsert({
|
||||
address: 'alice',
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(1),
|
||||
deleted: false,
|
||||
});
|
||||
idx.tombstone('alice', 5);
|
||||
const e = idx.get('alice')!;
|
||||
expect(e.deleted).toBe(true);
|
||||
expect(e.latestLeafIndex).toBe(5);
|
||||
expect(e.bundleHash.length).toBe(0);
|
||||
});
|
||||
|
||||
test('computeIndexRoot equals AddressIndex.rootHash for the same sorted snapshot', () => {
|
||||
const idx = new AddressIndex();
|
||||
for (const a of ['carol', 'alice', 'bob']) {
|
||||
idx.upsert({
|
||||
address: a,
|
||||
latestLeafIndex: 0,
|
||||
bundleHash: new Uint8Array(32).fill(a.charCodeAt(0)),
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
expect(idx.rootHash()).toEqual(computeIndexRoot(idx.snapshot()));
|
||||
});
|
||||
});
|
||||
189
packages/shade-key-transparency/tests/log.test.ts
Normal file
189
packages/shade-key-transparency/tests/log.test.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import fc from 'fast-check';
|
||||
import {
|
||||
MerkleLog,
|
||||
auditPath,
|
||||
consistencyProof,
|
||||
emptyRootHash,
|
||||
leafHash,
|
||||
nodeHash,
|
||||
recomputeRootFromAuditPath,
|
||||
verifyConsistencyProof,
|
||||
} from '../src/index.js';
|
||||
import { mth } from '../src/log.js';
|
||||
|
||||
function buildLog(n: number): MerkleLog {
|
||||
const log = new MerkleLog();
|
||||
for (let i = 0; i < n; i++) {
|
||||
log.appendData(new Uint8Array([i & 0xff, (i >> 8) & 0xff]));
|
||||
}
|
||||
return log;
|
||||
}
|
||||
|
||||
describe('MerkleLog basics', () => {
|
||||
test('empty tree root = SHA-256(empty)', () => {
|
||||
const log = new MerkleLog();
|
||||
expect(log.rootHash()).toEqual(emptyRootHash());
|
||||
});
|
||||
|
||||
test('single-leaf tree root = leaf_hash(d)', () => {
|
||||
const log = new MerkleLog();
|
||||
const d = new Uint8Array([0xab, 0xcd]);
|
||||
log.appendData(d);
|
||||
expect(log.rootHash()).toEqual(leafHash(d));
|
||||
});
|
||||
|
||||
test('two-leaf tree root = node_hash(leaf0, leaf1)', () => {
|
||||
const log = new MerkleLog();
|
||||
const d0 = new Uint8Array([1]);
|
||||
const d1 = new Uint8Array([2]);
|
||||
log.appendData(d0);
|
||||
log.appendData(d1);
|
||||
expect(log.rootHash()).toEqual(nodeHash(leafHash(d0), leafHash(d1)));
|
||||
});
|
||||
|
||||
test('append-only ordering preserved', () => {
|
||||
const log = new MerkleLog();
|
||||
log.appendData(new Uint8Array([1]));
|
||||
log.appendData(new Uint8Array([2]));
|
||||
expect(log.size).toBe(2);
|
||||
log.appendData(new Uint8Array([3]));
|
||||
expect(log.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Audit path verification', () => {
|
||||
test('valid audit path reconstructs root for every leaf, every size 1..32', () => {
|
||||
for (let n = 1; n <= 32; n++) {
|
||||
const log = buildLog(n);
|
||||
const root = log.rootHash();
|
||||
for (let m = 0; m < n; m++) {
|
||||
const path = log.auditPath(m);
|
||||
const lh = log.leafHashAt(m);
|
||||
const reconstructed = recomputeRootFromAuditPath(lh, m, n, path);
|
||||
expect(Buffer.from(reconstructed).toString('hex')).toBe(
|
||||
Buffer.from(root).toString('hex'),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('property: tampered leaf fails verification', () => {
|
||||
const log = buildLog(7);
|
||||
const root = log.rootHash();
|
||||
const path = log.auditPath(3);
|
||||
const tampered = new Uint8Array(log.leafHashAt(3));
|
||||
tampered[0] ^= 0xff;
|
||||
const reconstructed = recomputeRootFromAuditPath(tampered, 3, 7, path);
|
||||
expect(Buffer.from(reconstructed).toString('hex')).not.toBe(
|
||||
Buffer.from(root).toString('hex'),
|
||||
);
|
||||
});
|
||||
|
||||
test('property: tampered audit path fails verification', () => {
|
||||
const log = buildLog(11);
|
||||
const root = log.rootHash();
|
||||
const path = log.auditPath(5);
|
||||
if (path.length === 0) return;
|
||||
const tampered = path.map((p, i) => {
|
||||
if (i === 0) {
|
||||
const x = new Uint8Array(p);
|
||||
x[0] ^= 0xff;
|
||||
return x;
|
||||
}
|
||||
return p;
|
||||
});
|
||||
const reconstructed = recomputeRootFromAuditPath(log.leafHashAt(5), 5, 11, tampered);
|
||||
expect(Buffer.from(reconstructed).toString('hex')).not.toBe(
|
||||
Buffer.from(root).toString('hex'),
|
||||
);
|
||||
});
|
||||
|
||||
test('property-based: random N and m', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.integer({ min: 1, max: 100 }), (n) => {
|
||||
const log = buildLog(n);
|
||||
const root = log.rootHash();
|
||||
const m = Math.min(n - 1, Math.floor(Math.random() * n));
|
||||
const path = log.auditPath(m);
|
||||
const reconstructed = recomputeRootFromAuditPath(log.leafHashAt(m), m, n, path);
|
||||
return Buffer.from(reconstructed).toString('hex') === Buffer.from(root).toString('hex');
|
||||
}),
|
||||
{ numRuns: 50 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Consistency proofs', () => {
|
||||
test('m === 0 always consistent', () => {
|
||||
const log = buildLog(5);
|
||||
const newRoot = log.rootHash();
|
||||
expect(verifyConsistencyProof(0, 5, emptyRootHash(), newRoot, [])).toBe(true);
|
||||
});
|
||||
|
||||
test('m === n consistent only when both roots match and proof empty', () => {
|
||||
const log = buildLog(5);
|
||||
const root = log.rootHash();
|
||||
expect(verifyConsistencyProof(5, 5, root, root, [])).toBe(true);
|
||||
const wrong = new Uint8Array(root);
|
||||
wrong[0] ^= 0xff;
|
||||
expect(verifyConsistencyProof(5, 5, wrong, root, [])).toBe(false);
|
||||
});
|
||||
|
||||
test('valid proof verifies for every (m, n) up to 16', () => {
|
||||
for (let n = 1; n <= 16; n++) {
|
||||
const newLog = buildLog(n);
|
||||
const newRoot = newLog.rootHash();
|
||||
for (let m = 0; m <= n; m++) {
|
||||
const oldLog = buildLog(m);
|
||||
const oldRoot = oldLog.rootHash();
|
||||
const proof = newLog.consistencyProof(m);
|
||||
expect(verifyConsistencyProof(m, n, oldRoot, newRoot, proof)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('fork detection — re-write history fails consistency', () => {
|
||||
const original = buildLog(5);
|
||||
const oldRoot = original.rootHash();
|
||||
|
||||
// Server "rewrites" leaf 2: build a new log where leaf 2 has different data.
|
||||
const tampered = new MerkleLog();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
tampered.appendData(
|
||||
i === 2 ? new Uint8Array([0x99, 0x99]) : new Uint8Array([i & 0xff, (i >> 8) & 0xff]),
|
||||
);
|
||||
}
|
||||
tampered.appendData(new Uint8Array([0x77]));
|
||||
const tamperedRoot = tampered.rootHash();
|
||||
const proof = tampered.consistencyProof(5);
|
||||
|
||||
expect(verifyConsistencyProof(5, 6, oldRoot, tamperedRoot, proof)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mth helper', () => {
|
||||
test('mth over slice == sub-tree root', () => {
|
||||
const log = buildLog(4);
|
||||
const leaves = log.exportLeaves();
|
||||
const left = mth(leaves, 0, 2);
|
||||
const right = mth(leaves, 2, 4);
|
||||
const root = mth(leaves, 0, 4);
|
||||
expect(root).toEqual(nodeHash(left, right));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Direct auditPath helper (tree size 1)', () => {
|
||||
test('singleton tree audit path is empty', () => {
|
||||
const log = buildLog(1);
|
||||
const path = log.auditPath(0);
|
||||
expect(path.length).toBe(0);
|
||||
expect(consistencyProof([log.leafHashAt(0)], 1, 1)).toEqual([]);
|
||||
});
|
||||
|
||||
test('auditPath out-of-range throws', () => {
|
||||
expect(() => auditPath([], 0, 0)).toThrow();
|
||||
const log = buildLog(3);
|
||||
expect(() => log.auditPath(3)).toThrow();
|
||||
});
|
||||
});
|
||||
223
packages/shade-key-transparency/tests/manager.test.ts
Normal file
223
packages/shade-key-transparency/tests/manager.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
KTLogManager,
|
||||
MemoryKTLogStore,
|
||||
computeBundleHash,
|
||||
ktProofFromWire,
|
||||
ktProofToWire,
|
||||
verifyBundleAbsence,
|
||||
verifyBundleInclusion,
|
||||
verifyBundleTombstone,
|
||||
} from '../src/index.js';
|
||||
import { KTSplitViewError, KTVerificationError } from '../src/errors.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
async function makeManager() {
|
||||
const kp = await crypto.generateEd25519KeyPair();
|
||||
const store = new MemoryKTLogStore();
|
||||
const mgr = await KTLogManager.create({
|
||||
crypto,
|
||||
store,
|
||||
signingPrivateKey: kp.privateKey,
|
||||
signingPublicKey: kp.publicKey,
|
||||
});
|
||||
return { mgr, kp, store };
|
||||
}
|
||||
|
||||
function fakeBundle(seed: number) {
|
||||
return {
|
||||
identitySigningKey: new Uint8Array(32).fill(seed),
|
||||
identityDHKey: new Uint8Array(32).fill(seed + 1),
|
||||
signedPreKey: {
|
||||
keyId: 1,
|
||||
publicKey: new Uint8Array(32).fill(seed + 2),
|
||||
signature: new Uint8Array(64).fill(seed + 3),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('KTLogManager — happy paths', () => {
|
||||
test('register + buildBundleInclusionProof + verify', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
const bundle = fakeBundle(0x10);
|
||||
const bundleHash = computeBundleHash(bundle);
|
||||
await mgr.recordRegister('alice', bundleHash);
|
||||
const sth = await mgr.publishSTH();
|
||||
|
||||
const proof = await mgr.buildBundleInclusionProof('alice', sth);
|
||||
expect(proof).not.toBeNull();
|
||||
expect(proof!.body.kind).toBe('inclusion');
|
||||
|
||||
const verified = await verifyBundleInclusion(
|
||||
{ crypto, logPublicKey: kp.publicKey },
|
||||
'alice',
|
||||
bundle,
|
||||
proof!,
|
||||
);
|
||||
expect(verified.treeSize).toBe(1);
|
||||
});
|
||||
|
||||
test('absence proof for unknown address verifies', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
const sth = await mgr.publishSTH();
|
||||
const proof = mgr.buildBundleAbsenceProof('zeta', sth);
|
||||
expect(proof).not.toBeNull();
|
||||
expect(proof!.body.kind).toBe('absence');
|
||||
await verifyBundleAbsence({ crypto, logPublicKey: kp.publicKey }, 'zeta', proof!);
|
||||
});
|
||||
|
||||
test('tombstone proof verifies after delete', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
await mgr.recordDelete('alice');
|
||||
const sth = await mgr.publishSTH();
|
||||
const proof = await mgr.buildBundleInclusionProof('alice', sth);
|
||||
expect(proof!.body.kind).toBe('tombstone');
|
||||
const verified = await verifyBundleTombstone(
|
||||
{ crypto, logPublicKey: kp.publicKey },
|
||||
'alice',
|
||||
proof!,
|
||||
);
|
||||
expect(verified.treeSize).toBe(2);
|
||||
});
|
||||
|
||||
test('multiple addresses + STH at increasing tree sizes', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
const aliceBundle = fakeBundle(0x10);
|
||||
const bobBundle = fakeBundle(0x20);
|
||||
await mgr.recordRegister('alice', computeBundleHash(aliceBundle));
|
||||
await mgr.recordRegister('bob', computeBundleHash(bobBundle));
|
||||
const sth = await mgr.publishSTH();
|
||||
expect(sth.treeSize).toBe(2);
|
||||
|
||||
const proofAlice = await mgr.buildBundleInclusionProof('alice', sth);
|
||||
const proofBob = await mgr.buildBundleInclusionProof('bob', sth);
|
||||
await verifyBundleInclusion(
|
||||
{ crypto, logPublicKey: kp.publicKey },
|
||||
'alice',
|
||||
aliceBundle,
|
||||
proofAlice!,
|
||||
);
|
||||
await verifyBundleInclusion(
|
||||
{ crypto, logPublicKey: kp.publicKey },
|
||||
'bob',
|
||||
bobBundle,
|
||||
proofBob!,
|
||||
);
|
||||
});
|
||||
|
||||
test('rotation: new register replaces old', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
const v1 = fakeBundle(0x10);
|
||||
const v2 = fakeBundle(0x55);
|
||||
await mgr.recordRegister('alice', computeBundleHash(v1));
|
||||
await mgr.recordRegister('alice', computeBundleHash(v2));
|
||||
const sth = await mgr.publishSTH();
|
||||
const proof = await mgr.buildBundleInclusionProof('alice', sth);
|
||||
// Latest is v2; verifying with v1's bundle should fail.
|
||||
await expect(
|
||||
verifyBundleInclusion({ crypto, logPublicKey: kp.publicKey }, 'alice', v1, proof!),
|
||||
).rejects.toBeInstanceOf(KTVerificationError);
|
||||
await verifyBundleInclusion(
|
||||
{ crypto, logPublicKey: kp.publicKey },
|
||||
'alice',
|
||||
v2,
|
||||
proof!,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('KTLogManager — wire encoding roundtrip', () => {
|
||||
test('inclusion proof survives wire roundtrip', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
const bundle = fakeBundle(0x42);
|
||||
await mgr.recordRegister('alice', computeBundleHash(bundle));
|
||||
const sth = await mgr.publishSTH();
|
||||
const proof = await mgr.buildBundleInclusionProof('alice', sth);
|
||||
|
||||
const wire = ktProofToWire(proof!);
|
||||
const json = JSON.stringify(wire);
|
||||
const back = ktProofFromWire(JSON.parse(json));
|
||||
await verifyBundleInclusion(
|
||||
{ crypto, logPublicKey: kp.publicKey },
|
||||
'alice',
|
||||
bundle,
|
||||
back,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tampering detection', () => {
|
||||
test('forged bundle (different signing key) is rejected', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
const real = fakeBundle(0x10);
|
||||
await mgr.recordRegister('alice', computeBundleHash(real));
|
||||
const sth = await mgr.publishSTH();
|
||||
const proof = await mgr.buildBundleInclusionProof('alice', sth);
|
||||
|
||||
const forged = { ...real, identitySigningKey: new Uint8Array(32).fill(0xff) };
|
||||
await expect(
|
||||
verifyBundleInclusion({ crypto, logPublicKey: kp.publicKey }, 'alice', forged, proof!),
|
||||
).rejects.toBeInstanceOf(KTVerificationError);
|
||||
});
|
||||
|
||||
test('proof for alice cannot be re-used for bob (address mismatch)', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
const aliceBundle = fakeBundle(0x10);
|
||||
await mgr.recordRegister('alice', computeBundleHash(aliceBundle));
|
||||
const sth = await mgr.publishSTH();
|
||||
const proof = await mgr.buildBundleInclusionProof('alice', sth);
|
||||
await expect(
|
||||
verifyBundleInclusion(
|
||||
{ crypto, logPublicKey: kp.publicKey },
|
||||
'bob',
|
||||
aliceBundle,
|
||||
proof!,
|
||||
),
|
||||
).rejects.toBeInstanceOf(KTVerificationError);
|
||||
});
|
||||
|
||||
test('split-view: same tree_size with different roots is detected by witness', async () => {
|
||||
const { mgr, kp } = await makeManager();
|
||||
const bundle = fakeBundle(0x10);
|
||||
await mgr.recordRegister('alice', computeBundleHash(bundle));
|
||||
const sth = await mgr.publishSTH();
|
||||
// Forge a *different* STH at the same tree_size — pretend the server
|
||||
// signed two divergent versions.
|
||||
const sth2 = await mgr.publishSTH();
|
||||
expect(sth2.treeSize).toBe(sth.treeSize);
|
||||
|
||||
// To simulate a conflicting STH, sign one with a tampered root_hash.
|
||||
const tampered = { ...sth, rootHash: new Uint8Array(sth.rootHash) };
|
||||
tampered.rootHash[0] ^= 0xff;
|
||||
// Re-sign with the same key so it would individually verify…
|
||||
const forged = await (await import('../src/sth.js')).signSth(crypto, kp.privateKey, {
|
||||
treeSize: tampered.treeSize,
|
||||
timestampMs: tampered.timestampMs,
|
||||
rootHash: tampered.rootHash,
|
||||
indexRoot: tampered.indexRoot,
|
||||
logId: tampered.logId,
|
||||
});
|
||||
|
||||
const { LightWitness } = await import('../src/witness.js');
|
||||
const witness = new LightWitness({
|
||||
crypto,
|
||||
logPublicKey: kp.publicKey,
|
||||
fetcher: {
|
||||
async fetchLatestSTH() {
|
||||
return (await import('../src/sth.js')).sthToWire(sth, (b) =>
|
||||
Buffer.from(b).toString('base64'),
|
||||
);
|
||||
},
|
||||
async fetchConsistencyProof() {
|
||||
return { proof: [] };
|
||||
},
|
||||
},
|
||||
});
|
||||
await witness.observe(sth);
|
||||
await expect(witness.observe(forged)).rejects.toBeInstanceOf(KTSplitViewError);
|
||||
});
|
||||
});
|
||||
83
packages/shade-key-transparency/tests/sth.test.ts
Normal file
83
packages/shade-key-transparency/tests/sth.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
canonicalSthBytes,
|
||||
computeLogId,
|
||||
signSth,
|
||||
sthFromWire,
|
||||
sthToWire,
|
||||
verifySthSignature,
|
||||
} from '../src/index.js';
|
||||
import { fromBase64, toBase64 } from '../src/util.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
describe('STH signing & verification', () => {
|
||||
test('signSth + verifySthSignature roundtrip', async () => {
|
||||
const kp = await crypto.generateEd25519KeyPair();
|
||||
const sth = await signSth(crypto, kp.privateKey, {
|
||||
treeSize: 42,
|
||||
timestampMs: 1700000000000,
|
||||
rootHash: new Uint8Array(32).fill(0xaa),
|
||||
indexRoot: new Uint8Array(32).fill(0xbb),
|
||||
logId: computeLogId(kp.publicKey),
|
||||
});
|
||||
expect(await verifySthSignature(crypto, sth, kp.publicKey)).toBe(true);
|
||||
});
|
||||
|
||||
test('verify fails with wrong public key', async () => {
|
||||
const kp = await crypto.generateEd25519KeyPair();
|
||||
const other = await crypto.generateEd25519KeyPair();
|
||||
const sth = await signSth(crypto, kp.privateKey, {
|
||||
treeSize: 1,
|
||||
timestampMs: 1700000000000,
|
||||
rootHash: new Uint8Array(32),
|
||||
indexRoot: new Uint8Array(32),
|
||||
logId: computeLogId(kp.publicKey),
|
||||
});
|
||||
expect(await verifySthSignature(crypto, sth, other.publicKey)).toBe(false);
|
||||
});
|
||||
|
||||
test('verify fails when log_id is forged', async () => {
|
||||
const kp = await crypto.generateEd25519KeyPair();
|
||||
const other = await crypto.generateEd25519KeyPair();
|
||||
const sth = await signSth(crypto, kp.privateKey, {
|
||||
treeSize: 1,
|
||||
timestampMs: 1700000000000,
|
||||
rootHash: new Uint8Array(32),
|
||||
indexRoot: new Uint8Array(32),
|
||||
logId: computeLogId(other.publicKey), // mismatched
|
||||
});
|
||||
// The signature was made over a log_id that doesn't match the supplied
|
||||
// public key — verifySthSignature should refuse.
|
||||
expect(await verifySthSignature(crypto, sth, kp.publicKey)).toBe(false);
|
||||
});
|
||||
|
||||
test('canonical bytes layout is stable', () => {
|
||||
const bytes = canonicalSthBytes({
|
||||
treeSize: 0x0102030405,
|
||||
timestampMs: 0x06070809,
|
||||
rootHash: new Uint8Array(32).fill(0x11),
|
||||
indexRoot: new Uint8Array(32).fill(0x22),
|
||||
logId: new Uint8Array(32).fill(0x33),
|
||||
});
|
||||
// 1 prefix + 8 treeSize + 8 timestamp + 32 + 32 + 32
|
||||
expect(bytes.length).toBe(113);
|
||||
expect(bytes[0]).toBe(0x02);
|
||||
});
|
||||
|
||||
test('wire roundtrip', async () => {
|
||||
const kp = await crypto.generateEd25519KeyPair();
|
||||
const sth = await signSth(crypto, kp.privateKey, {
|
||||
treeSize: 7,
|
||||
timestampMs: 1700000000000,
|
||||
rootHash: new Uint8Array(32).fill(0x77),
|
||||
indexRoot: new Uint8Array(32).fill(0x88),
|
||||
logId: computeLogId(kp.publicKey),
|
||||
});
|
||||
const wire = sthToWire(sth, toBase64);
|
||||
const back = sthFromWire(wire, fromBase64);
|
||||
expect(back).toEqual(sth);
|
||||
expect(await verifySthSignature(crypto, back, kp.publicKey)).toBe(true);
|
||||
});
|
||||
});
|
||||
223
packages/shade-key-transparency/tests/witness.test.ts
Normal file
223
packages/shade-key-transparency/tests/witness.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { SubtleCryptoProvider } from '@shade/crypto-web';
|
||||
import {
|
||||
KTLogManager,
|
||||
LightWitness,
|
||||
MemoryKTLogStore,
|
||||
computeBundleHash,
|
||||
computeLogId,
|
||||
signSth,
|
||||
sthToWire,
|
||||
} from '../src/index.js';
|
||||
import {
|
||||
KTLogIdMismatchError,
|
||||
KTSplitViewError,
|
||||
KTStaleSTHError,
|
||||
KTVerificationError,
|
||||
} from '../src/errors.js';
|
||||
import { toBase64, fromBase64 } from '../src/util.js';
|
||||
|
||||
const crypto = new SubtleCryptoProvider();
|
||||
|
||||
async function setup() {
|
||||
const kp = await crypto.generateEd25519KeyPair();
|
||||
const store = new MemoryKTLogStore();
|
||||
const mgr = await KTLogManager.create({
|
||||
crypto,
|
||||
store,
|
||||
signingPrivateKey: kp.privateKey,
|
||||
signingPublicKey: kp.publicKey,
|
||||
});
|
||||
return { kp, mgr };
|
||||
}
|
||||
|
||||
function fakeBundle(seed: number) {
|
||||
return {
|
||||
identitySigningKey: new Uint8Array(32).fill(seed),
|
||||
identityDHKey: new Uint8Array(32).fill(seed + 1),
|
||||
signedPreKey: {
|
||||
keyId: 1,
|
||||
publicKey: new Uint8Array(32).fill(seed + 2),
|
||||
signature: new Uint8Array(64).fill(seed + 3),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('LightWitness', () => {
|
||||
test('observes valid STH and stores it', async () => {
|
||||
const { kp, mgr } = await setup();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
const sth = await mgr.publishSTH();
|
||||
|
||||
const witness = new LightWitness({
|
||||
crypto,
|
||||
logPublicKey: kp.publicKey,
|
||||
fetcher: {
|
||||
async fetchLatestSTH() {
|
||||
return sthToWire(sth, toBase64);
|
||||
},
|
||||
async fetchConsistencyProof() {
|
||||
return { proof: [] };
|
||||
},
|
||||
},
|
||||
});
|
||||
const polled = await witness.pollOnce();
|
||||
expect(polled.treeSize).toBe(1);
|
||||
expect(witness.compare(sth)).toBe('agree');
|
||||
});
|
||||
|
||||
test('rejects STH whose log_id does not match pinned key', async () => {
|
||||
const { mgr } = await setup();
|
||||
const wrong = await crypto.generateEd25519KeyPair();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
const sth = await mgr.publishSTH();
|
||||
|
||||
const witness = new LightWitness({
|
||||
crypto,
|
||||
logPublicKey: wrong.publicKey, // pinned to wrong key
|
||||
fetcher: {
|
||||
async fetchLatestSTH() {
|
||||
return sthToWire(sth, toBase64);
|
||||
},
|
||||
async fetchConsistencyProof() {
|
||||
return { proof: [] };
|
||||
},
|
||||
},
|
||||
});
|
||||
await expect(witness.pollOnce()).rejects.toBeInstanceOf(KTLogIdMismatchError);
|
||||
});
|
||||
|
||||
test('rejects STH older than maxStaleMs', async () => {
|
||||
const { kp, mgr } = await setup();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
const sth = await mgr.publishSTH(1000); // far in the past
|
||||
const witness = new LightWitness({
|
||||
crypto,
|
||||
logPublicKey: kp.publicKey,
|
||||
fetcher: {
|
||||
async fetchLatestSTH() {
|
||||
return sthToWire(sth, toBase64);
|
||||
},
|
||||
async fetchConsistencyProof() {
|
||||
return { proof: [] };
|
||||
},
|
||||
},
|
||||
maxStaleMs: 1000,
|
||||
now: () => 10_000_000,
|
||||
});
|
||||
await expect(witness.pollOnce()).rejects.toBeInstanceOf(KTStaleSTHError);
|
||||
});
|
||||
|
||||
test('detects split-view at same tree_size', async () => {
|
||||
const { kp, mgr } = await setup();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
const sth1 = await mgr.publishSTH();
|
||||
|
||||
// Forge another signed STH with same tree_size but different rootHash
|
||||
const tamperedRoot = new Uint8Array(sth1.rootHash);
|
||||
tamperedRoot[0] ^= 0xff;
|
||||
const sth2 = await signSth(crypto, kp.privateKey, {
|
||||
treeSize: sth1.treeSize,
|
||||
timestampMs: sth1.timestampMs,
|
||||
rootHash: tamperedRoot,
|
||||
indexRoot: sth1.indexRoot,
|
||||
logId: computeLogId(kp.publicKey),
|
||||
});
|
||||
|
||||
const witness = new LightWitness({
|
||||
crypto,
|
||||
logPublicKey: kp.publicKey,
|
||||
fetcher: {
|
||||
async fetchLatestSTH() {
|
||||
return sthToWire(sth1, toBase64);
|
||||
},
|
||||
async fetchConsistencyProof() {
|
||||
return { proof: [] };
|
||||
},
|
||||
},
|
||||
});
|
||||
await witness.observe(sth1);
|
||||
await expect(witness.observe(sth2)).rejects.toBeInstanceOf(KTSplitViewError);
|
||||
});
|
||||
|
||||
test('verifies consistency between two successive STHs', async () => {
|
||||
const { kp, mgr } = await setup();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
const sth1 = await mgr.publishSTH();
|
||||
await mgr.recordRegister('bob', computeBundleHash(fakeBundle(0x20)));
|
||||
const sth2 = await mgr.publishSTH();
|
||||
|
||||
const consistency = await mgr.buildConsistencyProof(sth1.treeSize);
|
||||
const witness = new LightWitness({
|
||||
crypto,
|
||||
logPublicKey: kp.publicKey,
|
||||
fetcher: {
|
||||
async fetchLatestSTH() {
|
||||
return sthToWire(sth2, toBase64);
|
||||
},
|
||||
async fetchConsistencyProof() {
|
||||
return { proof: consistency.proof.map(toBase64) };
|
||||
},
|
||||
},
|
||||
});
|
||||
await witness.observe(sth1);
|
||||
await witness.observe(sth2);
|
||||
expect(witness.compare(sth2)).toBe('agree');
|
||||
});
|
||||
|
||||
test('rejects STH where log re-wrote history (consistency proof fails)', async () => {
|
||||
const { kp, mgr } = await setup();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
const sth1 = await mgr.publishSTH();
|
||||
|
||||
// Build a forked log where leaf 0 is different.
|
||||
const forkStore = new MemoryKTLogStore();
|
||||
const forkMgr = await KTLogManager.create({
|
||||
crypto,
|
||||
store: forkStore,
|
||||
signingPrivateKey: kp.privateKey,
|
||||
signingPublicKey: kp.publicKey,
|
||||
});
|
||||
await forkMgr.recordRegister('mallory', computeBundleHash(fakeBundle(0xee)));
|
||||
await forkMgr.recordRegister('bob', computeBundleHash(fakeBundle(0x20)));
|
||||
const forkedSth2 = await forkMgr.publishSTH();
|
||||
const forkedConsistency = await forkMgr.buildConsistencyProof(sth1.treeSize);
|
||||
|
||||
const witness = new LightWitness({
|
||||
crypto,
|
||||
logPublicKey: kp.publicKey,
|
||||
fetcher: {
|
||||
async fetchLatestSTH() {
|
||||
return sthToWire(forkedSth2, toBase64);
|
||||
},
|
||||
async fetchConsistencyProof() {
|
||||
return { proof: forkedConsistency.proof.map(toBase64) };
|
||||
},
|
||||
},
|
||||
});
|
||||
await witness.observe(sth1);
|
||||
await expect(witness.observe(forkedSth2)).rejects.toBeInstanceOf(KTVerificationError);
|
||||
});
|
||||
|
||||
test('compare returns "unknown" for tree_size we have not seen', async () => {
|
||||
const { kp, mgr } = await setup();
|
||||
await mgr.recordRegister('alice', computeBundleHash(fakeBundle(0x10)));
|
||||
const sth = await mgr.publishSTH();
|
||||
const witness = new LightWitness({
|
||||
crypto,
|
||||
logPublicKey: kp.publicKey,
|
||||
fetcher: {
|
||||
async fetchLatestSTH() {
|
||||
return sthToWire(sth, toBase64);
|
||||
},
|
||||
async fetchConsistencyProof() {
|
||||
return { proof: [] };
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(witness.compare(sth)).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
// Make TS happy about unused fromBase64
|
||||
void fromBase64;
|
||||
Reference in New Issue
Block a user