This page explains the document verification algorithm in detail. For a brief overview of the verification process see this page. You may use these instructions to perform document verification manually or to implement document verification on your own. If you do not plan to do either of these, you can skip this page and simply use our production-ready TypeScript library to verify your documents. While the code on this page is kept minimal for educative purposes, the library performs more elaborate error handling and progress reporting and thus serves as a reference implementation for document verification.
Let's walk through the steps of the document verification process with an exemplary certificate. The certificate was created with the cronStamp demo for the input text "test". Note, that the certificate contains both XRP and Solana. For simplicity, we explain document verification only for Solana. But it works similarly for XRP.
You can follow along with this JSFiddle (open the console in the bottom right corner of the Fiddle to see the script output).
The first step of document verification is to retrieve the document and the certificate from wherever they are stored, e.g. files on a filesystem or rows in a database. In our example, the document is simply the character string "test" and the certificate can be obtained from the downloaded file. Let's take a closer look at the certificate.
const mydocument = 'test';
const mycertificate = {
blockchains: {
xrp: {
transaction: 'A5C5547C67DC4BAAB1793F8DC83616B5AA988AB261F425AA459AF2B2B7812C00',
block_timestamp: 786501601,
merkle_tree_splice: [
'7fDcxqo67ghGTV1HyJpscanO0EEerhijtXYA0USlxnk=',
'',
'IOxNJ/fhVpu5w+oOYXg4CIyfvBiKZ3ozPs2XnplyTBo='
]
},
solana: {
transaction: '2j1tNuEP5LxcW9tKsi2Scr3fvjLkpJ1xcszzC8RdGwoJioppt7JzWSyJniTCGhSZorFNnR3y2nKZTs18nCfYADgr',
block_timestamp: 1733187600,
merkle_tree_splice: [
'7fDcxqo67ghGTV1HyJpscanO0EEerhijtXYA0USlxnk=',
'',
'IOxNJ/fhVpu5w+oOYXg4CIyfvBiKZ3ozPs2XnplyTBo=',
'56dme4H5OOa4yLMuyvK7KPMrXKFj/u/BHCc9RYmWHlk=',
'Cz1F06Khmq1QmDxJ77PPNnmDSuWapPu/9HFyIY0AdZU=',
'pGIWsoAJm+bVKvGVEUGaGK5Cb92+nnLYEgi5QUdHhVA=',
'Nr/8EqtWd+XJhseUg2+6vNc4ST9j/FqjVdDewABwkTk=',
'fVxcnHkJAS4vtJuWYg5HtDr3XKeK65rEo60IKpsr3Rg='
]
}
},
meta: {
version: 1,
blockchains: ['solana', 'xrp'],
salt: '00a54fabe12c75e8663534ecda19ab38'
},
info: {
hash_algorithm: 'SHA-256',
salted_hash: 'o0X9rt38cyVpTmfKmegkkXkjr1Z48T8ihsvxDFa1wCg='
},
document_hash: 'n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg='
};
Relevant for verification are the entries in blockchains
, meta
,
and document_hash
. The info
block is merely for your
information. Document verification can be performed independently for each blockchain. We will use the Solana
blockchain as an example because retrieval of the root hash from the blockchain is straightforward and requires no
complicated decoding procedure as for other blockchains. Please refer to the source code of the client library if you care about the details
for the other blockchains.
Next, the hash and salted hash of the document, which is to be verified, are recalulcated as shown in the code block below. The salted hash is based on the document hash and additional information, such as the blockchains, the salt, and the certificate schema version present during document timestamping. All details are concatenated into a string and then hashed. Note, that the formatting matters, as a single wrong space or wrong capitalization would prevent the recalculated hash from matching the hash stored on the blockchain, causing document verification to always indicate the document as invalid.
async function hashString(message) {
const messageUint8 = new TextEncoder().encode(message); // encode as (utf-8) Uint8Array
const hashBuffer = await crypto.subtle.digest('SHA-256', messageUint8); // hash the message
const hashArray = new Uint8Array(hashBuffer); // convert ArrayBuffer to Array
const base64String = btoa(Array.from(hashArray, (byte) => String.fromCodePoint(byte)).join('')); // to hex
return base64String;
}
async function calculateSaltedHash(documentHash, certificate) {
const flattenedBlockchains = certificate.meta.blockchains.sort().join(',');
const hashMetaContent = `${certificate.meta.version}${flattenedBlockchains}${certificate.meta.salt}`;
const hashContent = documentHash + hashMetaContent;
return await hashString(hashContent);
}
const documentHash = await hashString(mydocument);
console.log(`Regenerated document hash: ${documentHash}`); // "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
const saltedHash = await calculateSaltedHash(documentHash, mycertificate);
console.log(`Regenerated salted document hash: ${saltedHash}`); // "o0X9rt38cyVpTmfKmegkkXkjr1Z48T8ihsvxDFa1wCg="
To reduce transaction cost, cronStamp aggregates all hashes, that are submitted in a fixed time interval for document timestamping, in a Merkle tree. An exemplary Merkle could look like this:
The Merkle tree is a binary tree, in which each leaf node contains one of the submitted salted document hashes, a
, b
, c
, c
, d
e
, f
in the example. Non-leaf nodes contain
the hash of their two child nodes. Prior to hashing, the two constituent hashes are sorted alphanumerically. This is
important because the hash depends on the order of the child hashes. Sorting encodes the information which of the children
is the left one and which the right one. This facilitates recalculation of the Merkle tree root hash in this step of
the document verification.
During document verification the Merkle tree root hash is recalucated based on the salted document hash obtained in step 2 and other hashes in the Merkle tree. For each document hash, only a subset of the other hashes is needed to calculate
the Merkle tree root hash. This subset is the Merkle tree splice
. To understand how the
Merkle tree splice is constructed, let's look at the Merkle tree splice of document hash "c"
, which is ["d", "ef", "ab'"]
(colored dark blue in the
example tree). The splice contains for each tree level the sibling hash required to calculate the hash of the next
level. With the document hash "c"
and the Merkle tree splice, the intermediate hashes "cd"
, "cdef"
and finally the Merkle tree root hash can be calculated.
In cases where a hash has no sibling in the Merkle tree, e.g. hash "ab"
, the Merkle tree
splice contains an empty string for this level. For example, the Merkle tree splice for document hash "b"
is ["a", "", "cdef"]
.
The following code snippet implements the described deaggregation algorithm.
async function hashSiblingHashes(hashA, hashB) {
// if the other side is empty just one is hashed
if (hashA == '' && hashB != '') {
return hashString(hashB);
}
if (hashB == '' && hashA != '') {
return hashString(hashA);
}
// order depends on lexicographical order of hashes
if (hashA < hashB) {
return hashString(hashA + hashB);
} else {
return hashString(hashB + hashA);
}
}
let rootHash = saltedHash;
for (let siblingHash of mycertificate.blockchains.solana.merkle_tree_splice) {
rootHash = await hashSiblingHashes(rootHash, siblingHash);
}
console.log(`Regenerated Merkle tree root hash: ${rootHash}`); // "mTQAoBEc0DYh6iJfdkIRM55UAhheXFaUn2bJM5oAwEw="
For simplicity we simulate the blockchain lookup here by looking up the Merkle tree root hash in the Solana Explorer. Copy the transaction ID from the certficiate.blockchains.solana.transaction
field of the
certificate and paste it into the search bar of the Solana Explorer. Obtain the Merkle tree root hash from the "memo"
field of the transaction as shown in the screenshot below.
For other blockchains, such as XRP, we encode the Merkle tree root hash partially in the destination address and partially in the body of the transaction to reduce transaction cost. See the TypeScript library for the required decoding procedure.
In the actual verification process, we would connect to the API of the blockchain servers, search for the blockchain block by transaction ID and get the Merkle tree root hash by decoding the block's payload. Please refer to the source code of our client library for the actual implementation.
const rootHashFromBlockchain = 'mTQAoBEc0DYh6iJfdkIRM55UAhheXFaUn2bJM5oAwEw=';
Finally, we have to compare the Merkle tree root hash recalculated in step 3 with the Merkle tree root hash fetched from the blockchain in step 4 as shown in the next code snippet.
if (rootHash == rootHashFromBlockchain) {
console.log("Verification successful. The document has not been modified after timestamping.")
} else {
console.log("Verification failed. The document has been modified after timestamping.")
}
If both root hashes are equal, the document has not been modified after document timestamping, i.e., creation of the certificate. Unequal root hashes indicate that the document and/or those fields of the certificate included in the document hash, such as the backend version or the salt, were modified after document timestamping.
Because the additional fields of the certificate are contained in the root hash, they can be used to convey additional important information in a tamper-proof way, such as the backend version or the blockchains used during document timestamping. The latter prevents an attacker from removing all but one of the blockchains in the certificate, which could otherwise significantly lower the guarantees provided by the certificate.