This library provides a Rust implementation of the holder side of VCALM —
Verifiable Credential API Lifecycle Management — the vcapi exchange protocol
defined by VC API.
[dependencies]
vcalm-rs = { git = "https://github.com/spruceid/vcalm-rs", rev = "..." }Implement the credential-store and signer ports over your wallet, then drive the
exchange. The third port, the JSON-LD engine, defaults to the bundled ssi
implementation; pass your own with new_session_with_engine.
use std::sync::Arc;
use vcalm_rs::holder::VcalmHolder;
use vcalm_rs::exchange::StepResult;
let holder = VcalmHolder::new_session(
store, // Arc<dyn VcalmCredentialStore<Credential = Arc<MyCredential>>>
vec![], // trusted DIDs (forward-looking)
signer, // Arc<dyn VcalmSigner>
context_map, // Option<HashMap<String, String>> -- your bundled JSON-LD contexts
).await?;
// Optionally seed the matcher from the host's own wallet. When set, these
// REPLACE the store as the candidate set for matching -- they are not merged.
holder.provide_credentials(wallet_credentials).await;
let mut step = holder.clone().start_exchange(scanned_url, None).await?;
loop {
step = match step {
// The verifier wants a presentation
StepResult::Request { .. } => {
let matched = holder.matched_credentials().await?;
let fields = holder.requested_fields().await?; // for the consent UI
let (selected, selected_fields) = user_selects(matched, fields);
// `selected_fields: HashMap<u32, Vec<String>>`, keyed by query index.
// An ABSENT index means "disclose everything that query named"; a
// present-but-empty vec means "the user deselected every field". So
// `HashMap::new()` is correct when there is no per-field consent UI.
holder
.clone()
.submit_presentation(selected, selected_fields, false)
.await?
}
// The issuer offered credentials
StepResult::Offer { .. } => {
let preview = holder.offered_credentials().await?;
if user_accepts(preview) {
holder.clone().accept_offer().await?
} else {
holder.clone().reject_offer().await?
}
}
StepResult::Redirect { url } => break follow(url),
StepResult::Complete => break,
StepResult::Problem { details } => break show(details),
};
}- Interaction: the holder scans an
interaction:<url>QR code, or a barehttp(s)URL carrying a?iuv=1parameter. - Protocol discovery:
discover_protocolsGETs that endpoint and returns the advertisedprotocolsmap. - Start:
VcalmHolder::start_exchangeresolves the exchange URL and POSTs an empty{}message.
All discovery code is in the discover_protocols module. It is
protocol-agnostic: the returned map names every protocol the endpoint
advertises.
- Each reply is mapped to a
StepResultbyexchange::classify, based on field-presence:verifiablePresentation(Offer) →redirectUrl(Redirect) →verifiablePresentationRequest(Request) →Complete. - The server's
referenceIdis echoed on the next request.
- Matching:
matched_credentialsruns QueryByExample — type,@contextand recursivecredentialSubjectsubset matching — per query, over the host-provided credentials if any were supplied, otherwise over the store. - Consent:
requested_fieldsreports the fields each query'sexamplenames, for display. It is informational;ecdsa-rdfc-2019reveals the whole credential. - Submission:
submit_presentationassembles a presentation, and signs it. The proof binds the VPRchallengeanddomainwithProofPurpose::Authentication.
Selective disclosure activates on two gates: the VPR lists ecdsa-sd-2023
and the matched credential carries a derivable base proof. When both hold,
the derive reveals the fields that credential's own queries named, narrowed by
selected_fields.
Narrowing is deliberately constrained. Only credentialSubject.* paths can be
dropped — type, @context and credentialStatus are structural. A selection that drops fields from a query without an explicit "required": false
is refused with VcalmError::RequiredFieldsDeselected, and an optional query
whose subject fields are all deselected drops that credential from the
presentation rather than deriving a VC with no credentialSubject.
- Preview:
offered_credentialsverifies each offered VC read-only and surfaces anOfferedValidityvalue. - Accept:
accept_offerverifies every credential first. Any proof failure aborts the whole offer and stores nothing; a valid-but-expired credential is still stored, with a distinct warning. - Reject:
reject_offeradvances without storing.
sequenceDiagram
participant Holder
participant Exchange as Exchange Server
Holder->>Exchange: 1. GET interaction URL (discovery)
Exchange-->>Holder: 2. { protocols: { vcapi, OID4VP, ... } }
Holder->>Exchange: 3. POST {} to the vcapi URL
Exchange-->>Holder: 4. Request (verifiablePresentationRequest)
Note over Holder: 5. Match credentials, show consent
Holder->>Exchange: 6. POST signed verifiablePresentation
Exchange-->>Holder: 7. Offer (verifiablePresentation)
Note over Holder: 8. Verify all, then store all
Holder->>Exchange: 9. POST {} to advance
Exchange-->>Holder: 10. Complete / Redirect / Problem
cargo test
cargo clippy --all-targets -- -D warnings