@hashborn/verify 0.0.0-stage → 0.1.1

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/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1 · 2026-10-13
4
+
5
+ First release as `@hashborn/verify`. It supersedes `@hashborne/verify` 0.1.0: the product is now
6
+ named Hashborn and lives at https://hashborn.com.
7
+
8
+ - Package renamed to `@hashborn/verify`. The CLI is `hashborn-verify`; `hashborne-verify` is kept
9
+ as a second name for the same command.
10
+ - The default site for `fetchRecord`, `verifyRemote`, `encodeVerifyLink` and the CLI is
11
+ https://hashborn.com (was https://hashborne.vercel.app). The old address redirects there, and
12
+ fetch follows redirects, so 0.1.0 keeps working too.
13
+ - `HashborneError` is renamed `HashbornError`; `HashborneError` stays exported as the same class.
14
+ - Verify links on any host still parse: only the `attestation` parameter is read.
15
+ - The README's CLI sample prints the anchoring transaction hash whole, as the CLI does.
16
+
17
+ The protocol is unchanged (pqc-rh v1): every signature, digest and verdict is the same as in 0.1.0.
18
+
19
+ ## 0.1.0 · 2026-10-12
20
+
21
+ First release, as `@hashborne/verify`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hashborn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,179 @@
1
- # Temporary Holding Version
1
+ # @hashborn/verify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Verify Hashborn records yourself, offline, with SHA-256 and nothing else.
4
+
5
+ Hashborn signs every agent action, transfer, asset proof and vault withdrawal once with a
6
+ hash-based one-time key (protocol `pqc-rh` v1) and anchors it on Robinhood Chain. This package
7
+ checks those signatures in Node or the browser, with one dependency (`@noble/hashes`) and no
8
+ server in the loop.
9
+
10
+ ## Why SHA-256 is enough
11
+
12
+ A Hashborn identity is a Merkle tree of 256 WOTS one-time keys (w = 16, 67 chains, height 8).
13
+ Its public key is 64 bytes: a Merkle `root` and a `pubSeed`. A signature is 2,404 bytes: the
14
+ leaf index, 67 chain values and 8 sibling hashes. To verify one, you finish each of the 67 hash
15
+ chains from the signed digest, hash the results into the leaf, and climb 8 levels to the root.
16
+ Every step is one SHA-256 call: 45 to 990 on the chains, then nine more for the leaf and the
17
+ climb. If the climb lands on the registered root, the signature is genuine.
18
+
19
+ There is no elliptic curve and no lattice anywhere in that path. The only assumption is that
20
+ SHA-256 resists second preimages, which a quantum computer weakens to about 2^128 work (Grover),
21
+ not to a break. A verifier you run today keeps working after Q-day.
22
+
23
+ ## Install
24
+
25
+ ```sh
26
+ npm install @hashborn/verify
27
+ ```
28
+
29
+ ESM only, Node 20.19 or later, or any modern browser or bundler. TypeScript types are included.
30
+
31
+ ### Moving from @hashborne/verify
32
+
33
+ This package supersedes `@hashborne/verify`, the same code under the product's earlier name.
34
+ Swap the package name in your imports; the API is the same. The error class is now
35
+ `HashbornError`, and `HashborneError` is still exported as the same class, so `instanceof` checks
36
+ keep working. The CLI is `hashborn-verify`, with `hashborne-verify` kept as a second name for it.
37
+ The default site is https://hashborn.com. Changes are listed in `CHANGELOG.md`.
38
+
39
+ ## Examples
40
+
41
+ ### Verify a link
42
+
43
+ Every record page on the site has a verify link. Paste it, or pass just its `attestation`
44
+ parameter.
45
+
46
+ ```ts
47
+ import {parseVerifyLink, verifyAttestation} from '@hashborn/verify'
48
+
49
+ const attestation = parseVerifyLink('https://hashborn.com/verify?attestation=eyJ2ZXJza...')
50
+ const result = verifyAttestation(attestation)
51
+
52
+ result.valid // true
53
+ result.pqAddress // 'pq14DdQMGxcqXDfp4wBXypxHsoZY9W9', the signer
54
+ result.leaf // 3, the one-time key that signed
55
+ result.domain // 'pqc-rh/agent-action/v1'
56
+ result.reason // set when valid is false
57
+ ```
58
+
59
+ Only the `attestation` parameter is read, so a link shared from the earlier address,
60
+ `hashborne.vercel.app`, verifies the same way.
61
+
62
+ ### Verify a record by id
63
+
64
+ A record id is the anchoring transaction hash followed by the log index, as in
65
+ `https://hashborn.com/records/<id>`. `verifyRemote` reads the published bytes from the
66
+ public API and verifies them here. The server's own verdict is never used.
67
+
68
+ ```ts
69
+ import {verifyRemote} from '@hashborn/verify'
70
+
71
+ const result = await verifyRemote('0x42c1acc3116dc13c56638cccd5fcaca5134a9653b7092883796afd74fd28d2a500000000')
72
+
73
+ result.valid // true: the record text hashes to the signed digest, and the signature climbs to the root
74
+ result.remote.record // the record exactly as anchored, e.g. {"agent":"smoke-test",...}
75
+ result.remote.txHash // the anchoring transaction on Robinhood Chain
76
+ ```
77
+
78
+ Pass `{baseUrl, fetch}` to read from another deployment or through your own fetch.
79
+
80
+ ### Recompute a digest
81
+
82
+ A record's `messageHash` is `SHA-256(domain || "\n" || canonical JSON of its fields)`, keys
83
+ sorted. Recompute it from the fields you expect, then check the attestation signed exactly that.
84
+
85
+ ```ts
86
+ import {DOMAINS, digest, verifyRecord} from '@hashborn/verify'
87
+
88
+ const fields = {agent: 'smoke-test', at: '2026-10-10T06:36:23.653Z', kind: 'attest', summary: 'deployment smoke test'}
89
+
90
+ digest(DOMAINS.agentAction, fields) // '0x10ccea67...'
91
+ verifyRecord(attestation, fields) // signature check plus digest check, one verdict
92
+ ```
93
+
94
+ For an RWA asset, `manifestRoot(documentHashes)` recomputes the Merkle root over the SHA-256
95
+ hashes of its documents, in order, to compare with the record's `manifestRoot` field.
96
+
97
+ ## CLI
98
+
99
+ ```sh
100
+ npx @hashborn/verify <input> [--base-url <url>] [--json]
101
+ ```
102
+
103
+ Installed globally or as a dependency, the command is `hashborn-verify`. The input is a verify
104
+ link, a path to an attestation saved as JSON (`-` reads stdin), a record id, or a record page
105
+ URL (`https://hashborn.com/records/<id>`).
106
+
107
+ ```
108
+ $ hashborn-verify 0x42c1acc3116dc13c56638cccd5fcaca5134a9653b7092883796afd74fd28d2a500000000
109
+ valid
110
+ domain pqc-rh/agent-action/v1
111
+ signer pq14DdQMGxcqXDfp4wBXypxHsoZY9W9
112
+ leaf 3
113
+ root 0xd62ab9cd596a47725174077fe391069d1f0e4fc06323e636e0645090f70e1ef1
114
+ message 0x10ccea672702a258da36b5c05597805ec0cd243a66cf74097a69a4907cff0094
115
+ record its published text hashes to the signed message
116
+ anchored 0x42c1acc3116dc13c56638cccd5fcaca5134a9653b7092883796afd74fd28d2a5 block 84792793 chain 4663 at 2026-10-10T06:36:24.000Z
117
+ hashes 555 SHA-256 calls on the chains
118
+ ```
119
+
120
+ Exit code 0 when valid, 1 otherwise. `--json` prints the result object.
121
+
122
+ ## What it checks
123
+
124
+ `verifyAttestation` checks, with SHA-256 alone:
125
+
126
+ - the attestation's shape and sizes: 32-byte root, pubSeed and messageHash, 67 chain values of
127
+ 32 bytes, 8 auth nodes, a leaf index in 0..255;
128
+ - protocol fields: version 1, scheme `wots-merkle`, height 8, a v1 domain;
129
+ - the WOTS signature and Merkle path over `messageHash`, against `root` and `pubSeed`;
130
+ - that `pqAddress` derives from `root` and `pubSeed`.
131
+
132
+ `verifyRecord` adds that the record's fields (or its published text) hash to `messageHash` under
133
+ the attestation's domain. `verifyRemote` adds that the leaf the anchor burned is the leaf that
134
+ signed.
135
+
136
+ ## What it does not check
137
+
138
+ It does not check that a record was anchored on chain, or that its root belongs to the wallet
139
+ that claims it. For that, read the registry: `PQRegistry` at
140
+ `0xD791A8Eb2b8C48E5340579A9f91F983AB9597c56` on Robinhood Chain (chain id 4663). Its `Anchored`
141
+ event carries the wallet, root, domain, record and the 2,404-byte signature, so `decodeSignature`
142
+ and `verifyRecord` can run on bytes read straight from the chain, and its `IdentityRegistered`
143
+ event binds each wallet to one root and pubSeed. `REGISTRY` exports the address and chain id.
144
+
145
+ The pq address is a display form of the public key. Compare roots when you need certainty.
146
+
147
+ ## API
148
+
149
+ | Export | What it does |
150
+ |---|---|
151
+ | `verifyAttestation(att)` | Verifies an attestation. Returns `{valid, code?, reason?, leaf, pqAddress, domain, messageHash, root, computedRoot, hashCalls}`. Never throws. |
152
+ | `verifyRecord(att, fields)` | `verifyAttestation` plus the digest check. `fields` is the fields object or the record text as published. Adds `recordDigest`. |
153
+ | `verifySignature(messageHash, signature, {root, pubSeed})` | Low level: does the signature climb to the root? Returns `{valid, computedRoot, hashCalls}`. |
154
+ | `digest(domain, fields)` | `SHA-256(domain \|\| "\n" \|\| canonicalJson(fields))`, byte for byte as the site computes it. |
155
+ | `recordDigest(domain, record)` | The same over record text exactly as anchored. This is what the registry computes. |
156
+ | `canonicalJson(fields)` | Keys sorted by UTF-16 code unit, no whitespace; strings, finite numbers, booleans and null only. |
157
+ | `manifestRoot(hashes)` | Merkle root over 32-byte document hashes, for RWA manifests. |
158
+ | `pqAddress({root, pubSeed})` | The `pq1...` address a public key derives. |
159
+ | `parseVerifyLink(urlOrParam)` | Reads the attestation out of a verify link, a query string or the bare parameter. |
160
+ | `encodeVerifyLink(att, base?)` | Builds a verify link. `base` defaults to `https://hashborn.com`; `''` gives `/verify?attestation=...`. |
161
+ | `parseAttestation(json)` / `checkAttestation(value)` | Strict shape check of untrusted JSON or an object, 64 kB cap. |
162
+ | `encodeSignature(sig)` / `decodeSignature(hex)` | The 2,404-byte wire form the registry stores, both ways. |
163
+ | `fetchRecord(id, {baseUrl, fetch})` | Reads an anchored record and builds its attestation from the published bytes. Does not verify. |
164
+ | `verifyRemote(id, {baseUrl, fetch})` | `fetchRecord`, then `verifyRecord` locally, plus the leaf check. |
165
+ | `PQ`, `DOMAINS`, `SCHEME_IDS`, `REGISTRY` | Protocol v1 parameters, attestation domains, scheme ids, registry address and chain id. |
166
+ | `HashbornError` | Thrown on malformed input or a failed fetch, with `code`: `malformed`, `unsupported`, `not-found` or `network`. `HashborneError` is the same class under its earlier name. |
167
+ | `Attestation`, `MerkleSignature`, `DigestFields`, `Domain`, `Hex` | Types. |
168
+
169
+ Failure codes in a result: `malformed`, `unsupported`, `signature`, `pq-address`, `digest`, `leaf`.
170
+
171
+ `fetchRecord` reads `GET /api/v1/records/<id>`. That view leaves the 2,404 signature bytes out,
172
+ so the signature and the record text are then read from the site's index route,
173
+ `GET /api/index?q=record&id=<id>`. Both are public and cached.
174
+
175
+ The protocol is specified in full on the site's docs page, https://hashborn.com/docs.
176
+
177
+ ## License
178
+
179
+ MIT
@@ -0,0 +1,9 @@
1
+ /** pq address: "pq1" + base58(SHA-256("pqc-rh" || pubSeed || root)[0..20]). Display only. */
2
+ import { type Hex } from './constants.js';
3
+ export declare const PQ_ADDRESS_PREFIX = "pq1";
4
+ export declare function pqAddressFromBytes(pubSeed: Uint8Array, root: Uint8Array): string;
5
+ /** The pq address an identity's public key (root and pubSeed) derives. */
6
+ export declare function pqAddress(pub: {
7
+ root: Hex;
8
+ pubSeed: Hex;
9
+ }): string;
@@ -0,0 +1,12 @@
1
+ /** pq address: "pq1" + base58(SHA-256("pqc-rh" || pubSeed || root)[0..20]). Display only. */
2
+ import { base58, fromHex, utf8ToBytes } from './bytes.js';
3
+ import { PROTOCOL_DOMAIN } from './constants.js';
4
+ import { H } from './hash.js';
5
+ export const PQ_ADDRESS_PREFIX = 'pq1';
6
+ export function pqAddressFromBytes(pubSeed, root) {
7
+ return PQ_ADDRESS_PREFIX + base58(H(utf8ToBytes(PROTOCOL_DOMAIN), pubSeed, root).slice(0, 20));
8
+ }
9
+ /** The pq address an identity's public key (root and pubSeed) derives. */
10
+ export function pqAddress(pub) {
11
+ return pqAddressFromBytes(fromHex(pub.pubSeed, 32, 'pubSeed'), fromHex(pub.root, 32, 'root'));
12
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The Attestation boundary: checking untrusted JSON (pasted, read from a file or carried in a
3
+ * link) against the attestation shape, and the verify link that carries one.
4
+ *
5
+ * Link format: `<site>/verify?attestation=<base64url, no padding, of the UTF-8 JSON>`.
6
+ * The rules match the site's own parser (apps/web lib/pq/attestation.ts): the same fields, sizes
7
+ * and 64 kB cap, unknown keys dropped, keys returned in schema order.
8
+ */
9
+ import { type Attestation } from './constants.js';
10
+ /** Largest attestation text accepted. A real one is about 5 kB. */
11
+ export declare const ATTESTATION_MAX_BYTES: number;
12
+ export declare const ATTESTATION_PARAM = "attestation";
13
+ /**
14
+ * Checks `value` against the attestation shape and returns a clean copy. `requireAnchor` makes
15
+ * `txHash` and `anchoredAt` mandatory (null allowed), as the site's parser does; without it a
16
+ * missing one reads as null, since neither is part of what the leaf signed.
17
+ * Throws a HashbornError naming the first bad field.
18
+ */
19
+ export declare function checkAttestation(value: unknown, requireAnchor?: boolean): Attestation;
20
+ /** Parses attestation JSON text, as pasted on the Verify page or saved to a file. */
21
+ export declare function parseAttestation(text: string): Attestation;
22
+ /** The `attestation` parameter of a verify link: base64url, no padding, of the UTF-8 JSON. */
23
+ export declare function encodeAttestationParam(attestation: Attestation): string;
24
+ export declare function decodeAttestationParam(param: string): Attestation;
25
+ /**
26
+ * Reads the attestation out of a verify link. Accepts a full URL
27
+ * (`https://hashborn.com/verify?attestation=...`), a path (`/verify?attestation=...`),
28
+ * a query string (`?attestation=...` or `attestation=...`) or the bare parameter value.
29
+ * Throws a HashbornError when there is no attestation or it is malformed.
30
+ */
31
+ export declare function parseVerifyLink(urlOrParam: string): Attestation;
32
+ /**
33
+ * The verify link that carries `attestation`, on `base` (default the Hashborn site). With
34
+ * `base = ''` it is the site-relative `/verify?attestation=...` the site itself builds.
35
+ */
36
+ export declare function encodeVerifyLink(attestation: Attestation, base?: string): string;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The Attestation boundary: checking untrusted JSON (pasted, read from a file or carried in a
3
+ * link) against the attestation shape, and the verify link that carries one.
4
+ *
5
+ * Link format: `<site>/verify?attestation=<base64url, no padding, of the UTF-8 JSON>`.
6
+ * The rules match the site's own parser (apps/web lib/pq/attestation.ts): the same fields, sizes
7
+ * and 64 kB cap, unknown keys dropped, keys returned in schema order.
8
+ */
9
+ import { base64urlDecode, base64urlEncode } from './bytes.js';
10
+ import { DEFAULT_BASE_URL, DOMAINS, PQ, SCHEME_IDS } from './constants.js';
11
+ import { HashbornError } from './errors.js';
12
+ /** Largest attestation text accepted. A real one is about 5 kB. */
13
+ export const ATTESTATION_MAX_BYTES = 64 * 1024;
14
+ export const ATTESTATION_PARAM = 'attestation';
15
+ const PQ_ADDRESS_RE = /^pq1[1-9A-HJ-NP-Za-km-z]{20,40}$/;
16
+ const DOMAIN_SET = new Set(Object.values(DOMAINS));
17
+ const SCHEME_SET = new Set(SCHEME_IDS);
18
+ function isObject(value) {
19
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
20
+ }
21
+ function hexField(value, bytes, path) {
22
+ if (typeof value !== 'string' || value.length !== 2 + bytes * 2 || !/^0x[0-9a-fA-F]*$/.test(value)) {
23
+ throw new HashbornError(`${path}: ${bytes}-byte 0x hex expected`);
24
+ }
25
+ return value;
26
+ }
27
+ /**
28
+ * Checks `value` against the attestation shape and returns a clean copy. `requireAnchor` makes
29
+ * `txHash` and `anchoredAt` mandatory (null allowed), as the site's parser does; without it a
30
+ * missing one reads as null, since neither is part of what the leaf signed.
31
+ * Throws a HashbornError naming the first bad field.
32
+ */
33
+ export function checkAttestation(value, requireAnchor = true) {
34
+ if (!isObject(value))
35
+ throw new HashbornError('attestation: object expected');
36
+ if (value.version !== 1)
37
+ throw new HashbornError('version: protocol version 1 expected', 'unsupported');
38
+ if (typeof value.scheme !== 'string' || !SCHEME_SET.has(value.scheme))
39
+ throw new HashbornError('scheme: unknown scheme');
40
+ if (typeof value.domain !== 'string' || !DOMAIN_SET.has(value.domain)) {
41
+ throw new HashbornError('domain: not a protocol v1 domain', 'unsupported');
42
+ }
43
+ if (typeof value.pqAddress !== 'string' || !PQ_ADDRESS_RE.test(value.pqAddress)) {
44
+ throw new HashbornError('pqAddress: pq1 base58 address expected');
45
+ }
46
+ const root = hexField(value.root, PQ.n, 'root');
47
+ const pubSeed = hexField(value.pubSeed, PQ.n, 'pubSeed');
48
+ if (value.height !== PQ.height)
49
+ throw new HashbornError(`height: ${PQ.height} expected`, 'unsupported');
50
+ const messageHash = hexField(value.messageHash, PQ.n, 'messageHash');
51
+ const sig = value.signature;
52
+ if (!isObject(sig))
53
+ throw new HashbornError('signature: object expected');
54
+ const leaf = sig.leaf;
55
+ if (typeof leaf !== 'number' || !Number.isInteger(leaf) || leaf < 0 || leaf >= PQ.leaves) {
56
+ throw new HashbornError(`signature.leaf: integer in 0..${PQ.leaves - 1} expected`);
57
+ }
58
+ const wots = hexField(sig.wots, PQ.len * PQ.n, 'signature.wots');
59
+ if (!Array.isArray(sig.auth) || sig.auth.length !== PQ.height) {
60
+ throw new HashbornError(`signature.auth: ${PQ.height} nodes expected`);
61
+ }
62
+ const auth = sig.auth.map((node, i) => hexField(node, PQ.n, `signature.auth.${i}`));
63
+ let txHash = null;
64
+ if (value.txHash !== null && (requireAnchor || value.txHash !== undefined))
65
+ txHash = hexField(value.txHash, 32, 'txHash');
66
+ let anchoredAt = null;
67
+ if (value.anchoredAt !== null && (requireAnchor || value.anchoredAt !== undefined)) {
68
+ if (typeof value.anchoredAt !== 'string' || value.anchoredAt.length > 64)
69
+ throw new HashbornError('anchoredAt: string of at most 64 characters expected');
70
+ anchoredAt = value.anchoredAt;
71
+ }
72
+ return {
73
+ version: 1,
74
+ scheme: value.scheme,
75
+ domain: value.domain,
76
+ pqAddress: value.pqAddress,
77
+ root,
78
+ pubSeed,
79
+ height: PQ.height,
80
+ messageHash,
81
+ signature: { leaf, wots, auth },
82
+ txHash,
83
+ anchoredAt,
84
+ };
85
+ }
86
+ /** Parses attestation JSON text, as pasted on the Verify page or saved to a file. */
87
+ export function parseAttestation(text) {
88
+ if (typeof text !== 'string')
89
+ throw new HashbornError('attestation must be text');
90
+ if (text.length > ATTESTATION_MAX_BYTES)
91
+ throw new HashbornError('attestation is too large');
92
+ let raw;
93
+ try {
94
+ raw = JSON.parse(text);
95
+ }
96
+ catch {
97
+ throw new HashbornError('attestation is not valid JSON');
98
+ }
99
+ return checkAttestation(raw);
100
+ }
101
+ /** The `attestation` parameter of a verify link: base64url, no padding, of the UTF-8 JSON. */
102
+ export function encodeAttestationParam(attestation) {
103
+ return base64urlEncode(new TextEncoder().encode(JSON.stringify(attestation)));
104
+ }
105
+ export function decodeAttestationParam(param) {
106
+ if (typeof param !== 'string' || param.length > ATTESTATION_MAX_BYTES)
107
+ throw new HashbornError('attestation link is too large');
108
+ let bytes;
109
+ try {
110
+ bytes = base64urlDecode(param);
111
+ }
112
+ catch {
113
+ throw new HashbornError('attestation link is not base64url');
114
+ }
115
+ return parseAttestation(new TextDecoder().decode(bytes));
116
+ }
117
+ /**
118
+ * Reads the attestation out of a verify link. Accepts a full URL
119
+ * (`https://hashborn.com/verify?attestation=...`), a path (`/verify?attestation=...`),
120
+ * a query string (`?attestation=...` or `attestation=...`) or the bare parameter value.
121
+ * Throws a HashbornError when there is no attestation or it is malformed.
122
+ */
123
+ export function parseVerifyLink(urlOrParam) {
124
+ if (typeof urlOrParam !== 'string')
125
+ throw new HashbornError('verify link must be a string');
126
+ const text = urlOrParam.trim();
127
+ let param = text;
128
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text) || text.includes('?') || text.includes('=') || text.startsWith('/')) {
129
+ let query = text;
130
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text) || text.startsWith('/')) {
131
+ try {
132
+ query = new URL(text, 'https://link.invalid').search;
133
+ }
134
+ catch {
135
+ throw new HashbornError('verify link is not a URL');
136
+ }
137
+ }
138
+ else if (text.includes('?')) {
139
+ query = text.slice(text.indexOf('?'));
140
+ }
141
+ param = new URLSearchParams(query.split('#')[0]).get(ATTESTATION_PARAM);
142
+ }
143
+ if (!param)
144
+ throw new HashbornError(`verify link has no ${ATTESTATION_PARAM} parameter`);
145
+ return decodeAttestationParam(param);
146
+ }
147
+ /**
148
+ * The verify link that carries `attestation`, on `base` (default the Hashborn site). With
149
+ * `base = ''` it is the site-relative `/verify?attestation=...` the site itself builds.
150
+ */
151
+ export function encodeVerifyLink(attestation, base = DEFAULT_BASE_URL) {
152
+ return `${base.replace(/\/+$/, '')}/verify?${ATTESTATION_PARAM}=${encodeAttestationParam(attestation)}`;
153
+ }
@@ -0,0 +1,15 @@
1
+ /** Byte helpers. Hex in and out is always 0x-prefixed. */
2
+ import { concatBytes, utf8ToBytes } from '@noble/hashes/utils.js';
3
+ import type { Hex } from './constants.js';
4
+ export { concatBytes, utf8ToBytes };
5
+ export declare function toHex(bytes: Uint8Array): Hex;
6
+ export declare function isHex(value: unknown, length?: number): value is Hex;
7
+ /** Parses 0x hex into bytes; `length` pins the byte count. Throws a HashbornError naming `label`. */
8
+ export declare function fromHex(value: unknown, length: number | undefined, label: string): Uint8Array;
9
+ export declare function equalBytes(a: Uint8Array, b: Uint8Array): boolean;
10
+ /** Base58 with the Bitcoin alphabet; each leading zero byte becomes a leading "1". */
11
+ export declare function base58(bytes: Uint8Array): string;
12
+ /** Base64url without padding (RFC 4648 section 5). */
13
+ export declare function base64urlEncode(bytes: Uint8Array): string;
14
+ /** Strict base64url decode: no padding, no stray characters, unused trailing bits must be zero. */
15
+ export declare function base64urlDecode(text: string): Uint8Array;
package/dist/bytes.js ADDED
@@ -0,0 +1,96 @@
1
+ /** Byte helpers. Hex in and out is always 0x-prefixed. */
2
+ import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
3
+ import { HashbornError } from './errors.js';
4
+ export { concatBytes, utf8ToBytes };
5
+ const HEX_RE = /^0x(?:[0-9a-fA-F]{2})*$/;
6
+ export function toHex(bytes) {
7
+ return `0x${bytesToHex(bytes)}`;
8
+ }
9
+ export function isHex(value, length) {
10
+ return typeof value === 'string' && HEX_RE.test(value) && (length === undefined || value.length === 2 + length * 2);
11
+ }
12
+ /** Parses 0x hex into bytes; `length` pins the byte count. Throws a HashbornError naming `label`. */
13
+ export function fromHex(value, length, label) {
14
+ if (!isHex(value))
15
+ throw new HashbornError(`${label} must be 0x-prefixed hex`);
16
+ if (length !== undefined && value.length !== 2 + length * 2) {
17
+ throw new HashbornError(`${label} must be ${length} bytes, got ${(value.length - 2) / 2}`);
18
+ }
19
+ return hexToBytes(value.slice(2));
20
+ }
21
+ export function equalBytes(a, b) {
22
+ if (a.length !== b.length)
23
+ return false;
24
+ let diff = 0;
25
+ for (let i = 0; i < a.length; i++)
26
+ diff |= a[i] ^ b[i];
27
+ return diff === 0;
28
+ }
29
+ const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
30
+ /** Base58 with the Bitcoin alphabet; each leading zero byte becomes a leading "1". */
31
+ export function base58(bytes) {
32
+ let zeros = 0;
33
+ while (zeros < bytes.length && bytes[zeros] === 0)
34
+ zeros++;
35
+ const digits = [];
36
+ for (let i = zeros; i < bytes.length; i++) {
37
+ let carry = bytes[i];
38
+ for (let j = 0; j < digits.length; j++) {
39
+ carry += digits[j] << 8;
40
+ digits[j] = carry % 58;
41
+ carry = (carry / 58) | 0;
42
+ }
43
+ while (carry > 0) {
44
+ digits.push(carry % 58);
45
+ carry = (carry / 58) | 0;
46
+ }
47
+ }
48
+ let out = '1'.repeat(zeros);
49
+ for (let i = digits.length - 1; i >= 0; i--)
50
+ out += B58[digits[i]];
51
+ return out;
52
+ }
53
+ const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
54
+ const B64URL_INDEX = new Map([...B64URL].map((c, i) => [c, i]));
55
+ /** Base64url without padding (RFC 4648 section 5). */
56
+ export function base64urlEncode(bytes) {
57
+ let out = '';
58
+ let i = 0;
59
+ for (; i + 2 < bytes.length; i += 3) {
60
+ const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
61
+ out += B64URL[n >> 18] + B64URL[(n >> 12) & 63] + B64URL[(n >> 6) & 63] + B64URL[n & 63];
62
+ }
63
+ const rest = bytes.length - i;
64
+ if (rest === 1) {
65
+ const n = bytes[i] << 16;
66
+ out += B64URL[n >> 18] + B64URL[(n >> 12) & 63];
67
+ }
68
+ else if (rest === 2) {
69
+ const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
70
+ out += B64URL[n >> 18] + B64URL[(n >> 12) & 63] + B64URL[(n >> 6) & 63];
71
+ }
72
+ return out;
73
+ }
74
+ /** Strict base64url decode: no padding, no stray characters, unused trailing bits must be zero. */
75
+ export function base64urlDecode(text) {
76
+ if (text.length % 4 === 1)
77
+ throw new HashbornError('not base64url');
78
+ const out = new Uint8Array(Math.floor((text.length * 3) / 4));
79
+ let acc = 0;
80
+ let bits = 0;
81
+ let o = 0;
82
+ for (const c of text) {
83
+ const v = B64URL_INDEX.get(c);
84
+ if (v === undefined)
85
+ throw new HashbornError('not base64url');
86
+ acc = ((acc << 6) | v) & 0xffff;
87
+ bits += 6;
88
+ if (bits >= 8) {
89
+ bits -= 8;
90
+ out[o++] = (acc >> bits) & 0xff;
91
+ }
92
+ }
93
+ if ((acc & ((1 << bits) - 1)) !== 0)
94
+ throw new HashbornError('not base64url');
95
+ return out;
96
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,8 @@
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
+ export {};