Attestra

The canonical digest

The 32 bytes every Attestra signature is taken over, and the four implementations that must agree on them.

The rule

The digest is keccak256 over nine 32-byte words, concatenated in a fixed order. The preimage is always 288 bytes. Nothing is length-prefixed, nothing is delimited, and nothing is optional: an empty string still contributes a full word, because it is hashed first.

The canonical digest preimageNine 32-byte words stacked in fixed order: six hashes of UTF-8 strings (did, credentialId, credType, issuer, status, sourceChain), then issuedAt and expiresAt as big-endian uint64s occupying the last eight bytes of an otherwise zero word, then the raw 32-byte nonce. The 288 bytes are hashed once with keccak256 to produce the 32-byte digest.32 bytes1didkeccak256(utf8)2credentialIdkeccak256(utf8)3credTypekeccak256(utf8)4issuerkeccak256(utf8)5statuskeccak256(utf8)6sourceChainkeccak256(utf8)724 zero bytes8issuedAtuint64, big-endian824 zero bytes8expiresAtuint64, big-endian9nonceraw 32 bytes288 bytes. Nothing between the words, nothing length-prefixed.keccak256the digest32 bytes
#FieldWord is produced by
1didkeccak256(utf8)
2credentialIdkeccak256(utf8)
3credTypekeccak256(utf8)
4issuerkeccak256(utf8)
5statuskeccak256(utf8)
6sourceChainkeccak256(utf8)
7issuedAtbig-endian uint64, right-aligned in 32 bytes
8expiresAtbig-endian uint64, right-aligned in 32 bytes
9noncethe 32 bytes as they are

Words 7 and 8 are written as bytes32(uint256(a.issuedAt)) in Solidity and as u64be32 in Go and Rust. Both mean the same thing: eight bytes of value at the low-order end of a 32-byte word, twenty-four zero bytes ahead of them.

That is the whole rule. A reader who implements the table above, in any language, and feeds it the same fields, gets the same 32 bytes as every deployed verifier.

Why the strings are hashed first

Words 1 to 6 are hashes of the strings, never the strings themselves. That is what gives every word a fixed width, and fixed width is what makes abi.encodePacked unambiguous in Solidity.

Packing variable-length strings directly would not be unambiguous. "ab" and "c" pack to exactly the same bytes as "a" and "bc", so two different attestations would produce one digest, and a signature over the first would be a valid signature over the second. Hashing each string to 32 bytes before concatenation removes the boundary question entirely: the words cannot bleed into each other, because every word ends where the next begins.

A worked example

These values come from testdata/fixture.json in the oracle repository, generated by cmd/genfixture.

did          did:eid:4tARKz6hK5GuWr9fkdzsG1Bf9X3b3agzh7RPBdjdpLWapHRh
credentialId 0xfb898d4079eed14603bbca2a245278b45af1d2acad278c6c690e067bc9eaae2c
credType     KYCVerified
issuer       0xe81fa0ecbf9c77cd095fbeb4687d78cbd98bd51092a2cc12d4276e1ade4a8e78
status       valid
sourceChain  eid
issuedAt     1751360400
expiresAt    1751360700
nonce        0x00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff

The digest:

0xe5a664863ffb9d33dc4627c956a3046afacbb7daba80dd3145583ab82333272b

Note that credentialId and issuer here are hex strings, and they are hashed as strings, character by character, not decoded to bytes first. The leading 0x is part of the input to keccak256.

This exact vector is the project's acceptance gate. The same nine inputs are run through all four implementations, and all four must return the hex above.

The four implementations

Four languages, four runtimes, one result. The code below is the deployed source, not a transcription.

Go, the oracle: internal/attestation/attestation.go

func hashStr(s string) [32]byte {
	var out [32]byte
	copy(out[:], crypto.Keccak256([]byte(s)))
	return out
}

func u64be32(n uint64) [32]byte {
	var out [32]byte
	binary.BigEndian.PutUint64(out[24:], n)
	return out
}

// Digest returns keccak256 of the canonical preimage (9 x 32-byte words).
func (a Attestation) Digest() [32]byte {
	buf := make([]byte, 0, 9*32)
	appendWord := func(w [32]byte) { buf = append(buf, w[:]...) }

	h := hashStr(a.DID)
	appendWord(h)
	h = hashStr(a.CredentialID)
	appendWord(h)
	h = hashStr(a.CredType)
	appendWord(h)
	h = hashStr(a.Issuer)
	appendWord(h)
	h = hashStr(a.Status)
	appendWord(h)
	h = hashStr(a.SourceChain)
	appendWord(h)
	appendWord(u64be32(a.IssuedAt))
	appendWord(u64be32(a.ExpiresAt))
	appendWord(a.Nonce)

	var digest [32]byte
	copy(digest[:], crypto.Keccak256(buf))
	return digest
}

Solidity, Ethereum: contracts/src/AttestationVerifier.sol

function digest(Attestation memory a) public pure returns (bytes32) {
    return keccak256(
        abi.encodePacked(
            keccak256(bytes(a.did)),
            keccak256(bytes(a.credentialId)),
            keccak256(bytes(a.credType)),
            keccak256(bytes(a.issuer)),
            keccak256(bytes(a.status)),
            keccak256(bytes(a.sourceChain)),
            bytes32(uint256(a.issuedAt)),
            bytes32(uint256(a.expiresAt)),
            a.nonce
        )
    );
}

contracts/src/AttestationStore.sol carries this same digest function, character for character. The pull path (check a signature at the point of use) and the push path (write the attestation on chain) therefore agree by construction rather than by review. See Push and pull.

Rust, eIDChain: pallet/src/lib.rs

fn hash_str(s: &str) -> [u8; 32] {
    keccak(s.as_bytes())
}

fn u64be32(n: u64) -> [u8; 32] {
    let mut out = [0u8; 32];
    out[24..].copy_from_slice(&n.to_be_bytes());
    out
}

impl Attestation {
    pub fn digest(&self) -> [u8; 32] {
        let mut buf = Vec::with_capacity(9 * 32);
        buf.extend_from_slice(&hash_str(&self.did));
        buf.extend_from_slice(&hash_str(&self.credential_id));
        buf.extend_from_slice(&hash_str(&self.cred_type));
        buf.extend_from_slice(&hash_str(&self.issuer));
        buf.extend_from_slice(&hash_str(&self.status));
        buf.extend_from_slice(&hash_str(&self.source_chain));
        buf.extend_from_slice(&u64be32(self.issued_at));
        buf.extend_from_slice(&u64be32(self.expires_at));
        buf.extend_from_slice(&self.nonce);
        keccak(&buf)
    }
}

Go, Hyperledger Fabric chaincode: chaincode/verify/verify.go

func hashStr(s string) [32]byte {
	var out [32]byte
	copy(out[:], crypto.Keccak256([]byte(s)))
	return out
}

func u64be32(n uint64) [32]byte {
	var out [32]byte
	binary.BigEndian.PutUint64(out[24:], n)
	return out
}

func (a Attestation) Digest() [32]byte {
	buf := make([]byte, 0, 9*32)
	w := func(x [32]byte) { buf = append(buf, x[:]...) }
	w(hashStr(a.DID))
	w(hashStr(a.CredentialID))
	w(hashStr(a.CredType))
	w(hashStr(a.Issuer))
	w(hashStr(a.Status))
	w(hashStr(a.SourceChain))
	w(u64be32(a.IssuedAt))
	w(u64be32(a.ExpiresAt))
	w(a.Nonce)
	var d [32]byte
	copy(d[:], crypto.Keccak256(buf))
	return d
}

The invariant

The digest never changes. Not one byte, not one field, not one reordering.

Four deployed verifiers depend on it, and so does every attestation ever issued. A signature is only meaningful against the exact preimage it was taken over, so changing the rule does not upgrade old attestations, it invalidates them. Adding a field is a new version and a new deployment, not an edit to this one.

Two artefacts hold that line:

  • testdata/fixture.json, the shared vector. Every suite reads the same file, so no implementation can pass against a private copy of the truth.
  • scripts/verify-all.sh, the acceptance gate. It regenerates the fixture and runs all four suites, in all four languages, against it. A change that moves the digest fails there before it reaches a chain.

What the digest binds

Every field in the table is inside the signature, and that is what each one buys:

IssuedAt and ExpiresAt are inside the digest, so an attestation cannot be re-dated. A holder of an expired attestation cannot extend its window without invalidating the signature over it.

Nonce is inside the digest, so an attestation cannot be replayed under a different challenge. A consumer that generated the nonce can confirm the attestation it receives carries that nonce and no other.

DID and CredentialID are inside the digest, so an attestation cannot be re-pointed at another holder. At least one of them is always present, which is what stops the signed statement from asserting that some credential is valid without saying which.

For how a chain checks a signature over these 32 bytes, and why it requires several of them in ascending signer order, continue to Verifying an attestation.

On this page