Portable Evidence Bundle Verification Guide

Audience: Advisory AI and audit teams who consume evidence bundles in sealed deployments.

This guide describes how to verify the integrity and authenticity of portable evidence bundles produced by the Stella Ops Excititor module for air-gapped deployments. For the export/import contract that produces these bundles, see Portable Evidence Bundles.

Overview

Portable evidence bundles are self-contained ZIP archives that include:

Bundle Structure

evidence-bundle-{tenant}-{timestamp}.zip
├── manifest.json           # VexLockerManifest with Merkle root
├── attestation.json        # DSSE envelope (optional)
├── evidence/
│   └── {provider}/
│       └── sha256_{digest}.json
├── timeline.json           # Audit timeline events
├── bundle-manifest.json    # Index of all contents
└── VERIFY.md               # Verification instructions

Verification Steps

Step 1: Extract and Validate Structure

# Extract the bundle
unzip evidence-bundle-*.zip -d evidence-bundle/

# Verify expected files exist
ls -la evidence-bundle/
# Should see: manifest.json, bundle-manifest.json, evidence/, timeline.json, VERIFY.md

Step 2: Verify Evidence Item Integrity

Each evidence item’s content hash must match its filename:

cd evidence-bundle/evidence

# For each provider directory
for provider in */; do
    for file in "$provider"*.json; do
        # Extract expected hash from filename (sha256_xxxx.json -> xxxx)
        expected=$(basename "$file" .json | sed 's/sha256_//')
        # Compute actual hash
        actual=$(sha256sum "$file" | cut -d' ' -f1)
        if [ "$expected" != "$actual" ]; then
            echo "MISMATCH: $file"
        fi
    done
done

Step 3: Verify Merkle Root

The Merkle root provides cryptographic proof that all evidence items are included without modification.

Python Verification Script

#!/usr/bin/env python3
import json
import hashlib
from pathlib import Path

def compute_merkle_root(hashes):
    """Compute Merkle root from list of hex hashes."""
    if len(hashes) == 0:
        return hashlib.sha256(b'').hexdigest()
    if len(hashes) == 1:
        return hashes[0]

    # Pad to even number
    if len(hashes) % 2 != 0:
        hashes = hashes + [hashes[-1]]

    # Compute next level
    next_level = []
    for i in range(0, len(hashes), 2):
        combined = bytes.fromhex(hashes[i] + hashes[i+1])
        next_level.append(hashlib.sha256(combined).hexdigest())

    return compute_merkle_root(next_level)

def verify_bundle(bundle_path):
    """Verify a portable evidence bundle."""
    bundle_path = Path(bundle_path)

    # Load manifest
    with open(bundle_path / 'manifest.json') as f:
        manifest = json.load(f)

    # Extract hashes, sorted by observationId then providerId
    items = sorted(manifest['items'],
                   key=lambda x: (x['observationId'], x['providerId'].lower()))

    hashes = []
    for item in items:
        content_hash = item['contentHash']
        # Strip sha256: prefix if present
        if content_hash.startswith('sha256:'):
            content_hash = content_hash[7:]
        hashes.append(content_hash.lower())

    # Compute Merkle root
    computed_root = 'sha256:' + compute_merkle_root(hashes)
    expected_root = manifest['merkleRoot']

    if computed_root == expected_root:
        print(f"✓ Merkle root verified: {computed_root}")
        return True
    else:
        print(f"✗ Merkle root mismatch!")
        print(f"  Expected: {expected_root}")
        print(f"  Computed: {computed_root}")
        return False

if __name__ == '__main__':
    import sys
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <bundle-directory>")
        sys.exit(1)

    success = verify_bundle(sys.argv[1])
    sys.exit(0 if success else 1)

Step 4: Verify Attestation (if present)

When attestation.json exists, verify the DSSE envelope:

# Check if attestation exists
if [ -f "evidence-bundle/attestation.json" ]; then
    # Extract attestation metadata
    jq '.' evidence-bundle/attestation.json

    # Verify signature using appropriate tool
    # For Sigstore/cosign attestations:
    # cosign verify-attestation --type custom ...
fi

Attestation Fields

FieldDescription
dsseEnvelopeBase64-encoded DSSE envelope
envelopeDigestSHA-256 hash of the envelope
predicateTypein-toto predicate type URI
signatureTypeSignature algorithm (e.g., “ES256”)
keyIdSigning key identifier
issuerCertificate issuer
subjectCertificate subject
signedAtSigning timestamp (ISO-8601)
transparencyLogRefRekor transparency log entry URL

Step 5: Validate Timeline

The timeline provides audit trail of bundle creation:

# View timeline events
jq '.' evidence-bundle/timeline.json

# Check for any failed events
jq '.[] | select(.errorCode != null)' evidence-bundle/timeline.json

Timeline Event Types

Event TypeDescription
airgap.import.startedBundle import initiated
airgap.import.completedImport succeeded
airgap.import.failedImport failed (check errorCode)

Error Codes Reference

CodeDescriptionResolution
AIRGAP_EGRESS_BLOCKEDExternal URL blocked in sealed modeUse mirror/portable media
AIRGAP_SOURCE_UNTRUSTEDPublisher not allowlistedContact administrator
AIRGAP_SIGNATURE_MISSINGRequired signature absentRe-export with signing
AIRGAP_SIGNATURE_INVALIDSignature verification failedCheck key/certificate
AIRGAP_PAYLOAD_STALETimestamp exceeds toleranceRe-create bundle
AIRGAP_PAYLOAD_MISMATCHHash doesn’t match metadataVerify transfer integrity

Advisory AI Integration

Quick Integrity Check

For automated pipelines, use the bundle manifest:

import json

with open('bundle-manifest.json') as f:
    manifest = json.load(f)

# Key fields for Advisory AI
print(f"Bundle ID: {manifest['bundleId']}")
print(f"Merkle Root: {manifest['merkleRoot']}")
print(f"Item Count: {manifest['itemCount']}")
print(f"Has Attestation: {manifest['hasAttestation']}")

Evidence Lookup

Find evidence for specific observations:

# Index evidence by observation ID
evidence_index = {e['observationId']: e for e in manifest['evidence']}

# Lookup specific observation
obs_id = 'obs-123-abc'
if obs_id in evidence_index:
    entry = evidence_index[obs_id]
    file_path = f"evidence/{entry['providerId']}/sha256_{entry['contentHash'][7:]}.json"

Provenance Chain

Build complete provenance from bundle:

  1. bundle-manifest.json → Bundle creation metadata
  2. manifest.json → Evidence locker snapshot
  3. attestation.json → Cryptographic attestation
  4. timeline.json → Audit trail

Offline Verification

For fully air-gapped environments:

  1. Transfer bundle via approved media
  2. Extract to isolated verification system
  3. Run verification scripts without network
  4. Document verification results for audit

Support

For questions or issues: