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.
| # | Field | Word is produced by |
|---|---|---|
| 1 | did | keccak256(utf8) |
| 2 | credentialId | keccak256(utf8) |
| 3 | credType | keccak256(utf8) |
| 4 | issuer | keccak256(utf8) |
| 5 | status | keccak256(utf8) |
| 6 | sourceChain | keccak256(utf8) |
| 7 | issuedAt | big-endian uint64, right-aligned in 32 bytes |
| 8 | expiresAt | big-endian uint64, right-aligned in 32 bytes |
| 9 | nonce | the 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 0x00112233445566778899aabbccddeeff00112233445566778899aabbccddeeffThe digest:
0xe5a664863ffb9d33dc4627c956a3046afacbb7daba80dd3145583ab82333272bNote 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.