@smartledger/notaryhash
v2.5.1
Published
Client SDK for NotaryHash — sign hashes locally, anchor them to BSV, and verify certificates offline against Bitcoin block headers. Privacy-preserving and post-quantum (ML-DSA / SLH-DSA).
Maintainers
Readme
@smartledger/notaryhash
Client SDK for NotaryHash — anchor a signed hash to the BSV blockchain and verify the resulting certificate, in any Node.js app.
- Privacy-preserving — you hash and sign locally; only the hash, signature, and public key are ever sent. Your document and private key never leave your process.
- Post-quantum signatures — ECDSA plus ML-DSA (FIPS 204) and SLH-DSA (FIPS 205; see the note on SLH-DSA under "Version 2.0").
- Trustless verification — verify a certificate against Bitcoin block headers alone, with no dependency on the issuing service.
- Tiny — depends only on
@noblecrypto. No server code.
npm install @smartledger/notaryhashNotarize (sign locally, anchor via a service)
import { generateKey, notarizeBytes } from '@smartledger/notaryhash';
import { readFileSync } from 'node:fs';
const key = generateKey('ECDSA-secp256k1'); // keep key.privateKey secret — it never leaves you
const { certificate } = await notarizeBytes(readFileSync('contract.pdf'), key.privateKey, {
url: 'https://notaryhash.com',
apiKey: process.env.NOTARYHASH_API_KEY, // write endpoint requires a key
});
console.log('anchored:', certificate.anchor.txid);Swap generateKey('ML-DSA-65') (or any of the 16 algorithms) for post-quantum signatures —
pass the same algorithm to notarizeBytes via the options.
Retrying safely
A certificate's id covers its createdAt, to the second. The service returns the existing
certificate only for a request with the same createdAt; without one, every call is stamped
with the current time, so calling notarizeBytes again after a timeout anchors the same
record a second time. If a record must be anchored once, fix its time before the first
attempt and reuse it:
import { canonicalCreatedAt, notarizeBytes } from '@smartledger/notaryhash';
const createdAt = canonicalCreatedAt(); // choose once; store it with your record
// ...on every attempt, including retries after a timeout or a 5xx:
const { certificate, alreadyExisted } = await notarizeBytes(bytes, key.privateKey, {
url, apiKey, createdAt,
});That is enough for ECDSA, whose signatures here are deterministic. ML-DSA and SLH-DSA signatures can differ from one call to the next, which also changes the id, so for those sign once, store the body, and resend it:
import { hashFile, sign, submitNotarize } from '@smartledger/notaryhash';
const body = sign('ML-DSA-65', key.privateKey, hashFile(bytes)); // store this
const { certificate } = await submitNotarize(body, { url, apiKey }); // safe to repeatVersion 2.0: a signature that says what it is for
By default ("1.0") the signature is over the payload hash itself. That has two limits:
nothing in it says it was made for NotaryHash, and createdAt is not signed, so anyone can
have a published signature anchored again under another time. Ask for 2.0 and both go:
const body = sign('ML-DSA-65', key.privateKey, hashFile(bytes), canonicalCreatedAt(), { version: '2.0' });
const { certificate } = await submitNotarize(body, { url, apiKey }); // certificate.version === '2.0'
// or: notarizeBytes(bytes, key.privateKey, { url, apiKey, algorithm: 'ML-DSA-65', version: '2.0' })The signer signs SHA-256(lp("NotaryHash/2.0 signature") || lp(algorithm) || lp(hashAlgorithm)
|| lp(payloadHash) || lp(publicKey) || u64be(createdAt seconds)) (signedMessage() computes
it). ML-DSA signs it with the FIPS 204 context string NotaryHash/2.0; ECDSA signs it as the
digest and has one encoding (64-byte low-s signature, 33-byte key). Verification needs
nothing new: verifyDocument and verifyCertificateStandalone read the certificate's
version and apply its rule.
- Everyone who verifies your 2.0 certificates needs SDK 2.4.0 or later. Earlier versions, and other verifiers written for 1.0, refuse a 2.0 certificate; they do not misread it.
- If you issue 2.0 only, tell your verifiers to pass
acceptVersions: ['2.0']in the policy. An ECDSA 2.0 signature can otherwise be re-presented as a 1.0 certificate of a hash that is no document's. (An ML-DSA one cannot: the context string differs.) createdAtis now your signed claim. It is still not evidence of time; the block is.- SLH-DSA. In 1.0, an SLH-DSA signature made by this SDK is FIPS 205's internal signing function over the payload hash, without the standard context prefix, so a standard FIPS 205 verifier rejects it unless it exposes that function. It verifies with this SDK and is documented in SPEC §2. 2.0 specifies the standard function; this SDK does not offer 2.0 SLH-DSA yet and says so if asked.
Sealed chains
When a history must not fork (checkpoints of a log, the state of a registry), make each record the sealed successor of the one before. The anchoring transaction spends a seal output of the previous record's transaction, and an output is spent once, so a record can have only one successor. Anyone can check the link from the two certificates.
import { canonicalCreatedAt, hashFile, sealBody, sign, submitNotarize, verifySealedLink } from '@smartledger/notaryhash';
// Sign once, seal once, STORE the body, and send that on every attempt.
const signed = sign('ECDSA-secp256k1', key.privateKey, hashFile(recordBytes), canonicalCreatedAt());
const body = sealBody(signed, key.privateKey, previousCertificateId); // null starts a chain
const { certificate } = await submitNotarize(body, { url, apiKey });
// Later, with both certificates confirmed:
const v = await verifySealedLink(previousCertificate, certificate, headers);
// v.link: 'linked' | 'invalid' | 'indeterminate' v.ok === (v.link === 'linked')- The previous record must be in a block before it can be given a successor (the service
answers
409until then), so a chain advances at most once per block. - In a sealed record the signer signs the certificate id, which covers
createdAt. In an ordinary certificatecreatedAtis NOT signed: anyone can re-anchor a published signature under anothercreatedAt. If you issue certificates, treat only the id you stored as yours. - Pin the certificate id of the first record of the chain you mean, and the signer's key.
linkedsays "this is the one successor of that record"; it does not say the newer record has no successor of its own, which only a chain source can tell you, at the time you ask. notarizeBytes(bytes, key, { url, apiKey, createdAt, seal: { previous } })does the same in one call for ECDSA; for ML-DSA and SLH-DSA store the sealed body as above.
Verify (offline, against block headers — no trust in the service)
import {
verifyCertificateStandalone, MultiSourceHeaderProvider, WocHeaderProvider, BitailsHeaderProvider,
DEFAULT_HEADER_POLICY,
} from '@smartledger/notaryhash';
// Two independent sources; both must agree.
const headers = new MultiSourceHeaderProvider([
new WocHeaderProvider('https://api.whatsonchain.com/v1/bsv/main'),
new BitailsHeaderProvider('https://api.bitails.io'),
]);
const verdict = await verifyCertificateStandalone(certificate, headers);
// { ok, signatureValid, proofHashValid, anchorValid, anchorStatus: 'valid' | 'invalid' | 'indeterminate',
// blockTime (from the block header; null unless the anchor is valid),
// verificationMethod: 'spv', blockHeight, confirmations, reasons }
// Stricter: six confirmations, each header linked to the one below.
await verifyCertificateStandalone(certificate, headers, { ...DEFAULT_HEADER_POLICY, minConfirmations: 6 });The anchor check accepts a block header only as its 80 bytes: they must hash to the
certificate's block hash, meet their own proof-of-work target and a minimum difficulty
(DEFAULT_HEADER_POLICY.minDifficulty, 5e9), and the sources must hold the block on the main
chain at that height. A custom HeaderProvider must therefore return raw (the 80 bytes as
hex) and implement getHeaderAtHeight; without them the anchor is indeterminate.
anchorStatus: 'indeterminate' means the check could not be completed (source outage,
disagreement, not yet deep enough) and says nothing against the certificate.
To check a certificate is about a particular file, use verifyDocument. It needs the file's
bytes, hashes them, and is ok only if the certificate verifies and the hash is its
payloadHash:
import { verifyDocument } from '@smartledger/notaryhash';
const v = await verifyDocument(certificate, readFileSync('contract.pdf'), headers);
// v.document: 'matched' | 'mismatched' | 'unsupported-hash'verifyCertificateStandalone's verdict says document: 'unchecked'; read certificateOk there,
which says in its name what it covers (ok from verifyDocument covers the file too). A valid certificate shows
that a key signed some hash and it was anchored, not that it is about a file next to it.
Never display "verified" beside a file you have not hashed.
verifyCertificateOffline(certificate) checks the signature and proof hash with no network at
all (the anchor check needs a block header).
CLI
This package also installs a notaryhash CLI:
notaryhash keygen --algorithm ECDSA-secp256k1 --out key.json
notaryhash notarize contract.pdf --algorithm ECDSA-secp256k1 \
--key "$(node -e "console.log(require('./key.json').privateKey)")" \
--url https://notaryhash.com --api-key "$NOTARYHASH_API_KEY"
notaryhash verify cert.json --offlineWhat this SDK does and does not do
It produces and verifies proofs. It does not run the notarization service (anchoring, fee-paying wallet, key issuance) — that is operated by a NotaryHash provider such as notaryhash.com. Verification is free and open; anchoring goes through a provider's authenticated API.
See the protocol specification (BRC-141).
License
MIT © SmartLedger.Technology
Changes in 2.5.1
- TSC proofs: a literal sibling equal to the working hash is refused where the working hash
is the right operand, at every level. In a valid block two real siblings are never equal,
so that node is the odd-count padding of a left node written out in full, and the index
is an alias one bit past the real position (CVE-2012-2459's shape). The root is the same,
so the transaction was in the block, but the stated position was false.
'*'on the right was already refused. Tested against vectors from the bitcoin-sv v1.2.3 reference implementation's own merkle and header code.
Changes in 2.5.0
checkAnchorBySpvresults carryok, true exactly whenstatusis'valid'.statusis a string, soif (check.status)is true for'invalid'and'indeterminate'too; it had no boolean to read.sealedLinkresults carryoklikewise (true exactly for'linked').statusis unchanged in both. Found by the smartledger-bsv session.- Documentation only:
sealedLinkis not an exclusivity check by itself (two certificates spending the same seal both report linked;verifySealedLinkrules the second out), andconfirmationsis the depth proved, which stops at the policy's minimum, not the chain's depth. Both found by the smartledger-bsv session.
Changes in 2.4.2
MultiSourceHeaderProvider: the verdict no longer depends on the order of the sources. Every source that answers with a header must give the same one; one that answers differently makes the lookup undecided ("header sources disagree"). The quorum still says how many must answer, so a source that is down can be tolerated. Before, only answers equal to the FIRST source's were counted: the same three sources gave valid, indeterminate or invalid depending on their order. Found by the smart-git session. With the default quorum (all sources) nothing changes. This changes what thequorumargument means: it used to be read as "this many must report the same block" (a majority could outvote a dissenter); it is now "this many must answer", and all answers must agree. A caller who passed a smaller quorum to ride over a source that answers differently now gets "undecided" instead of a header. That only ever refuses more, and the old behaviour depended on source order, so there was no well-defined behaviour to keep.A header's bytes are taken only from a source whose bytes hash to the agreed block hash, so a custom
HeaderProviderthat reports the right hash with unrelated bytes cannot supply them.Scope: of this release's commits, only the header provider and the anchor check reach the package; the rest (sealed smartsats checkpoints, service settings) is the service.
What the header sources hold at a height condemns a certificate only if it is a block. If two sources agreed on 80 bytes that are not one (they do not hash to the hash given, or fail their own proof-of-work target), the verifier called a true certificate invalid; it now answers indeterminate, since that is evidence about the sources. A real other block at the height is still invalid. Found by the decentralize-compute session with the smart-git session's header-source cases. It could not be used to forge anything.
Changes in 2.4.1
Found by an independent verifier's differential fuzz against 2.4.0 (4,541 mutated real certificates); neither verifier accepted an anchor the other refused.
- A malformed certificate gets a verdict, never an exception. 288 mutations (a
nullanchor, a non-array Merkle path, non-hex hashes, a non-string algorithm...) madeverifyCertificateStandalonereject its promise. Every verifier function now checks the certificate's form first. New exportcertificateShapeProblem. protocolmust be"NotaryHash". It was never looked at; no hash covers it.- An
spvthat is present but not an object is invalid (it was read as "no proof yet"). - A block hash the header sources do not know, at a height where they hold another block, is invalid, not "try again".
verifyDocumentcan now answerdocument: 'unchecked'(neverok) for a malformed certificate.
Changes in 2.4.0
- Certificate version 2.0 (opt-in):
sign(..., { version: '2.0' }),notarizeBytes(..., { version: '2.0' }). The signature covers a message that names NotaryHash and binds the algorithm, the public key andcreatedAt; ML-DSA signs with the context stringNotaryHash/2.0. See "Version 2.0" above and SPEC §2a. New exports:signedMessage,SIGNED_MESSAGE_TAG_V2,SIGNING_CONTEXT_V2. - Verifiers read both versions. New policy member
acceptVersionsrestricts which. - Nothing changes for 1.0: the same requests, the same certificates, the same ids.
- Documented, not changed: a 1.0 SLH-DSA signature is FIPS 205's internal signing function, not the standard external one. No certificate on notaryhash.com uses SLH-DSA.
Changes in 2.3.3
- A certificate whose
createdAtis before 1970 gets a verdict (proof hash invalid, the signature reported as it is). It used to make the verifiers THROW: the proof bytes hold unsigned seconds, and the encoder refused the negative number after the spelling rule had passed it.canonicalCreatedAtrefuses such a time too. - A direct certificate must say
anchor.type: "direct"and carry nomerklemember. One with amerklemember attached used to verify, the member ignored. - For implementers: the published tamper set has three more cases, T29 (anchor type relabelled), T30 (merkle removed from a batch certificate) and T31 (a 1969 createdAt). T00-T28 are unchanged; only T00 and T04 are accepted.
Both faults were found by an independent implementation of this format being compared with this one, rule by rule.
Changes in 2.3.2
- A certificate whose
spv.formatis not"TSC"is invalid. The proof was always folded as a TSC proof whatever that member said, and no signature covers it. Every certificate the service has issued saysTSC. - When a quorum of header sources is not met, a source that answered "no such header" is now counted by name beside the ones that failed ("0 of 2 header sources answered with a header and 2 must agree; no such header at: ...; failed: ...").
- For implementers: the published tamper set (
test/vectors/mainnet-tamper-cases.jsonin the service repository) has two more cases, T27 (network relabelled) and T28 (proof format changed), markedpolicyDependent. T00-T26 are unchanged.
Changes in 2.3.1
- A failed header source is named. When
MultiSourceHeaderProvidercannot reach its quorum because a source failed, or because sources disagree, the verdict is stillindeterminate, but its reason now says which source and why ("1 of 2 header sources answered and 2 must agree; failed: WocHeaderProvider #1: ..."), where it used to say only "unavailable or not agreed across sources".nullfrom the provider now means only that the sources answered and there is no such header. new WocHeaderProvider()without its base URL throws at once with a sentence. It used to construct and then fail inside a request with a TypeError, which read as a source being down. It has no default on purpose: passhttps://api.whatsonchain.com/v1/bsv/main.- Remember what the multi-source provider is: agreement, not failover. Its quorum defaults to ALL sources; pass a smaller quorum as the second argument if you want availability instead. (From 2.4.2 that tolerates a source being down, not one that answers differently: any dissent is undecided at every quorum.)
Changes in 2.3.0
- Sealed chains. A record can be anchored as the one successor of another: its
transaction spends a seal output of the previous record's transaction, which can be spent
once. New:
sealBody(body, privateKey, previousId), asealoption onnotarizeBytesandnotarizeHash,verifySealedLink(previous, certificate, headers, policy?), andsealDigest,sealProblem,sealedLinkfor implementers. See "Sealed chains" above. computeProofHashandencodeCanonicalare exported, so a client can compute a certificate's id before sending the request.verifyCertificateStandalone(cert, headers, null)uses the default policy; it used to throw.- A certificate carrying
anchor.sealis checked for it byverifyCertificateStandaloneandverifyDocument: the signer's authorisation of the link, the fixed transaction layout, and the spend of the seal it names. No verdict changes for certificates without a seal.
Changes in 2.2.0
Anchoring can now be retried without anchoring twice. See "Retrying safely" above.
createdAtoption onnotarizeBytesandnotarizeHash. A certificate's id covers itscreatedAt, and until now both functions stamped the current time on every call, so calling one again after a timeout made a second certificate and a second anchor, with no way to prevent it. ChoosecreatedAtonce, store it with your record, and pass it on every attempt: a retry returns the first certificate (alreadyExisted: true).submitNotarize(body, { url, apiKey, batch })sends an already signed body. Sign once withsign(), store the body, resend it: safe for every algorithm, and the only safe route for ML-DSA and SLH-DSA, whose signatures can differ between calls.sign(algorithm, privateKey, payloadHashHex, createdAt?)takes the time as an optional fourth argument, and when given none now stamps the current whole second in the canonical.000Zform (it used to include milliseconds, which the service dropped).canonicalCreatedAt(when?)is exported: the one accepted form for now or any time.
Nothing is removed and no verdict changes. If you call notarizeBytes once per record and
never retry, nothing changes for you.
Changes in 2.1.4
- A policy that omits
networkis compared against the default. In 2.1.3 the network check ran only when the policy object carried anetwork, so a policy built by hand (for example{ minDifficulty, requireMainChain, minConfirmations }, the three fields that existed before) switched the check off without saying so, and a relabelled certificate verified. A policy without the property is now compared against"bsv-mainnet"; only an explicitnetwork: undefinedskips the comparison. If you spreadDEFAULT_HEADER_POLICY, nothing changes for you.
Changes in 2.1.3
anchor.networkis checked. It was the last certificate field that no signature covers and nothing compared: a mainnet certificate relabelledbsv-testnetverified. The policy gainsnetwork(default"bsv-mainnet", the chain of the default header sources); a certificate that names another network is invalid. A verifier using other header sources sets it to match ({ ...DEFAULT_HEADER_POLICY, network: "bsv-testnet" }), and an explicitnetwork: undefinedskips the comparison (a policy object that merely omits the property is compared against the default). This compares the label with your setting; it does not establish which chain a header source serves, which is your choice of source.- Header lookups retry a rate limit. A
429or a502/503/504from a header source is retried twice (300 ms, then 900 ms, or aRetry-Afterof up to 2 s) before the source counts as not answering. A404, a timeout, and a refusal asking for a longer pause are not retried. Public explorers throttle; this stops ordinary throttling from turning a sound certificate intoindeterminate. - When you build a notarize request yourself, send
createdAtand reuse it on every retry (see the service README): the certificate id covers it, so only a request repeated with the samecreatedAtreturns the existing certificate.sign()has always set it.
Changes in 2.1.2
A security release: upgrade. Several certificate fields are covered by no signature or hash, and earlier versions did not check them, so an edited certificate could verify. Each is now checked against the chain or against the one form it may take. No certificate the NotaryHash service has issued is affected (every one was checked against each rule), and there is no API change beyond one added verdict field.
blockTimein verdicts, read from the authenticated 80-byte block header. Show this as the time a record was anchored, never the certificate's ownanchor.blockTime.- A certificate whose
anchor.blockTimeoranchor.blockHeightis not the block's is invalid. A re-dated certificate used to verify. - The record is read from output
anchor.voutand no other, and that output must be exactlyOP_FALSE OP_RETURNfollowed by data pushes. The first OP_RETURN found used to be taken. - The block Merkle proof is refused when its index does not fit its path (an index with
extra high bits used to verify as well as the true one), when a node is not 32 bytes, or
when
*stands beside a right-hand node. A raw transaction of 64 bytes or fewer is refused. - In a batched certificate,
merkle.leafIndexis checked: the path must have exactly the length and sides that index has in a tree of the on-chain leaf count. The same path used to verify under any index. createdAtmust be the canonicalYYYY-MM-DDTHH:MM:SS.000Zform of its second. Any milliseconds used to verify for the same proof.
These rules came from independent verifiers of this format being run against this one, and the batch-mode test vector published in BRC-220 is now part of the test suite.
Changes in 2.1.1
require()works on Node 22.12 or later (require(esm)): the exports map gains adefaultcondition, and@smartledger/notaryhash/package.jsonis exported. The package is still ES modules; older Node versions needimport().- A certificate whose
createdAtdoes not decode is still not ok, but its signature is now reported as it is (signatureValidtrue when it verifies). It used to read as a cryptographic failure, although the signature coverspayloadHashalone.
Changes in 2.1.0
verifyDocument(certificate, bytes, headers, policy?)checks that a certificate is about a particular file: it hashes the bytes and isokonly if the certificate verifies and the hash is itspayloadHash(document: 'matched' | 'mismatched' | 'unsupported-hash'). The bytes are required; there is no call without them that says yes.- Verdicts gain
certificateOk, which says in its name that it covers the certificate alone.verifyCertificateStandalonenow reportsdocument: 'unchecked'; itsokkeeps the 2.0 meaning (the certificate is valid), so nothing that relied on it changes. - CLI:
verify --offline --document FILE, and certificate files are read strictly: a duplicated JSON name or nesting deeper than 32 levels is refused rather than read as the last copy.
Changes in 2.0.0
A security release. 1.x verified a certificate's anchor against a block header's reported fields without checking that they belonged to a real block: a made-up block hash, or an anchor in a block orphaned by a reorganisation, could verify as valid. Upgrade.
- The anchor check now rests on header bytes. A header counts only as its 80 bytes, which
must hash to the certificate's block hash; the Merkle root is read from them. The block must
meet its own proof-of-work target and a minimum difficulty (
DEFAULT_MIN_DIFFICULTY, 5e9), and the header sources must hold it as the main chain's block at that height. - Breaking: custom header providers.
BlockHeadergainsraw(the 80 bytes as hex) andHeaderProvidergainsgetHeaderAtHeight(andtipHeight). A provider without them makes the anchorindeterminate. The bundledWocHeaderProviderand the newBitailsHeaderProviderimplement both;DEFAULT_HEADER_SOURCESuses the two together. - Verdicts gain
anchorStatus(valid|invalid|indeterminate) andconfirmations.indeterminate— a source outage, disagreement, or not yet deep enough — says nothing against the certificate. A third argument, aHeaderPolicy, setsminDifficulty,requireMainChainandminConfirmations. - New exports:
checkAnchorBySpv,DEFAULT_HEADER_POLICY,headerProviderForUrl, and the header helpersparseHeader,hashOfHeader,headerFromFields,meetsOwnTarget. - CLI:
verify --offlinedefaults to WhatsOnChain and Bitails together, and takes--min-confirmationsand--min-difficulty.
