Offline Verification Walkthrough
How a regulator, auditor, or other agent verifies an Attestix Verifiable Credential without any network access.
This guide walks through verifying an Attestix-issued Verifiable Credential with no internet connection. For the supported did:key profile, the issuer public key is encoded in issuer.id, so signature checking does not fetch a DID document or call the issuer. Independently approve the issuer DID/key before treating the credential as trusted. Offline checks cannot establish current revocation, authorization, complete capture or legal conformity.
Scenario
You are a regulator. A supplier has sent you a Declaration of Conformity VC for their high-risk AI system. You want to confirm:
- The VC was signed by the claimed issuer.
- The signature has not been tampered with.
- The audit trail behind it is internally consistent.
No network calls. Everything verifies from the JSON blob, because the issuer's Ed25519 public key is embedded in the did:key.
Prerequisites
pip install attestix==0.4.2Step 1 / Fetch the artefacts
Ask the supplier to send:
- The Verifiable Credential JSON (for example an EU AI Act Declaration of Conformity).
- Optionally, a Verifiable Presentation JSON bundling several credentials.
- Optionally, the Article 12 audit trail entries for the agent.
These are plain JSON. The issuer's public key is not a separate file: it is the did:key in credential["issuer"]["id"], which decodes to the raw Ed25519 verifying key. That is what makes verification self contained and offline.
import json
with open("credential.json") as f:
vc = json.load(f)
print(vc["issuer"]["id"]) # did:key:z6Mk... encodes the issuer public keyStep 2 / Verify the signature
verify_credential_external verifies any credential supplied as raw JSON. It does not require the credential to be in local storage, which is exactly the external verifier case.
from attestix.services.credential_service import CredentialService
credential_svc = CredentialService()
result = credential_svc.verify_credential_external(vc)
# result -> {
# "valid": True,
# "credential_id": "urn:uuid:...",
# "type": ["VerifiableCredential", "ConformityAssessmentCredential"],
# "subject": "attestix:f9bdb7a94ccb40f1",
# "checks": {
# "structure_valid": True,
# "not_revoked": True,
# "not_expired": True,
# "signature_valid": True,
# },
# }
print(result["valid"], result["checks"])Internally this:
- Confirms the object is a
VerifiableCredential. - Strips the mutable fields (
proofandcredentialStatus) and canonicalises the rest with the Attestix canonical JSON form (a practical subset of RFC 8785 JCS, sorted keys, no whitespace, NFC normalized). - Decodes the verifying key from
issuer.id(the stated signing key; issuer trust must be established separately), not fromproof.verificationMethod. Ifproof.verificationMethodnames a different DID, verification fails. This blocks issuer key substitution. - Verifies the Ed25519 signature in
proof.proofValueper RFC 8032, and checks expiry and (when the revocation list is available locally) revocation.
The credential is valid only when signature_valid AND not_expired AND not_revoked all hold. An external verifier with no revocation list assumes not revoked, so confirm revocation separately if that matters for your use case.
If the signature is valid, you have cryptographic proof that the issuer controlled the private key at signing time.
Step 3 / Verify a presentation (optional)
If the supplier sent a Verifiable Presentation that bundles several credentials, verify it in one call. verify_presentation checks the presentation signature plus every embedded credential.
with open("presentation.json") as f:
vp = json.load(f)
result = credential_svc.verify_presentation(vp)
# result -> {
# "valid": True,
# "holder": "attestix:f9bdb7a94ccb40f1",
# "credential_count": 2,
# "checks": {
# "structure_valid": True,
# "vp_signature_valid": True,
# "credentials_valid": True,
# "holder_matches_subjects": True,
# ...
# },
# }
print(result["valid"], result["checks"])Each embedded credential is verified against its own issuer.id, and every credential subject is checked against the presentation holder.
Step 4 / Verify the audit chain
The provenance action log is a SHA-256 hash chain: each entry links to the previous one, so tampering with any row invalidates every later row. Request an exported audit-trail.json from the supplier, then recompute the chain offline. get_audit_trail reads your local workspace; it does not fetch another operator's records. The hash body includes prev_hash and excludes only chain_hash and signature. This action-log profile is separate from the structured audit-event profile.
import hashlib
import json
with open("audit-trail.json", encoding="utf-8") as f:
entries = json.load(f)
GENESIS = "0" * 64
def chain_hash(previous_hash: str, entry: dict) -> str:
# Same rule the recorder uses: SHA-256 over "{prev_hash}:{canonical_json}"
# where canonical_json excludes the chain_hash and signature
# fields and uses sorted keys with compact separators.
body = {
k: v
for k, v in entry.items()
if k not in {"chain_hash", "signature"}
}
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(f"{previous_hash}:{canonical}".encode("utf-8")).hexdigest()
prev = GENESIS
first_bad = None
for index, entry in enumerate(entries):
if entry.get("prev_hash") != prev or chain_hash(prev, entry) != entry.get("chain_hash"):
first_bad = index
break
prev = entry["chain_hash"]
print("empty export - no evidence" if not entries else
"chain consistent" if first_bad is None else f"tamper at entry {first_bad}")A changed or reordered row is detected unless the hashes are recomputed. This helper does not verify entry signatures or complete capture. Pin an independently trusted final hash and expected count to detect truncation; a self-supplied checkpoint is not independent evidence.
Full example script
Putting the three checks together into one runnable script:
# offline_verify.py
import hashlib
import json
import sys
from attestix.services.credential_service import CredentialService
GENESIS = "0" * 64
def chain_hash(previous_hash: str, entry: dict) -> str:
body = {
k: v
for k, v in entry.items()
if k not in {"chain_hash", "signature"}
}
canonical = json.dumps(body, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(f"{previous_hash}:{canonical}".encode("utf-8")).hexdigest()
def main(vc_path: str, audit_path: str, expected_issuer: str) -> int:
with open(vc_path) as f:
vc = json.load(f)
issuer = vc.get("issuer") if isinstance(vc, dict) else None
issuer_did = issuer.get("id") if isinstance(issuer, dict) else issuer
if issuer_did != expected_issuer or not expected_issuer.startswith("did:key:"):
print("[fail] issuer does not match the trusted did:key issuer")
return 1
result = CredentialService().verify_credential_external(vc)
if not result.get("valid"):
print(f"[fail] credential not valid: {result.get('checks')}")
return 1
print(f"[ok] signature verified (issuer {issuer_did})")
with open(audit_path, encoding="utf-8") as f:
entries = json.load(f)
if not isinstance(entries, list) or not entries:
print("[fail] empty or malformed audit export")
return 1
prev = GENESIS
for index, entry in enumerate(entries):
if entry.get("prev_hash") != prev or chain_hash(prev, entry) != entry.get("chain_hash"):
print(f"[fail] audit chain broken at entry {index}")
return 1
prev = entry["chain_hash"]
print(f"[ok] audit chain consistent ({len(entries)} entries)")
print("SIGNATURE AND SUPPLIED CHAIN CHECKS = PASS; trust/status/completeness require separate review")
return 0
if __name__ == "__main__":
if len(sys.argv) != 4:
raise SystemExit("Usage: offline_verify.py credential.json audit-trail.json trusted_issuer_did")
raise SystemExit(main(sys.argv[1], sys.argv[2], sys.argv[3]))Run it:
python offline_verify.py ./credential.json ./audit-trail.json did:key:z6Mk...Output:
[ok] signature verified (issuer did:key:z6Mk...)
[ok] audit chain consistent (1247 entries)
SIGNATURE AND SUPPLIED CHAIN CHECKS = PASS; trust/status/completeness require separate reviewVerify without Python
The verifiers share fixtures at spec/verify/v1. Shared fixtures do not establish identical handling of every input, API or trust policy. Read the current verifier README and pin a reviewed release. You can also verify a credential in the browser at attestix.io/verify. Issuance stays in the Python core; verification is open and portable.
Why this matters
Signed records can bind recorded bytes to a signing key. They do not establish the signer's authority, truthful claims, independently trusted time or legal conformity. Other systems can also supply cryptographic evidence.
The same verification is what a peer agent runs when it decides whether to trust another agent's capability claim. See UCAN delegation for how delegation chains are verified the same way.
Browser and package release status
Python/PyPI and JavaScript/npm, Go and Java/Maven 0.4.2 are published. The 0.4.3 source prepares a website/documentation update; verify registry availability before pinning that release. Rust and R have GitHub 0.4.2 releases; crates.io and CRAN remain at 0.4.1 at this cutoff. The browser page uses npm attestix 0.4.2 with the retained boundary guards. Offline results do not check live revocation.
CrewAI Integration
Attach Attestix to every CrewAI agent via MCPServerAdapter. Real (not example) integration shipped in v0.3.0; v0.4.0-rc.2 release candidate. Crews become attestable by default.
Base L2 Testnet Anchor Walkthrough
Anchor an Attestix artefact hash to Base L2 testnet via the Ethereum Attestation Service. End-to-end guide with Sepolia faucets, gas estimates, and verification.