Skip to content

Add auto-updating VICAL support for mDL trust verification - #288

Open
spruceduncan wants to merge 3 commits into
mainfrom
feat/vical-support
Open

spruceduncan wants to merge 3 commits into
mainfrom
feat/vical-support

Conversation

@spruceduncan

Copy link
Copy Markdown
Contributor

Fetch, verify, and parse the AAMVA VICAL (Verified Issuer Certificate Authority List) to dynamically build IACA trust anchor registries for mDL verification across all participating US states.

Description

SpruceKit Mobile currently hardcodes a small set of SpruceID-specific IACA certificates for mDL trust verification. To verify mDLs from any US state, the verifier needs IACA root certificates from all participating states — which AAMVA publishes as a VICAL at vical.dts.aamva.org.

AAMVA is expected to eventually provide a proper REST endpoint for VICAL retrieval, but the timeline is unknown. In the interim, we scrape the HTML from their website to fetch the latest VICAL. The system falls back gracefully at every step and supports states not in the VICAL (like California) via additional raw IACA certificates.

New UniFFI API

Four exported functions, each usable independently:

  • fetch_vical — Scrapes the AAMVA website for the latest VICAL binary. Returns raw CBOR bytes + metadata for the app to cache. Does not parse or verify.
  • build_trust_anchor_registry_from_vical — Takes optional cached VICAL bytes + optional additional IACA PEM strings. Tries verified parse, falls back to unverified. Returns PEM strings compatible with establish_session.
  • fetch_and_build_trust_anchors — Convenience combining fetch + build with fallback to cached bytes. Returns PEMs + updated VICAL bytes for caching.
  • establish_session_with_vical — Wraps establish_session with automatic VICAL-based trust anchor resolution.

Fallback strategy

Step Failure Fallback
Network fetch of VICAL HTML Any HTTP error Use cached VICAL bytes
HTML scraping Page format changed Use cached VICAL bytes
VICAL binary download HTTP error Use cached VICAL bytes
Fetch AAMVA trust certs HTTP error Use hardcoded DER files
Verified VICAL parse Expired signer cert, chain error Unverified parse
Unverified parse Corrupted CBOR Use only additional IACAs
No cached bytes AND no network Both unavailable Return only additional IACA PEMs

The verified field in results tells the caller whether signature verification succeeded.

App-side caching model

Rust never reads or writes to disk. The app controls storage:

let cached = loadFromStorage("vical_cache")
let result = fetchAndBuildTrustAnchors(cachedVicalBytes: cached, additionalIacaPems: extraPems)
if let updated = result.updatedVicalBytes {
    saveToStorage("vical_cache", updated)
}
let session = establishSession(handover: handover, requestedItems: items, trustAnchorRegistry: result.trustAnchorPems)

Other changes

  • Added pem feature to x509-cert dependency to enable EncodePem/DecodePem traits.
  • Hardcoded AAMVA DTS Root CA and Issuing CA certificates (valid through 2043) in rust/src/trusted_roots/ as network fallbacks.

Optional section

  • This PR exceeds 500 lines because the new vical/mod.rs module includes comprehensive tests (scraping unit tests with HTML fixtures, fallback path tests, and #[ignore] live integration tests against the real AAMVA website).
  • Reviewers, please pay attention to the HTML scraping logic — it's intentionally simple (str::find based, no regex) but will break if AAMVA changes their page structure. The fallback to cached bytes mitigates this.
  • Demo app integration will follow in a subsequent update to this PR.

Tested

Unit tests (10, run in CI):

  • HTML scraping against hardcoded fixtures matching AAMVA's page structure
  • Fallback paths: no VICAL bytes, invalid VICAL bytes, missing date
  • PEM merging with and without additional IACAs
  • Hardcoded AAMVA DER certificates parse correctly

Live integration tests (4, #[ignore], require network):

Run with cargo test vical -- --ignored --nocapture

  • live_fetch_vical — fetches raw VICAL from AAMVA
  • live_fetch_and_build — end-to-end fetch + verify + build
  • live_fetch_parse_and_list_issuers — fetches, verifies COSE signature, lists all 20 IACA certs with issuing authority/jurisdiction, then round-trips the PEMs through TrustAnchorRegistry::from_pem_certificates (the same code path establish_session uses)
  • live_cache_round_trip — verifies cached bytes produce identical anchors without network

Live test output confirms: 20 IACA root certificates extracted from verified VICAL, covering MD, UT, VA, CO, GA, AK, ND, AZ, MT, IL.

spruceduncan and others added 2 commits March 10, 2026 12:10
Fetch, verify, and parse the AAMVA VICAL (Verified Issuer Certificate
Authority List) to dynamically build IACA trust anchor registries for
mDL verification across all participating US states.

Exports four UniFFI functions:
- fetch_vical: scrapes AAMVA website for the latest VICAL binary
- build_trust_anchor_registry_from_vical: parses cached VICAL bytes
  into PEM trust anchors, with optional additional IACA certs
- fetch_and_build_trust_anchors: convenience combining fetch + build
  with fallback to cached bytes
- establish_session_with_vical: wraps establish_session with automatic
  VICAL-based trust anchor resolution

Includes hardcoded AAMVA root and intermediate CA certificates as
fallbacks when the network is unavailable. Every step in the pipeline
has graceful degradation: network failures fall back to cached bytes,
signature verification failures fall back to unverified parsing, and
parse failures fall back to additional IACA PEMs only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@spruceduncan
spruceduncan marked this pull request as ready for review March 31, 2026 17:36

@Ryanmtate Ryanmtate left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! The only major issue I saw was the unused _verified flag for adding an unverified root to the trust anchor. We should provide a parameter to use this flag to either handle an error, or explicitly allow this to pass through (e.g. dev purposes, etc.)

Comment thread rust/src/vical/mod.rs

if let Some(bytes) = vical_bytes {
match verify_or_parse_vical(&bytes) {
Ok((vical, _verified)) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use the _verified flag here, allowing potentially an unverified (untrusted) root to be used in the trust anchor. Can we add an option that gates this, something like allow_untrusted: bool, that will deny unverified VICALs from being added to the trust registry?

Comment thread rust/src/vical/mod.rs
Comment on lines +315 to +323
verified = v;
if final_name.is_none() {
final_name = Some(vical.vical_provider.clone());
}
if final_date.is_none() {
final_date = Some(format!("{:?}", vical.date));
}
let registry = vical.to_trust_anchor_registry();
all_pems.extend(trust_anchors_to_pems(&registry));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar issue to the above

Comment thread rust/src/vical/mod.rs
let options = ValidationOptions::default();

// Try verified parse (async, use block_on)
match crate::mdl::block_on(VerifiedVical::from_bytes_with_options(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to use block_on here? should we maybe expose the async runtime in the uniffi export function to make that method async?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#[uniffi::export(async_runtime = "tokio")]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants