@hashborn/verify 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * hashborn-verify <verify link | attestation.json | - | record id | record page URL>
4
+ *
5
+ * Verifies one Hashborn attestation with SHA-256 alone and prints the verdict. Exit code 0 when
6
+ * it is valid, 1 otherwise (invalid, malformed, not found, or the API did not answer).
7
+ */
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { parseAttestation, parseVerifyLink } from './attestation.js';
10
+ import { DEFAULT_BASE_URL } from './constants.js';
11
+ import { HashbornError } from './errors.js';
12
+ import { RECORD_ID_RE, verifyRemote } from './remote.js';
13
+ import { verifyAttestation } from './verify.js';
14
+ const USAGE = `Usage: hashborn-verify <input> [--base-url <url>] [--json]
15
+
16
+ <input> is one of:
17
+ a verify link https://hashborn.com/verify?attestation=...
18
+ a JSON file path to an attestation saved as JSON, or - to read it from stdin
19
+ a record id 0x followed by 72 hex characters, as on /records/<id>
20
+ a record page https://hashborn.com/records/<id>
21
+
22
+ A record is read from the public API (default ${DEFAULT_BASE_URL}, or the record page's site)
23
+ and verified here; the server's own verdict is never used. Exit code 0 when valid, 1 otherwise.`;
24
+ function readStdin() {
25
+ return readFileSync(0, 'utf8');
26
+ }
27
+ function print(result) {
28
+ const lines = [result.valid ? 'valid' : `invalid: ${result.reason ?? 'unknown reason'}`];
29
+ const row = (label, value) => {
30
+ if (value !== null && value !== undefined && value !== '')
31
+ lines.push(` ${label.padEnd(9)}${String(value)}`);
32
+ };
33
+ row('domain', result.domain);
34
+ row('signer', result.pqAddress);
35
+ row('leaf', result.leaf);
36
+ row('root', result.root);
37
+ row('message', result.messageHash);
38
+ if ('remote' in result) {
39
+ const r = result.remote;
40
+ row('record', result.valid ? 'its published text hashes to the signed message' : null);
41
+ row('anchored', `${r.txHash} block ${r.blockNumber}${r.chainId ? ` chain ${r.chainId}` : ''} at ${r.anchoredAt}`);
42
+ }
43
+ if (result.hashCalls)
44
+ row('hashes', `${result.hashCalls} SHA-256 calls on the chains`);
45
+ console.log(lines.join('\n'));
46
+ }
47
+ async function main(argv) {
48
+ let baseUrl;
49
+ let json = false;
50
+ const inputs = [];
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a === '--help' || a === '-h') {
54
+ console.log(USAGE);
55
+ return 0;
56
+ }
57
+ else if (a === '--json')
58
+ json = true;
59
+ else if (a === '--base-url')
60
+ baseUrl = argv[++i];
61
+ else if (a.startsWith('--base-url='))
62
+ baseUrl = a.slice('--base-url='.length);
63
+ else
64
+ inputs.push(a);
65
+ }
66
+ if (inputs.length !== 1 || (baseUrl !== undefined && !baseUrl)) {
67
+ console.error(USAGE);
68
+ return 1;
69
+ }
70
+ const input = inputs[0].trim();
71
+ // A record page URL, https://<site>/records/<id>, is read from that site unless --base-url says otherwise.
72
+ const recordPage = /^(https?:\/\/[^/?#]+)\/records\/(0x[0-9a-fA-F]{72})(?:[/?#]|$)/i.exec(input);
73
+ let result;
74
+ try {
75
+ if (RECORD_ID_RE.test(input)) {
76
+ result = await verifyRemote(input, baseUrl ? { baseUrl } : {});
77
+ }
78
+ else if (recordPage) {
79
+ result = await verifyRemote(recordPage[2], { baseUrl: baseUrl ?? recordPage[1] });
80
+ }
81
+ else if (input === '-') {
82
+ result = verifyAttestation(parseAttestation(readStdin()));
83
+ }
84
+ else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input) || input.includes('attestation=')) {
85
+ result = verifyAttestation(parseVerifyLink(input));
86
+ }
87
+ else if (existsSync(input)) {
88
+ result = verifyAttestation(parseAttestation(readFileSync(input, 'utf8')));
89
+ }
90
+ else {
91
+ result = verifyAttestation(parseVerifyLink(input));
92
+ }
93
+ }
94
+ catch (err) {
95
+ const message = err instanceof Error ? err.message : String(err);
96
+ // Nothing to judge when the record was not found or the API did not answer.
97
+ const noVerdict = err instanceof HashbornError && (err.code === 'not-found' || err.code === 'network');
98
+ if (json)
99
+ console.log(JSON.stringify({ valid: false, error: message }, null, 2));
100
+ else
101
+ console.log(`${noVerdict ? 'error' : 'invalid'}: ${message}`);
102
+ return 1;
103
+ }
104
+ if (json) {
105
+ const { remote, ...rest } = result;
106
+ console.log(JSON.stringify(remote ? { ...rest, record: remote.record, anchored: { txHash: remote.txHash, block: remote.blockNumber, chainId: remote.chainId, at: remote.anchoredAt } } : rest, null, 2));
107
+ }
108
+ else {
109
+ print(result);
110
+ }
111
+ return result.valid ? 0 : 1;
112
+ }
113
+ main(process.argv.slice(2)).then((code) => {
114
+ process.exitCode = code;
115
+ }, (err) => {
116
+ console.error(err instanceof Error ? err.message : String(err));
117
+ process.exitCode = 1;
118
+ });
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Protocol v1 constants and the public JSON types. Copied from the reference implementation in
3
+ * apps/web (lib/pq/contract.ts and lib/types.ts); test/cross.test.ts pins them to it.
4
+ */
5
+ export type Hex = `0x${string}`;
6
+ /**
7
+ * Fixed parameters of protocol v1: SHA-256, n = 32, w = 16, 67 chains, height 8, 256 leaves.
8
+ * Signature = 4 + 67 x 32 + 8 x 32 = 2,404 bytes. Public key = root || pubSeed.
9
+ */
10
+ export declare const PQ: {
11
+ readonly hash: "SHA-256";
12
+ readonly n: 32;
13
+ readonly w: 16;
14
+ readonly len1: 64;
15
+ readonly len2: 3;
16
+ readonly len: 67;
17
+ readonly chainSteps: 15;
18
+ readonly height: 8;
19
+ readonly leaves: 256;
20
+ readonly signatureBytes: 2404;
21
+ readonly publicKeyBytes: 64;
22
+ };
23
+ /** Protocol name. It prefixes every domain and the pq address hash, and never changes. */
24
+ export declare const PROTOCOL_DOMAIN = "pqc-rh";
25
+ export declare const PROTOCOL_VERSION = 1;
26
+ /**
27
+ * Attestation domains, protocol v1. Every digest is
28
+ * SHA-256(domain || "\n" || canonical JSON of the fields, keys sorted).
29
+ */
30
+ export declare const DOMAINS: {
31
+ readonly identity: "pqc-rh/identity/v1";
32
+ readonly proof: "pqc-rh/proof/v1";
33
+ readonly agent: "pqc-rh/agent/v1";
34
+ readonly agentAction: "pqc-rh/agent-action/v1";
35
+ readonly transfer: "pqc-rh/transfer/v1";
36
+ readonly rwa: "pqc-rh/rwa/v1";
37
+ readonly vaultWithdraw: "pqc-rh/vault-withdraw/v2";
38
+ readonly statement: "pqc-rh/statement/v1";
39
+ readonly official: "pqc-rh/official/v1";
40
+ };
41
+ export type Domain = (typeof DOMAINS)[keyof typeof DOMAINS];
42
+ /**
43
+ * Scheme ids an attestation may name. Protocol v1 signs with `wots-merkle` only; the others are
44
+ * reserved for many-time keys certified by a WOTS leaf.
45
+ */
46
+ export declare const SCHEME_IDS: readonly ["wots-merkle", "ml-dsa-65", "slh-dsa-sha2-128s", "falcon-512", "ml-dsa-87", "slh-dsa-shake-128f", "falcon-1024", "secp256k1-ml-dsa-65"];
47
+ export type SchemeId = (typeof SCHEME_IDS)[number];
48
+ /** A WOTS + Merkle signature, 4 + 67 x 32 + 8 x 32 = 2,404 bytes on the wire. */
49
+ export interface MerkleSignature {
50
+ leaf: number;
51
+ /** 67 x 32 bytes, hex. */
52
+ wots: Hex;
53
+ /** 8 x 32 bytes, hex, sibling at every level from the leaf up. */
54
+ auth: Hex[];
55
+ }
56
+ /** One signed statement: what was signed, by which leaf of which identity, anchored where. */
57
+ export interface Attestation {
58
+ version: 1;
59
+ scheme: SchemeId;
60
+ domain: Domain;
61
+ /** pq1 + base58(SHA-256("pqc-rh" || pubSeed || root)[0..20]) */
62
+ pqAddress: string;
63
+ root: Hex;
64
+ pubSeed: Hex;
65
+ height: 8;
66
+ /** The digest the leaf signed. */
67
+ messageHash: Hex;
68
+ signature: MerkleSignature;
69
+ /** Registry transaction that anchored it, null while pending. */
70
+ txHash: Hex | null;
71
+ anchoredAt: string | null;
72
+ }
73
+ /** Field values allowed inside an attestation digest. Nested objects and arrays are not allowed. */
74
+ export type DigestFields = Record<string, string | number | boolean | null>;
75
+ /** The site that serves verify links and the public API. */
76
+ export declare const DEFAULT_BASE_URL = "https://hashborn.com";
77
+ /** Where records are anchored: PQRegistry on Robinhood Chain. */
78
+ export declare const REGISTRY: {
79
+ readonly chainId: 4663;
80
+ readonly address: "0xD791A8Eb2b8C48E5340579A9f91F983AB9597c56";
81
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Protocol v1 constants and the public JSON types. Copied from the reference implementation in
3
+ * apps/web (lib/pq/contract.ts and lib/types.ts); test/cross.test.ts pins them to it.
4
+ */
5
+ /**
6
+ * Fixed parameters of protocol v1: SHA-256, n = 32, w = 16, 67 chains, height 8, 256 leaves.
7
+ * Signature = 4 + 67 x 32 + 8 x 32 = 2,404 bytes. Public key = root || pubSeed.
8
+ */
9
+ export const PQ = {
10
+ hash: 'SHA-256',
11
+ n: 32,
12
+ w: 16,
13
+ len1: 64,
14
+ len2: 3,
15
+ len: 67,
16
+ chainSteps: 15,
17
+ height: 8,
18
+ leaves: 256,
19
+ signatureBytes: 2404,
20
+ publicKeyBytes: 64,
21
+ };
22
+ /** Protocol name. It prefixes every domain and the pq address hash, and never changes. */
23
+ export const PROTOCOL_DOMAIN = 'pqc-rh';
24
+ export const PROTOCOL_VERSION = 1;
25
+ /**
26
+ * Attestation domains, protocol v1. Every digest is
27
+ * SHA-256(domain || "\n" || canonical JSON of the fields, keys sorted).
28
+ */
29
+ export const DOMAINS = {
30
+ identity: 'pqc-rh/identity/v1',
31
+ proof: 'pqc-rh/proof/v1',
32
+ agent: 'pqc-rh/agent/v1',
33
+ agentAction: 'pqc-rh/agent-action/v1',
34
+ transfer: 'pqc-rh/transfer/v1',
35
+ rwa: 'pqc-rh/rwa/v1',
36
+ vaultWithdraw: 'pqc-rh/vault-withdraw/v2',
37
+ statement: 'pqc-rh/statement/v1',
38
+ official: 'pqc-rh/official/v1',
39
+ };
40
+ /**
41
+ * Scheme ids an attestation may name. Protocol v1 signs with `wots-merkle` only; the others are
42
+ * reserved for many-time keys certified by a WOTS leaf.
43
+ */
44
+ export const SCHEME_IDS = [
45
+ 'wots-merkle',
46
+ 'ml-dsa-65',
47
+ 'slh-dsa-sha2-128s',
48
+ 'falcon-512',
49
+ 'ml-dsa-87',
50
+ 'slh-dsa-shake-128f',
51
+ 'falcon-1024',
52
+ 'secp256k1-ml-dsa-65',
53
+ ];
54
+ /** The site that serves verify links and the public API. */
55
+ export const DEFAULT_BASE_URL = 'https://hashborn.com';
56
+ /** Where records are anchored: PQRegistry on Robinhood Chain. */
57
+ export const REGISTRY = {
58
+ chainId: 4663,
59
+ address: '0xD791A8Eb2b8C48E5340579A9f91F983AB9597c56',
60
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Attestation digests, protocol v1:
3
+ * digest = SHA-256(domain || "\n" || canonical JSON of the fields)
4
+ * Canonical JSON: one object, keys sorted by UTF-16 code unit, no whitespace, values limited to
5
+ * strings, finite numbers, booleans and null. Amounts and anything beyond 2^53 are strings; a
6
+ * nested object or array is rejected so two encoders can never disagree on the bytes.
7
+ */
8
+ import type { DigestFields, Hex } from './constants.js';
9
+ export declare function canonicalJson(fields: DigestFields): string;
10
+ /**
11
+ * The digest a leaf signs for `fields` under `domain`. Byte-exact with the site, so a record's
12
+ * fields recompute its `messageHash`.
13
+ */
14
+ export declare function digest(domain: string, fields: DigestFields): Hex;
15
+ /**
16
+ * The digest of a record exactly as published (the `record` bytes of a PQRegistry anchor, already
17
+ * canonical JSON): SHA-256(domain || "\n" || record). This is what the registry computes on chain.
18
+ */
19
+ export declare function recordDigest(domain: string, record: string): Hex;
package/dist/digest.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Attestation digests, protocol v1:
3
+ * digest = SHA-256(domain || "\n" || canonical JSON of the fields)
4
+ * Canonical JSON: one object, keys sorted by UTF-16 code unit, no whitespace, values limited to
5
+ * strings, finite numbers, booleans and null. Amounts and anything beyond 2^53 are strings; a
6
+ * nested object or array is rejected so two encoders can never disagree on the bytes.
7
+ */
8
+ import { toHex, utf8ToBytes } from './bytes.js';
9
+ import { HashbornError } from './errors.js';
10
+ import { H } from './hash.js';
11
+ export function canonicalJson(fields) {
12
+ if (fields === null || typeof fields !== 'object' || Array.isArray(fields)) {
13
+ throw new HashbornError('digest fields must be a plain object');
14
+ }
15
+ const proto = Object.getPrototypeOf(fields);
16
+ if (proto !== Object.prototype && proto !== null)
17
+ throw new HashbornError('digest fields must be a plain object');
18
+ const keys = Object.keys(fields).sort();
19
+ const parts = keys.map((key) => {
20
+ const value = fields[key];
21
+ const ok = value === null ||
22
+ typeof value === 'string' ||
23
+ typeof value === 'boolean' ||
24
+ (typeof value === 'number' && Number.isFinite(value));
25
+ if (!ok)
26
+ throw new HashbornError(`digest field "${key}" must be a string, finite number, boolean or null`);
27
+ return `${JSON.stringify(key)}:${JSON.stringify(value)}`;
28
+ });
29
+ return `{${parts.join(',')}}`;
30
+ }
31
+ /**
32
+ * The digest a leaf signs for `fields` under `domain`. Byte-exact with the site, so a record's
33
+ * fields recompute its `messageHash`.
34
+ */
35
+ export function digest(domain, fields) {
36
+ return recordDigest(domain, canonicalJson(fields));
37
+ }
38
+ /**
39
+ * The digest of a record exactly as published (the `record` bytes of a PQRegistry anchor, already
40
+ * canonical JSON): SHA-256(domain || "\n" || record). This is what the registry computes on chain.
41
+ */
42
+ export function recordDigest(domain, record) {
43
+ if (typeof domain !== 'string' || domain.length === 0)
44
+ throw new HashbornError('domain must be a non-empty string');
45
+ if (typeof record !== 'string')
46
+ throw new HashbornError('record must be a string');
47
+ return toHex(H(utf8ToBytes(`${domain}\n${record}`)));
48
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Thrown on malformed input: bad hex, wrong sizes, a link that is not base64url, JSON that is
3
+ * not an attestation, a record the API does not have. A well-formed attestation whose signature
4
+ * is wrong is not an error: `verifyAttestation` returns `{valid: false}` with a reason instead.
5
+ *
6
+ * `code` is `unsupported` for a well-formed value this protocol version does not define (another
7
+ * version, scheme, height or domain), `not-found` for a record id the API does not know, `network`
8
+ * for an API that did not answer, and `malformed` otherwise.
9
+ */
10
+ export declare class HashbornError extends Error {
11
+ readonly code: 'malformed' | 'unsupported' | 'not-found' | 'network';
12
+ name: string;
13
+ constructor(message: string, code?: 'malformed' | 'unsupported' | 'not-found' | 'network');
14
+ }
15
+ /** @deprecated The class under its name in `@hashborne/verify`; the same class, kept so old imports and `instanceof` checks still work. */
16
+ export declare const HashborneError: typeof HashbornError;
package/dist/errors.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Thrown on malformed input: bad hex, wrong sizes, a link that is not base64url, JSON that is
3
+ * not an attestation, a record the API does not have. A well-formed attestation whose signature
4
+ * is wrong is not an error: `verifyAttestation` returns `{valid: false}` with a reason instead.
5
+ *
6
+ * `code` is `unsupported` for a well-formed value this protocol version does not define (another
7
+ * version, scheme, height or domain), `not-found` for a record id the API does not know, `network`
8
+ * for an API that did not answer, and `malformed` otherwise.
9
+ */
10
+ export class HashbornError extends Error {
11
+ code;
12
+ name = 'HashbornError';
13
+ constructor(message, code = 'malformed') {
14
+ super(message);
15
+ this.code = code;
16
+ }
17
+ }
18
+ /** @deprecated The class under its name in `@hashborne/verify`; the same class, kept so old imports and `instanceof` checks still work. */
19
+ export const HashborneError = HashbornError;
package/dist/hash.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The one hash and the one address layout of protocol v1.
3
+ *
4
+ * ADRS is 32 bytes, four big-endian u32 fields then 16 zero bytes:
5
+ * type 0 chain step, 1 leaf public key, 2 tree node, 3 secret key PRF
6
+ * a leaf index (chain, leaf) or node height (node)
7
+ * b chain index (chain) or node index (node)
8
+ * c chain step (chain only)
9
+ *
10
+ * F(pubSeed, ADRS, x) = SHA-256(pubSeed || ADRS || x).
11
+ */
12
+ export declare const ADRS: {
13
+ readonly chain: 0;
14
+ readonly leaf: 1;
15
+ readonly node: 2;
16
+ };
17
+ export declare function adrs(type: number, a: number, b: number, c?: number): Uint8Array;
18
+ /** SHA-256 over the concatenation of `parts`. */
19
+ export declare function H(...parts: Uint8Array[]): Uint8Array;
20
+ /** The tweakable hash of one chain step. */
21
+ export declare function F(pubSeed: Uint8Array, address: Uint8Array, x: Uint8Array): Uint8Array;
package/dist/hash.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The one hash and the one address layout of protocol v1.
3
+ *
4
+ * ADRS is 32 bytes, four big-endian u32 fields then 16 zero bytes:
5
+ * type 0 chain step, 1 leaf public key, 2 tree node, 3 secret key PRF
6
+ * a leaf index (chain, leaf) or node height (node)
7
+ * b chain index (chain) or node index (node)
8
+ * c chain step (chain only)
9
+ *
10
+ * F(pubSeed, ADRS, x) = SHA-256(pubSeed || ADRS || x).
11
+ */
12
+ import { sha256 } from '@noble/hashes/sha2.js';
13
+ import { concatBytes } from '@noble/hashes/utils.js';
14
+ export const ADRS = { chain: 0, leaf: 1, node: 2 };
15
+ export function adrs(type, a, b, c = 0) {
16
+ const out = new Uint8Array(32);
17
+ const view = new DataView(out.buffer);
18
+ view.setUint32(0, type, false);
19
+ view.setUint32(4, a, false);
20
+ view.setUint32(8, b, false);
21
+ view.setUint32(12, c, false);
22
+ return out;
23
+ }
24
+ /** SHA-256 over the concatenation of `parts`. */
25
+ export function H(...parts) {
26
+ return sha256(parts.length === 1 ? parts[0] : concatBytes(...parts));
27
+ }
28
+ /** The tweakable hash of one chain step. */
29
+ export function F(pubSeed, address, x) {
30
+ const buf = new Uint8Array(96);
31
+ buf.set(pubSeed, 0);
32
+ buf.set(address, 32);
33
+ buf.set(x, 64);
34
+ return sha256(buf);
35
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @hashborn/verify: verify Hashborn (protocol pqc-rh v1) records with SHA-256 alone. It supersedes
3
+ * @hashborne/verify, the same code under the product's earlier name.
4
+ * Pure functions, no network, except `fetchRecord` and `verifyRemote`, which read the public API
5
+ * and then verify the published bytes here.
6
+ */
7
+ export { DEFAULT_BASE_URL, DOMAINS, PQ, PROTOCOL_DOMAIN, PROTOCOL_VERSION, REGISTRY, SCHEME_IDS, type Attestation, type DigestFields, type Domain, type Hex, type MerkleSignature, type SchemeId, } from './constants.js';
8
+ export { HashbornError, HashborneError } from './errors.js';
9
+ export { verifyAttestation, verifyRecord, verifySignature, type FailureCode, type RecordVerifyResult, type SignatureCheck, type VerifyResult, } from './verify.js';
10
+ export { canonicalJson, digest, recordDigest } from './digest.js';
11
+ export { manifestRoot } from './merkle.js';
12
+ export { pqAddress } from './address.js';
13
+ export { decodeSignature, encodeSignature } from './signature.js';
14
+ export { ATTESTATION_MAX_BYTES, checkAttestation, encodeVerifyLink, parseAttestation, parseVerifyLink, } from './attestation.js';
15
+ export { fetchRecord, verifyRemote, type FetchLike, type FetchOptions, type RemoteRecord, type RemoteVerifyResult, } from './remote.js';
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @hashborn/verify: verify Hashborn (protocol pqc-rh v1) records with SHA-256 alone. It supersedes
3
+ * @hashborne/verify, the same code under the product's earlier name.
4
+ * Pure functions, no network, except `fetchRecord` and `verifyRemote`, which read the public API
5
+ * and then verify the published bytes here.
6
+ */
7
+ export { DEFAULT_BASE_URL, DOMAINS, PQ, PROTOCOL_DOMAIN, PROTOCOL_VERSION, REGISTRY, SCHEME_IDS, } from './constants.js';
8
+ export { HashbornError, HashborneError } from './errors.js';
9
+ export { verifyAttestation, verifyRecord, verifySignature, } from './verify.js';
10
+ export { canonicalJson, digest, recordDigest } from './digest.js';
11
+ export { manifestRoot } from './merkle.js';
12
+ export { pqAddress } from './address.js';
13
+ export { decodeSignature, encodeSignature } from './signature.js';
14
+ export { ATTESTATION_MAX_BYTES, checkAttestation, encodeVerifyLink, parseAttestation, parseVerifyLink, } from './attestation.js';
15
+ export { fetchRecord, verifyRemote, } from './remote.js';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Merkle trees, protocol v1.
3
+ *
4
+ * Identity tree: leaf l_i = SHA-256(pubSeed || ADRS(1, i) || pk_i,0 || ... || pk_i,66) and
5
+ * node n_h,j = SHA-256(pubSeed || ADRS(2, h, j) || left || right), height 8, 256 leaves.
6
+ * Verifying climbs the auth path from the leaf and compares the result with the root.
7
+ *
8
+ * Manifest tree (RWA documents): leaves are the documents' SHA-256 hashes in the given order,
9
+ * node = SHA-256(0x01 || left || right), an odd node is paired with itself. The root is always
10
+ * a node, so a manifest root can never equal a single document hash.
11
+ */
12
+ import { type Hex } from './constants.js';
13
+ export declare function leafHash(pubSeed: Uint8Array, leaf: number, pks: Uint8Array[]): Uint8Array;
14
+ export declare function nodeHash(pubSeed: Uint8Array, height: number, index: number, left: Uint8Array, right: Uint8Array): Uint8Array;
15
+ /** Recomputes the root from a leaf hash and its auth path. */
16
+ export declare function climb(pubSeed: Uint8Array, leaf: number, leafValue: Uint8Array, auth: Uint8Array[], height: number): Uint8Array;
17
+ export declare function manifestRootBytes(hashes: Uint8Array[]): Uint8Array;
18
+ /**
19
+ * Merkle root over a list of 32-byte document hashes (RWA manifests), in the given order.
20
+ * An empty list throws. Compare the result with the `manifestRoot` field of an RWA record.
21
+ */
22
+ export declare function manifestRoot(hashes: readonly Hex[]): Hex;
package/dist/merkle.js ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Merkle trees, protocol v1.
3
+ *
4
+ * Identity tree: leaf l_i = SHA-256(pubSeed || ADRS(1, i) || pk_i,0 || ... || pk_i,66) and
5
+ * node n_h,j = SHA-256(pubSeed || ADRS(2, h, j) || left || right), height 8, 256 leaves.
6
+ * Verifying climbs the auth path from the leaf and compares the result with the root.
7
+ *
8
+ * Manifest tree (RWA documents): leaves are the documents' SHA-256 hashes in the given order,
9
+ * node = SHA-256(0x01 || left || right), an odd node is paired with itself. The root is always
10
+ * a node, so a manifest root can never equal a single document hash.
11
+ */
12
+ import { sha256 } from '@noble/hashes/sha2.js';
13
+ import { fromHex, toHex } from './bytes.js';
14
+ import { PQ } from './constants.js';
15
+ import { HashbornError } from './errors.js';
16
+ import { ADRS, H, adrs } from './hash.js';
17
+ export function leafHash(pubSeed, leaf, pks) {
18
+ const buf = new Uint8Array(64 + pks.length * PQ.n);
19
+ buf.set(pubSeed, 0);
20
+ buf.set(adrs(ADRS.leaf, leaf, 0), 32);
21
+ pks.forEach((pk, i) => buf.set(pk, 64 + i * PQ.n));
22
+ return sha256(buf);
23
+ }
24
+ export function nodeHash(pubSeed, height, index, left, right) {
25
+ return H(pubSeed, adrs(ADRS.node, height, index), left, right);
26
+ }
27
+ /** Recomputes the root from a leaf hash and its auth path. */
28
+ export function climb(pubSeed, leaf, leafValue, auth, height) {
29
+ if (auth.length !== height)
30
+ throw new HashbornError(`auth path must have ${height} nodes`);
31
+ let node = leafValue;
32
+ let index = leaf;
33
+ for (let h = 0; h < height; h++) {
34
+ const sibling = auth[h];
35
+ const parentIndex = index >> 1;
36
+ node = (index & 1) === 1 ? nodeHash(pubSeed, h + 1, parentIndex, sibling, node) : nodeHash(pubSeed, h + 1, parentIndex, node, sibling);
37
+ index = parentIndex;
38
+ }
39
+ return node;
40
+ }
41
+ const MANIFEST_NODE_PREFIX = new Uint8Array([1]);
42
+ export function manifestRootBytes(hashes) {
43
+ if (hashes.length === 0)
44
+ throw new HashbornError('a manifest needs at least one document hash');
45
+ let level = hashes.map((h, i) => {
46
+ if (h.length !== PQ.n)
47
+ throw new HashbornError(`document hash ${i} must be ${PQ.n} bytes`);
48
+ return h;
49
+ });
50
+ do {
51
+ const next = [];
52
+ for (let i = 0; i < level.length; i += 2) {
53
+ const left = level[i];
54
+ const right = i + 1 < level.length ? level[i + 1] : left;
55
+ next.push(H(MANIFEST_NODE_PREFIX, left, right));
56
+ }
57
+ level = next;
58
+ } while (level.length > 1);
59
+ return level[0];
60
+ }
61
+ /**
62
+ * Merkle root over a list of 32-byte document hashes (RWA manifests), in the given order.
63
+ * An empty list throws. Compare the result with the `manifestRoot` field of an RWA record.
64
+ */
65
+ export function manifestRoot(hashes) {
66
+ if (!Array.isArray(hashes))
67
+ throw new HashbornError('manifest leaves must be an array of 32-byte hex hashes');
68
+ return toHex(manifestRootBytes(hashes.map((h, i) => fromHex(h, PQ.n, `leaves[${i}]`))));
69
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Reading a published record from the Hashborn API and verifying it here. The API is only a
3
+ * transport: the server's own `verified` flag is never read. The record text, signature, root and
4
+ * pubSeed it returns are checked locally with SHA-256, exactly as `verifyRecord` checks them.
5
+ *
6
+ * Source: `GET /api/v1/records/<id>`. When that answer carries no signature (the v1 record view
7
+ * leaves the 2,404 signature bytes out), the published bytes are read from
8
+ * `GET /api/index?q=record&id=<id>`, the same index route the site's pages read.
9
+ */
10
+ import { type Attestation, type DigestFields, type Hex } from './constants.js';
11
+ import { type RecordVerifyResult } from './verify.js';
12
+ /** The subset of `fetch` this module uses; the global `fetch` fits it. */
13
+ export type FetchLike = (url: string, init?: {
14
+ headers?: Record<string, string>;
15
+ }) => Promise<{
16
+ ok: boolean;
17
+ status: number;
18
+ json(): Promise<unknown>;
19
+ }>;
20
+ export interface FetchOptions {
21
+ /** Site that serves the API. Default https://hashborn.com. Redirects are followed (fetch's default), so an earlier address that redirects here keeps working. */
22
+ baseUrl?: string;
23
+ /** A fetch implementation. Default the global `fetch`. */
24
+ fetch?: FetchLike;
25
+ }
26
+ /** One anchored record as published, with its attestation built from the published bytes. */
27
+ export interface RemoteRecord {
28
+ /** Anchoring transaction hash followed by the log index as 4 bytes. */
29
+ id: Hex;
30
+ attestation: Attestation;
31
+ /** The record text exactly as anchored (canonical JSON). */
32
+ record: string;
33
+ /** The record's fields, or null when the text is not a flat JSON object. */
34
+ fields: DigestFields | null;
35
+ /** The leaf the anchor burned. */
36
+ leaf: number;
37
+ /** The registered wallet that anchored it. */
38
+ wallet: Hex;
39
+ txHash: Hex;
40
+ blockNumber: number;
41
+ /** ISO time of the anchoring block. */
42
+ anchoredAt: string;
43
+ chainId: number | null;
44
+ /** Which route served the signature bytes. */
45
+ source: 'v1' | 'index';
46
+ }
47
+ export declare const RECORD_ID_RE: RegExp;
48
+ /**
49
+ * Reads one anchored record (id = anchoring transaction hash + log index, as on
50
+ * `/records/<id>`) and builds its attestation from the published bytes. Does not verify: pass the
51
+ * result to `verifyRecord(r.attestation, r.record)`, or call `verifyRemote`.
52
+ * Throws a HashbornError (`not-found`, `network`, `malformed`) when there is nothing to verify.
53
+ */
54
+ export declare function fetchRecord(id: string, options?: FetchOptions): Promise<RemoteRecord>;
55
+ export interface RemoteVerifyResult extends RecordVerifyResult {
56
+ id: Hex;
57
+ remote: RemoteRecord;
58
+ }
59
+ /**
60
+ * `fetchRecord` then `verifyRecord` here, on the published bytes: the record text must hash to
61
+ * `messageHash`, the signature must climb to the identity's root, and the leaf the anchor burned
62
+ * must be the leaf that signed. The server's own verdict is never used.
63
+ */
64
+ export declare function verifyRemote(id: string, options?: FetchOptions): Promise<RemoteVerifyResult>;