feat(vta-service)!: present ISO mdoc credentials over OID4VP - #993
Conversation
Completes mdoc support. A VTA could receive, verify and store an mdoc; it could
not present one. This is the last piece, and it needed three things the other
formats do not.
An OID4VP session on the query wire. An mdoc's holder binding is a DeviceAuth
signature over an ISO 18013-7 SessionTranscript, whose handover is
[clientId, responseUri, nonce, mdocGeneratedNonce]. Two of those exist only in
an OID4VP exchange, so a verifier that wants an mdoc supplies them; QueryBody
gains an optional oid4vp_session carrying OID4VP's own field names, so a
verifier can copy them out of its authorization request unrenamed.
Absent, an mdoc is not offered at all rather than offered unbound. A DeviceAuth
over invented handover values verifies nowhere and, worse, looks bound. The gate
lives in match_held so matchable and presentable stay the same set: a
matched-but-unpresentable credential bails the entire vp_token, not just itself,
taking every other credential the verifier legitimately asked for with it. A
mutation removing the gate fails the test that pins this.
Holder identity that is key-shaped. ConsentGrant.holder_did becomes
HolderIdentity::{Subject, DeviceKey}: every other format names a subject DID,
while an mdoc names a device key discovered at receive. Both resolve to a
did:key because ConsentRecord::verify_proof binds the proof's
verificationMethod to the data subject — the variant records provenance that
would otherwise be silently lost, not a different kind of value.
A P-256 consent receipt. The device key signs its own receipt under
ecdsa-jcs-2019 (affinidi-data-integrity 0.7.10), where every other format uses
eddsa-jcs-2022. Signing the receipt with some other key would break the
verificationMethod binding above; that is why the cryptosuite was added upstream
rather than worked around here.
Presentation itself is not a present_single arm: an mdoc vp_token entry is
base64url CBOR of a DeviceResponse, not a W3C VP object, so present_mdoc sits
beside it. Selective disclosure is by omission — only the [namespace, element]
paths the query asked for are included.
BREAKING: ConsentGrant.holder_did is replaced by ConsentGrant.holder;
match_vault and match_held take the session / a can_bind_mdoc flag; QueryBody
gains a field. All 0.x, and release-plz moves the compatibility fields.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Two QueryBody literals outside vta-service were missed: vta-sdk's own protocol test and the VTC join-request query builder. Both fail to compile against the added oid4vp_session field. The VTC one gets an explanatory None rather than a bare fill-in. The join flow is a Trust-Task exchange, not OID4VP over HTTP, so there is no response_uri to bind an mdoc's DeviceAuth to and an mdoc is deliberately not offered there — a VTC that later wants to accept one has to run a real OID4VP session and pass it through. Found by running cargo check --workspace --all-targets, which is what CI runs and what I should have run before pushing a change to a shared wire type. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Review
📊 Summary
|
| Field | Value |
|---|---|
| Repository | OpenVTC/verifiable-trust-infrastructure |
| Branch | feat/mdoc-present → main |
| Validated | 2026-08-16 |
| Scan ID | d90df867 |
| Validator | AI Security Validation Agent |
🗺️ Scan Coverage
Modules scanned: 5 · with findings: 1 · files: 10 · findings: 5
| Module | Files scanned | Findings |
|---|---|---|
vta-service |
4 | 5 |
(root) |
2 | 0 |
vta-vault |
2 | 0 |
vta-sdk |
1 | 0 |
vtc-service |
1 | 0 |
Executive Summary
| Category | Confirmed | Must-Review-By-Human | False Positive | Duplicate | Not Applicable | Total |
|---|---|---|---|---|---|---|
| Security Issues | 2 | 3 | 0 | 0 | 0 | 5 |
⚠️ 3 finding(s) need human review. These could not be conclusively confirmed or dismissed automatically (insufficient evidence). They are not dismissed — a developer / security team member must read and decide.
🔒 Security Issues
Confirmed Vulnerabilities (2)
🟠 Super-admin fallback in resolve_mdoc_device_keys bypasses per-context ACL for unscoped device keys
| Field | Detail |
|---|---|
| Severity | HIGH |
| Location | vta-service/src/operations/holder_keys.rs:68 |
| Finding ID | github_pr-91d8dc34cea9 |
| CWE | CWE-269, CWE-863 |
| OWASP | A01:2021 - Broken Access Control |
| MITRE ATT&CK | T1078, T1548 |
| CAPEC | CAPEC-233, CAPEC-122 |
| DREAD | 6 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
Summary: resolve_mdoc_device_keys grants access to unscoped (context_id == None) mdoc device keys to anyone with super-admin claims, with no additional scoping, audit trail, or step-up authentication, meaning a single elevated credential compromises every such key in the deployment.
📝 Description:
An attacker with a super-admin token (via insider threat, credential leak, or a chained privilege-escalation bug) can extract private signing keys for any unscoped mdoc holder in the deployment and impersonate them in presentations and consent receipts, undermining the non-repudiation guarantees the whole system is built to provide.
🧪 Proof of Concept:
The None branch collapses to a single global check (require_super_admin) rather than any per-key or per-holder scoping, meaning the blast radius of a super-admin token compromise includes every unscoped device key in the system.
if record.origin != KeyOrigin::Derived {
return Err(AppError::Validation(
"imported mdoc device keys are not supported for presentation yet".into(),
));
}
// ── Privilege boundary: only sign with a key in an authorised context. ──
// Same gate as `resolve_holder_keys`; without it a caller could present
// another context's mdoc by naming its device key.
match &record.context_id {
Some(ctx) => auth.require_context(ctx)?,
None => auth.require_super_admin()?,
}
let mut seed = load_seed_bytes(keys_ks, &**seed_store, record.seed_id)
.await
.map_err(|e| AppError::Internal(format!("seed load: {e}")))?;
Vulnerable lines: 60, 76
🔎 Evidence: vta-service/src/operations/holder_keys.rs:68
match &record.context_id {
Some(ctx) => auth.require_context(ctx)?,
None => auth.require_super_admin()?,
}
💥 Impact:
A single compromised or over-privileged super-admin credential can extract raw signing material for any unscoped mdoc device key in the system, allowing impersonation of any such holder in presentations and consent receipts — a total compromise of the affected key population rather than a scoped, single-tenant breach.
Confidentiality: high · Integrity: high · Availability: none
🧭 Reachability:
- Network exposure: internal
- Auth barrier: strong
- Attack path: EP-004 (resolve_mdoc_device_keys) → KeyRecord lookup by key_id → record.context_id == None branch → auth.require_super_admin() (holder_keys.rs:68-74) → seed load & BIP-32 derivation → device_private returned
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | high |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: A holder of a super-admin-scoped AuthClaims token can extract signing material for any unscoped mdoc device key in the system, bypassing intended context/tenant isolation.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
While super-admin fallback may be an intentional break-glass design for unscoped keys, it should be paired with mandatory audit logging, step-up authentication, and ideally elimination of unscoped key provisioning in production so this path is rarely if ever exercised.
Vulnerable code:
match &record.context_id {
Some(ctx) => auth.require_context(ctx)?,
None => auth.require_super_admin()?,
}
Secure code:
match &record.context_id {
Some(ctx) => auth.require_context(ctx)?,
None => {
auth.require_super_admin()?;
audit_log::record_privileged_key_access(auth, key_id, "mdoc_device_key");
}
}
🔍 Validation Log
- Verdict: ✅ Confirmed True Positive
- Confidence: 90%
- AI Validation Evidence: EVIDENCE FOUND: holder_keys.rs lines 68-74 for resolve_mdoc_device_keys (the version in source_files, later in file) explicitly shows: 'match &record.context_id { Some(ctx) => auth.require_context(ctx)?, None => auth.require_super_admin()?, }'. This is the sole ACL gate for context-less mdoc device keys — any caller with a super-admin AuthClaims token passes this check regardless of which unscoped key they target, since key_id is caller-supplied and there's no further per-key scoping. EVIDENCE NOT FOUND: No additional per-key allowlist, audit gate, or scoping mechanism beyond the boolean super-admin check was found in the provided holder_keys.rs. The identical pattern also exists in resolve_holder_keys (line ~130), confirming this is the established (if risky) convention. CHANGED VS PRE-EXISTING: resolve_mdoc_device_keys is a NEW function added by this MR (per features_affected: 'New resolve_mdoc_device_keys function'), so the vulnerable construct is CHANGED code in holder_keys.rs. VERDICT JUSTIFICATION: The code is present, the sink (key resolution granting device_private access) is reachable by any super-admin token for ANY unscoped key, and this matches the reported design flaw exactly as quoted in evidence.
- Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.
🔵 Seed material zeroization skipped on BIP-32 derivation error path (delayed wipe reliance on Drop)
| Field | Detail |
|---|---|
| Severity | LOW |
| Location | vta-service/src/operations/holder_keys.rs:78 |
| Finding ID | github_pr-5218e9726859 |
| CWE | CWE-459, CWE-226 |
| OWASP | A02:2021 - Cryptographic Failures |
| MITRE ATT&CK | T1005 |
| CAPEC | CAPEC-37 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | theoretical |
| Detection Source | skill_scan |
🔎 Evidence: vta-service/src/operations/holder_keys.rs:78
let mut seed = load_seed_bytes(keys_ks, &**seed_store, record.seed_id)
.await
.map_err(|e| AppError::Internal(format!("seed load: {e}")))?;
let bip32 = ExtendedSigningKey::from_seed(&seed)
.map_err(|e| AppError::Internal(format!("BIP-32 root key: {e}")))?;
seed.zeroize();
💥 Impact:
If exploited (requiring local memory access such as a core dump, debugger attach, or compromised co-tenant process coincident with a derivation failure), the entire BIP-32 seed tree — not just the single mdoc device key being resolved — could be recovered, expanding the blast radius from one key to every key derivable from that seed.
Confidentiality: high · Integrity: none · Availability: none
🧭 Reachability:
- Network exposure: none
- Auth barrier: strong
- Attack path: EP-004 (resolve_mdoc_device_keys) → load_seed_bytes (returns Zeroizing<Vec>) → ExtendedSigningKey::from_seed(&seed) error path → early return via
?before explicit seed.zeroize() (holder_keys.rs:78-83)
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | low |
| Business impact | low |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: On a rare BIP-32 derivation failure, the raw seed bytes remain unwiped for a nondeterministic window relying on async Drop semantics, widening the exposure window for local memory-inspection attacks.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Reorder the code so zeroize() executes unconditionally immediately after the derivation attempt, before the error is propagated, removing the error-path window during which the Drop-based wipe timing is nondeterministic.
Vulnerable code:
let mut seed = load_seed_bytes(...).await.map_err(...)?;
let bip32 = ExtendedSigningKey::from_seed(&seed).map_err(...)?;
seed.zeroize();
Secure code:
let mut seed = load_seed_bytes(...).await.map_err(...)?;
let bip32_result = ExtendedSigningKey::from_seed(&seed);
seed.zeroize(); // Always wipe, regardless of outcome.
let bip32 = bip32_result.map_err(|e| AppError::Internal(format!("BIP-32 root key: {e}")))?;
🔍 Validation Log
- Verdict: ✅ Confirmed True Positive
- Confidence: 85%
- AI Validation Evidence: EVIDENCE FOUND: holder_keys.rs (in source_files) for resolve_mdoc_device_keys shows: 'let mut seed = load_seed_bytes(...).await.map_err(...)?; let bip32 = ExtendedSigningKey::from_seed(&seed).map_err(...)?; seed.zeroize();' — this exact sequence appears twice in the file (once for resolve_holder_keys, once for resolve_mdoc_device_keys). The
?after from_seed's map_err means an early return on error skips the explicitseed.zeroize()call that follows. EVIDENCE NOT FOUND: No scope-guard, defer, or Drop-based immediate wipe mechanism visible beyondZeroizing<Vec<u8>>'s eventual Drop; load_seed_bytes's implementation (in crate::keys::seeds) is not provided so I can't confirm it doesn't already return a type wrapped for immediate zeroization on error, but the code as shown clearly delays zeroize() to after the fallible from_seed call. CHANGED VS PRE-EXISTING: resolve_mdoc_device_keys is new code added by this MR replicating the same (pre-existing) pattern seen in resolve_holder_keys; the vulnerable construct itself appears in both the new and old functions in the same file, making the specific line 78-83 instance CHANGED via the new resolve_mdoc_device_keys function. VERDICT JUSTIFICATION: The quoted code precisely matches the reported early-return-skips-zeroize pattern; Zeroizing<Vec> Drop still provides eventual cleanup, consistent with the 'low' severity, but the ordering issue is real and directly observable in the provided source.- Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.
⚠️ Must-Review-By-Human (3)
Validated up to a point, but inconclusive — a human must read the code and make the final call. Reported (not dismissed) so developers and the security team receive them.
🟠 Verifier-supplied OID4VP session fields used unvalidated in mdoc DeviceAuth SessionTranscript
| Field | Detail |
|---|---|
| Severity | HIGH |
| Location | vta-service/src/operations/credential_exchange.rs:691 |
| Finding ID | github_pr-66ecf02cd838 |
| CWE | CWE-346, CWE-290, CWE-345 |
| OWASP | A07:2021 - Identification and Authentication Failures |
| MITRE ATT&CK | T1556, T1550 |
| CAPEC | CAPEC-593, CAPEC-22 |
| DREAD | 5.4 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | poc |
| Detection Source | skill_scan |
Summary: The mdoc presentation path builds a SessionTranscript directly from verifier-supplied Oid4vpSession fields with no validation that they correspond to an authenticated verifier session, allowing a malicious verifier to obtain a cryptographically signed DeviceAuth bound to fabricated transcript values.
📝 Description:
An attacker who controls the verifier side of a presentation exchange can obtain a validly-signed DeviceAuth bound to a transcript containing fabricated client_id/response_uri values, potentially enabling presentation replay against a different relying party or undermining any downstream trust decision that assumes the transcript reflects an authenticated OID4VP session.
🧪 Proof of Concept:
session (an Oid4vpSession deserialized straight from client-supplied QueryBody JSON) is trusted implicitly; no comparison is made against verifier_did or any registered relying-party record before its fields are baked into the cryptographically-signed transcript.
async fn present_mdoc(
stored: &StoredCredential,
disclosed_paths: &[Vec<String>],
device_private: &[u8],
session: &Oid4vpSession,
nonce: &str,
) -> Result<Value, AppError> {
use affinidi_mdoc::es256_cose::Es256CoseSigner;
...
let transcript = affinidi_mdoc::SessionTranscript::new_oid4vp(
&session.client_id,
&session.response_uri,
nonce,
&session.mdoc_generated_nonce,
);
let signer = Es256CoseSigner::from_bytes(device_private)
.map_err(|e| AppError::Internal(format!("mdoc device signer: {e}")))?;
let response = affinidi_mdoc::DeviceResponse::create_with_device_auth(
&issued, &requested, &transcript, &signer, None,
)
.map_err(|e| AppError::Internal(format!("build mdoc DeviceResponse: {e}")))?;
Vulnerable lines: 678, 700
🔎 Evidence: vta-service/src/operations/credential_exchange.rs:691
let transcript = affinidi_mdoc::SessionTranscript::new_oid4vp(
&session.client_id,
&session.response_uri,
nonce,
&session.mdoc_generated_nonce,
);
💥 Impact:
The DeviceAuth signature over an unvalidated transcript can appear cryptographically valid to a downstream relying party while actually being bound to attacker-influenced session parameters, undermining the entire holder-binding guarantee ISO 18013-7 mdoc presentation relies on. This threatens the core value proposition of the trust infrastructure — verifiable, non-repudiable presentations.
Confidentiality: medium · Integrity: high · Availability: none
🧭 Reachability:
- Network exposure: internal
- Auth barrier: basic
- Attack path: EP-001 (present_query) → QueryBody.oid4vp_session → match_vault(session.is_some() only) → present_matched_set → present_mdoc → SessionTranscript::new_oid4vp (credential_exchange.rs:691-698)
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | high |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: A malicious/compromised verifier supplies forged OID4VP session parameters that get baked into a signed mdoc DeviceAuth transcript without verification against the verifier's authenticated identity.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Bind session.client_id/response_uri to the already-authenticated verifier_did or a verifier allow-list before building the transcript, closing the gap between 'verifier claims this session' and 'verifier is cryptographically who they say they are'.
Vulnerable code:
let transcript = affinidi_mdoc::SessionTranscript::new_oid4vp(
&session.client_id,
&session.response_uri,
nonce,
&session.mdoc_generated_nonce,
);
Secure code:
// Validate session fields against the authenticated verifier context before use.
if session.client_id != verifier_did && !verifier_registry.is_registered_client_id(&session.client_id) {
return Err(AppError::Validation("oid4vp_session.client_id does not match authenticated verifier".into()));
}
validate_response_uri_scheme_and_host(&session.response_uri, &allowed_hosts)?;
let transcript = affinidi_mdoc::SessionTranscript::new_oid4vp(
&session.client_id,
&session.response_uri,
nonce,
&session.mdoc_generated_nonce,
);
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 55%
- AI Validation Evidence: EVIDENCE FOUND: The scan targets vta-service/src/operations/credential_exchange.rs line 691-698, showing
SessionTranscript::new_oid4vp(&session.client_id, &session.response_uri, nonce, &session.mdoc_generated_nonce). This file is not included in source_files, so I cannot see the actual present_mdoc/present_query code, match_vault, or any validation logic around Oid4vpSession fields. EVIDENCE NOT FOUND: credential_exchange.rs itself is absent from source_files, so I cannot confirm whether client_id/response_uri undergo any origin/format validation before being fed into the transcript, nor can I verify the QueryBody deserialization boundary in vta-sdk/src/protocols/credential_exchange.rs (also not provided). CHANGED VS PRE-EXISTING: This is new functionality introduced by this MR (feat/mdoc-present adds Oid4vpSession/present_mdoc per the affects_summary 'new_data_inputs': QueryBody.oid4vp_session.*), so it is CHANGED code, but I lack the actual file content to trace the full data flow and confirm absence of validation. VERDICT JUSTIFICATION: Without the credential_exchange.rs source (only quoted snippet available), I cannot positively confirm no validation exists elsewhere in present_query/match_vault; must_review per hard rule that missing deciding file/function requires human review, not auto-validation.- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
🟡 Consent record identifier discarded for mdoc presentations, breaking consent-to-presentation binding
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | vta-service/src/operations/credential_exchange.rs:812 |
| Finding ID | github_pr-e0d4f69a1716 |
| CWE | CWE-778, CWE-354 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
| MITRE ATT&CK | T1070 |
| CAPEC | CAPEC-268 |
| DREAD | 5.2 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
Summary: The code explicitly discards the consent record's identifier (let _ = &consent;) in the mdoc presentation branch instead of passing it into present_mdoc, unlike the analogous non-mdoc path, creating a confirmed repudiation gap in consent-to-presentation traceability.
📝 Description:
Auditors, compliance reviewers, or dispute-resolution processes cannot cryptographically verify that a given mdoc DeviceResponse was authorized by a specific, valid (non-expired, non-revoked) consent grant, weakening the platform's ability to demonstrate GDPR/DPV-style consent compliance for mdoc presentations specifically.
🧪 Proof of Concept:
The let _ = &consent; line is a no-op suppression of an unused-variable warning that permanently severs the link between the created ConsentRecord and the signed DeviceResponse, unlike the else-branch which correctly passes &consent.identifier into present_single.
let presentation = if stored.format == CredentialFormat::MsoMdoc {
let device_key_id = stored.tags.get(vta_vault::model::MDOC_DEVICE_KEY_TAG).ok_or_else(...)?;
let keys = resolve_mdoc_device_keys(keys_ks, seed_store, auth, device_key_id).await?;
let consent = consent::create(
vault,
&ConsentGrant {
holder: vta_vault::consent::HolderIdentity::DeviceKey(&keys.device_did),
credential_id: &m.credential_id,
verifier_did,
purpose: &query.purpose,
claims,
valid_until,
},
&keys.consent_secret,
)
.await?;
let _ = &consent;
let session = query.oid4vp_session.as_ref().ok_or_else(...)?;
present_mdoc(&stored, &m.disclosed_paths, &keys.device_private, session, &query.nonce).await?
} else { ... }
Vulnerable lines: 800, 845
🔎 Evidence: vta-service/src/operations/credential_exchange.rs:812
let consent = consent::create(
vault,
&ConsentGrant { holder: vta_vault::consent::HolderIdentity::DeviceKey(&keys.device_did), ... },
&keys.consent_secret,
)
.await?;
let _ = &consent;
💥 Impact:
Weakens auditability and dispute-resolution capability for mdoc presentations, since a DeviceResponse cannot be cryptographically shown to correspond to a specific, valid consent grant — a compliance concern for privacy regulations (e.g., GDPR consent-receipt requirements referenced by the codebase's own dpv:hasDataSubject/DPV vocabulary usage) rather than a direct confidentiality breach.
Confidentiality: none · Integrity: low · Availability: low
🧭 Reachability:
- Network exposure: internal
- Auth barrier: basic
- Attack path: EP-001/EP-002 (present_query / approve_pending_presentation) → present_matched_set → MsoMdoc branch → consent::create(...) → let _ = &consent (credential_exchange.rs:826) → present_mdoc (no consent id passed)
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | low |
| Business impact | medium |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: The mdoc presentation path creates a consent record but discards its identifier before signing the DeviceResponse, so there's no cryptographic proof tying a specific presentation to a specific consent grant.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Thread the consent.identifier into present_mdoc (extending its signature analogous to present_single) so the resulting DeviceResponse or an accompanying signed log entry can be provably correlated to the exact consent grant that authorized it.
Vulnerable code:
let consent = consent::create(vault, &ConsentGrant { ... }, &keys.consent_secret).await?;
let _ = &consent;
Secure code:
let consent = consent::create(vault, &ConsentGrant { ... }, &keys.consent_secret).await?;
present_mdoc(
&stored,
&m.disclosed_paths,
&keys.device_private,
session,
&query.nonce,
&consent.identifier, // threaded through, e.g. embedded as a DeviceResponse status/extension element or correlated log entry
)
.await?
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 50%
- AI Validation Evidence: EVIDENCE FOUND: The finding quotes credential_exchange.rs lines 812-826 showing
let consent = consent::create(...).await?; let _ = &consent;in the MsoMdoc branch of present_matched_set, contrasted with the non-mdoc branch passing&consent.identifierinto present_single. This matches the security_findings SEC-001 entry which describes the identical pattern with matching line numbers (779-882). EVIDENCE NOT FOUND: credential_exchange.rs is not present in source_files, so I cannot independently verify present_mdoc's full signature to confirm it truly has no parameter for consent identifier, nor confirm the non-mdoc present_single call site for comparison. CHANGED VS PRE-EXISTING: present_matched_set's MsoMdoc branch is explicitly new code added by this MR (mdoc presentation is a new feature per features_affected), so this is CHANGED code. VERDICT JUSTIFICATION: While the quoted snippet is consistent with the finding and threat model's SEC-001, I cannot view the actual file to confirm present_mdoc's signature lacks a consent-linking parameter entirely; treating as must_review absent full file access, though evidence leans toward validated given consistent supporting detail across scan finding and business analysis (SEC-001).- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
🟡 mdoc candidate admission relies solely on Option::is_some(), not structural session validity, risking multi-credential vp_token DoS
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | vta-service/src/operations/credential_exchange.rs:396 |
| Finding ID | github_pr-63f0b774e634 |
| CWE | CWE-20, CWE-754 |
| OWASP | A04:2021 - Insecure Design |
| MITRE ATT&CK | T1499 |
| CAPEC | CAPEC-153 |
| DREAD | 5.6 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
Summary: match_vault's can_bind_mdoc flag is derived only from Option::is_some() on the OID4VP session, without checking field-level validity, allowing a malformed-but-present session to admit an mdoc candidate that later fails during presentation and aborts the entire vp_token batch for all matched credentials.
📝 Description:
Legitimate holders attempting to present multiple credentials in a single DCQL exchange (a supported and encouraged pattern per the codebase's design) can have their entire presentation blocked by a verifier supplying a malformed OID4VP session, denying access to unrelated, otherwise-valid credentials in the same batch.
🧪 Proof of Concept:
session.is_some() is a purely structural presence check that does not inspect the actual field values inside Oid4vpSession, so a syntactically valid-but-empty object passes this gate identically to a fully well-formed one.
pub async fn match_vault(
vault: &KeyspaceHandle,
query: &DcqlQuery,
session: Option<&Oid4vpSession>,
) -> Result<Vec<HeldMatch>, AppError> {
let held = gather_for_query(vault, query).await?;
match_held(query, &held, session.is_some())
}
Vulnerable lines: 536, 544
🔎 Evidence: vta-service/src/operations/credential_exchange.rs:396
if stored.format == CredentialFormat::MsoMdoc && !can_bind_mdoc {
continue;
}
💥 Impact:
A verifier (malicious or simply buggy) can deny a holder's ability to present a legitimate multi-credential batch (e.g., a membership credential PLUS an mdoc) by supplying a malformed oid4vp_session, since the failure of the mdoc portion aborts the entire vp_token rather than degrading gracefully.
Confidentiality: low · Integrity: high · Availability: medium
🧭 Reachability:
- Network exposure: internal
- Auth barrier: basic
- Attack path: EP-001 (present_query) → QueryBody.oid4vp_session (malformed but Some) → match_vault (session.is_some() check, credential_exchange.rs:536-543) → match_held admits mdoc candidate (credential_exchange.rs:396-405) → present_matched_set → present_mdoc fails → entire vp_token aborted
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | medium |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: A verifier can supply a technically-present-but-empty/malformed OID4VP session to get an mdoc admitted as a match candidate, then have present_mdoc fail and abort the entire multi-credential vp_token, denying legitimate credentials to be presented.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Validate structural well-formedness of the session fields (non-empty, and ideally URL-format-valid response_uri) before treating the mdoc as a viable candidate, so malformed sessions are excluded up-front rather than causing a late failure that aborts the whole batch.
Vulnerable code:
if stored.format == CredentialFormat::MsoMdoc && !can_bind_mdoc {
continue;
}
Secure code:
fn session_is_usable(session: Option<&Oid4vpSession>) -> bool {
session.map_or(false, |s| !s.client_id.trim().is_empty()
&& !s.response_uri.trim().is_empty()
&& !s.mdoc_generated_nonce.trim().is_empty())
}
// in match_vault:
let can_bind_mdoc = session_is_usable(session);
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 45%
- AI Validation Evidence: EVIDENCE FOUND: The finding cites credential_exchange.rs lines 396-405 with
if stored.format == CredentialFormat::MsoMdoc && !can_bind_mdoc { continue; }, and the threat model STRIDE-2/SEC-007 both describe can_bind_mdoc as derived purely fromsession.is_some(). SEC-007 is listed as a 'positive_security_change' confirming this gate exists and is tested (an_mdoc_does_not_match_without_an_oid4vp_session). EVIDENCE NOT FOUND: match_vault's actual implementation is not in source_files, so I cannot verify whether can_bind_mdoc is computed as a raw is_some() check with no additional structural validation (empty-string/well-formed checks) as alleged. CHANGED VS PRE-EXISTING: match_vault/match_held signatures were modified by this MR to add the can_bind_mdoc parameter (per breaking_changes), making this CHANGED code. VERDICT JUSTIFICATION: Cannot confirm the precise boolean-only computation of can_bind_mdoc without the actual match_vault source; the affects_summary lists this gate as an 'added security control' which is in tension with calling it a vulnerability — human review needed to reconcile whether the DoS risk is real or overstated given the tested invariant (SEC-007).- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
Generated by Agentic Sec — AI Security Validation Agent
This report includes full scan data + AI validation evidence. Feed to engineering copilots for automated fix deployment.
🛡️ Threat Model & Affect Analysis (supplementary — theoretical threats and MR impact analysis)
🛡️ Open full Threat Model & Affect Analysis — threat-modelling_affect-analysis_report_PR993_2026-08-16T15-08-35.md
🛡️ Threat Model & Affect Analysis — PR #993
| Field | Value |
|---|---|
| Repository | OpenVTC/verifiable-trust-infrastructure |
| Branch | feat/mdoc-present → main |
| Generated | 2026-08-16 |
ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Validation Report (which contains confirmed, materialised issues),
these are potential risks that may or may not be exploitable. Use this for defence-in-depth planning.
📋 Affect Analysis
Change Summary
Adds the ability for the VTA service to present previously-received ISO mdoc (mso_mdoc) credentials over OID4VP, including a new OID4VP session envelope, mdoc-specific matching/candidacy gating, device-key resolution, and DeviceAuth signing bound to the verifier's session transcript.
Diff: +455 / -90 lines
Types: feature, security, refactor, config, test
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| Credential Presentation Engine (vta-service::operations::credential_exchange) | critical | modified | Added full mdoc presentation pipeline: candidacy gating, claims decoding, device-key-based presentation, session-transcript-bound DeviceAuth |
| mdoc Device Key Management (vta-service::operations::holder_keys) | high | added | New ACL-gated resolution path for mdoc device signing keys |
| OID4VP Protocol Envelope (vta-sdk::protocols::credential_exchange) | medium | modified | New optional Oid4vpSession field and struct added to the wire-level QueryBody |
| Consent Receipt Model (vta-vault::consent) | medium | modified | HolderIdentity enum replaces flat holder_did string, formalizing subject-DID vs device-key-derived provenance |
| Dependency Supply Chain (Cargo workspace) | medium | modified | affinidi-mdoc and affinidi-data-integrity bumped; syn, windows-sys, and socket2 transitively downgraded |
| VTC Join-Request Flow (vtc-service) | low | modified | Explicitly excludes mdoc offering by setting oid4vp_session: None |
📁 File Classifications
vta-service/src/operations/credential_exchange.rs
- Type: security-critical
vta-service/src/operations/holder_keys.rs
- Type: security-critical
vta-sdk/src/protocols/credential_exchange.rs
- Type: business-logic
vta-vault/src/consent.rs
- Type: security-critical
Cargo.lock
- Type: infrastructure
Cargo.toml
- Type: configuration
vta-vault/src/present.rs
- Type: test
vta-service/tests/vault_consent.rs
- Type: test
vta-service/tests/vault_present.rs
- Type: test
vtc-service/src/routes/join_requests/present.rs
- Type: business-logic
🛡️ STRIDE Threat Model
Identified Threats (11)
⚪ STRIDE-1: Verifier-Controlled Session Transcript Forgery in Oid4vpSession Deserialization
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| Severity | High |
| Likelihood | Likely |
| CVSS | 7.5 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | High |
| CWE | CWE-346,CWE-290 |
| CAPEC | CAPEC-593,CAPEC-22 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: QueryBody.oid4vp_session deserialization in credential_exchange.rs allows unauthenticated verifier-supplied client_id/response_uri injection due to no validation of the OID4VP session fields before binding them into the mdoc SessionTranscript, resulting in acceptance of transcripts bound to attacker-chosen relying-party identifiers.
Evidence: vta-service/src/operations/credential_exchange.rs:675-745
let transcript = affinidi_mdoc::SessionTranscript::new_oid4vp(&session.client_id, &session.response_uri, nonce, &session.mdoc_generated_nonce);
Attack Scenario:
- A malicious or compromised verifier sends a QueryBody with an
oid4vp_sessionobject whereclient_idandresponse_uriare attacker-controlled values not validated against any registered verifier identity (vta-sdk/src/protocols/credential_exchange.rs, Oid4vpSession struct). present_query(vta-service/src/operations/credential_exchange.rs) passesquery.oid4vp_session.as_ref()straight intomatch_vault, which only checkssession.is_some()viacan_bind_mdoc— it never validates the session's contents against the actual verifier_did or any expected redirect target.present_mdocbuildsSessionTranscript::new_oid4vp(&session.client_id, &session.response_uri, nonce, &session.mdoc_generated_nonce)using these unvalidated fields verbatim.- The DeviceAuth COSE_Sign1 is computed over this attacker-influenced transcript, producing a cryptographically valid signature that is bound to a transcript the verifier fabricated rather than one derived from an authenticated OID4VP authorization request.
- An attacker positioned as a relaying/misbehaving verifier can replay or redirect the resulting DeviceResponse to a different relying party by reusing captured (client_id, response_uri, nonce, mdoc_generated_nonce) tuples, since nothing in this code cross-checks them against
verifier_didor an established session record.
🔎 Threat Clue: Derived from COMP-002, COMP-003 via EP-006, EP-001
- Data Flows: QueryBody -> present_query -> present_mdoc -> SessionTranscript
Preconditions: Attacker controls or intercepts a verifier-facing QueryBody submission, No independent verification ties oid4vp_session fields to an authenticated/expected verifier session
Existing Controls: Presentation requires AuthClaims-gated holder key resolution (ACL boundary) • Nonce is taken from QueryBody.nonce separately from mdoc_generated_nonce
Recommended Mitigations: Bind oid4vp_session.client_id/response_uri to the verifier_did already authenticated for the exchange • Reject oid4vp_session values that do not match a pre-registered or attested verifier endpoint • Add cryptographic session-establishment step before accepting client-supplied transcript fields
⚪ STRIDE-2: mdoc Candidate Admission Bypass via can_bind_mdoc Boolean Gate Desync
| Field | Detail |
|---|---|
| Category | Denial of Service, Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 6.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-20,CWE-754 |
| CAPEC | CAPEC-153 |
| OWASP | A04:2021 - Insecure Design |
Description: match_held in credential_exchange.rs allows mdoc candidate matching to diverge from presentability due to relying on a single boolean can_bind_mdoc flag rather than validating the actual session contents, resulting in a matched-but-unpresentable mdoc that aborts the entire vp_token for all other legitimately matched credentials.
Evidence: vta-service/src/operations/credential_exchange.rs:387-406
if stored.format == CredentialFormat::MsoMdoc && !can_bind_mdoc { continue; }
Attack Scenario:
match_vaultcomputescan_bind_mdocpurely fromsession.is_some()(vta-service/src/operations/credential_exchange.rs, match_vault function), not from whether the session is actually usable/well-formed.- A verifier or code path can supply an
Oid4vpSessionwith empty or malformedclient_id/response_uristrings that still satisfyOption::is_some(). match_heldadmits the mdoc candidate sincecan_bind_mdocis true, and the mdoc is now bundled into the matched set alongside other credentials the verifier legitimately requested.present_matched_setlater callspresent_mdoc, which may still succeed (producing a transcript with empty/garbage values) or fail with anAppError.- If
present_mdocfails, the error propagates and bails the entirepresent_matched_setoperation (per the code comment 'bailing the entire vp_token — including every other credential the verifier legitimately asked for'), denying legitimate presentation of unrelated credentials in the same query.
🔎 Threat Clue: Derived from COMP-002 via EP-003, EP-001
- Data Flows: match_vault -> match_held -> present_matched_set
Preconditions: Verifier or man-in-the-middle can supply a syntactically present but semantically empty/invalid Oid4vpSession, Holder is attempting a multi-credential DCQL presentation including an mdoc
Existing Controls: can_bind_mdoc gate prevents mdoc candidacy entirely when session is None • Test coverage (an_mdoc_does_not_match_without_an_oid4vp_session) validates the None case
Recommended Mitigations: Validate oid4vp_session field non-emptiness/format before computing can_bind_mdoc • Isolate per-credential presentation failures so one credential's failure does not abort the full vp_token batch • Add structural validation (URL format for response_uri, non-empty client_id) at deserialization/validate() time
⚪ STRIDE-3: ACL Context Bypass in resolve_mdoc_device_keys Super-Admin Fallback
| Field | Detail |
|---|---|
| Category | Elevation of Privilege, Information Disclosure |
| Severity | High |
| Likelihood | Possible |
| CVSS | 8.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-269,CWE-863 |
| CAPEC | CAPEC-233,CAPEC-122 |
| OWASP | A01:2021 - Broken Access Control |
Description: resolve_mdoc_device_keys in holder_keys.rs allows privilege elevation for context-less device keys due to falling back to require_super_admin() rather than a scoped check, resulting in any actor who can pass a super-admin token gaining access to sign with any unscoped mdoc device key regardless of intended context isolation.
Evidence: vta-service/src/operations/holder_keys.rs:~70-76
match &record.context_id {
Some(ctx) => auth.require_context(ctx)?,
None => auth.require_super_admin()?,
}
Attack Scenario:
- An mdoc device key is provisioned/derived without a
context_id(record.context_id == None), which the code comment states occurs for keys not bound to a specific context. resolve_mdoc_device_keys(vta-service/src/operations/holder_keys.rs) executesNone => auth.require_super_admin()?as the sole gate for such keys.- An attacker who compromises or is issued a super-admin-scoped AuthClaims token (e.g., via a separate vulnerability, misconfiguration, or insider threat) can call
resolve_mdoc_device_keysfor ANY unscoped device key across the entire VTA instance, not just keys belonging to their own tenant/context. - The attacker obtains
device_private(Zeroizing<Vec>) andconsent_secretfor that key, enabling them to sign arbitrary DeviceAuth presentations and consent receipts impersonating the legitimate holder. - Because there is no secondary audit or scoping check beyond the boolean super-admin claim, this creates a single point of total compromise for all unscoped mdoc keys.
🔎 Threat Clue: Derived from COMP-001 via EP-004
- Data Flows: resolve_mdoc_device_keys -> KeyRecord lookup -> BIP-32 derivation
Preconditions: Attacker obtains or forges a super-admin AuthClaims token, At least one mdoc device key exists with context_id == None
Existing Controls: require_super_admin() check exists as a gate (not absent) • require_context() enforced for context-bound keys
Recommended Mitigations: Scope super-admin key access with per-key audit logging and step-up authentication • Avoid provisioning production device keys without a context_id where possible • Add explicit break-glass workflow with time-bound elevated tokens for unscoped key access
⚪ STRIDE-4: Discarded Consent Record Handle in mdoc Presentation Path (let _ = &consent)
| Field | Detail |
|---|---|
| Category | Repudiation, Tampering |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:L |
| Residual Severity | Low |
| CWE | CWE-778,CWE-354 |
| CAPEC | CAPEC-268 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: present_matched_set in credential_exchange.rs allows loss of traceable consent-to-presentation linkage for mdoc presentations due to discarding the consent record identifier with let _ = &consent instead of threading it into present_mdoc, resulting in a repudiation gap where an mdoc DeviceResponse cannot be cryptographically tied back to its consent receipt.
Evidence: vta-service/src/operations/credential_exchange.rs:~815-825
let consent = consent::create(vault, &ConsentGrant { holder: HolderIdentity::DeviceKey(&keys.device_did), ... }, &keys.consent_secret).await?;
let _ = &consent;
Attack Scenario:
- In
present_matched_set, for the MsoMdoc branch,consent::create(...)is called and its result stored inconsent(vta-service/src/operations/credential_exchange.rs). - The very next line is
let _ = &consent;— the consent record'sidentifieris never passed topresent_mdoc, unlike the non-mdoc branch wherepresent_singlereceives&consent.identifierexplicitly. present_mdocbuilds and signs theDeviceResponsepurely fromstored,disclosed_paths,device_private,session, andnonce— the resulting COSE_Sign1 has no cryptographic or structural reference to the consent record ID that was just created and persisted.- A dispute over 'was this presentation authorized by a valid, non-expired consent record?' cannot be resolved by inspecting the DeviceResponse itself — an auditor must separately correlate timestamps between the vault's consent store and presentation logs, which is fragile and spoofable.
- If consent record storage later fails silently after this point, or a stale consent is reused across requests, there is no cryptographic proof binding a specific consent grant to a specific mdoc presentation, unlike the Data-Integrity/SD-JWT-VC path.
🔎 Threat Clue: Derived from COMP-002 via EP-001, EP-002
- Data Flows: consent::create -> present_mdoc (broken link)
Preconditions: Holder presents an mdoc credential through present_matched_set, Auditor or dispute-resolution process needs to verify consent linkage after the fact
Existing Controls: Consent record is still created and persisted (not skipped entirely) • Non-mdoc formats correctly bind consent.identifier into the presentation
Recommended Mitigations: Embed the consent record identifier into the mdoc DeviceResponse (e.g., as an additional signed status/extension element) or log it correlated with the DeviceResponse hash • Remove the let _ = &consent; discard pattern and enforce compiler-checked usage • Add integration test asserting consent.identifier is retrievable from/alongside every mdoc presentation
⚪ STRIDE-5: Zeroization Order Violation Leaving Ephemeral Seed Copy Unwiped on Error Path
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.9 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-459,CWE-226 |
| CAPEC | CAPEC-37 |
| OWASP | A02:2021 - Cryptographic Failures |
Description: resolve_mdoc_device_keys in holder_keys.rs allows sensitive seed material to persist in process memory longer than necessary due to zeroizing the seed only after BIP-32 derivation succeeds and not on early-return error paths from load_seed_bytes or ExtendedSigningKey::from_seed, resulting in an increased memory-disclosure window for the root seed if the process is compromised via memory scraping/core dump.
Evidence: vta-service/src/operations/holder_keys.rs:~80-88
let mut seed = load_seed_bytes(keys_ks, &**seed_store, record.seed_id).await.map_err(...)?;
let bip32 = ExtendedSigningKey::from_seed(&seed).map_err(...)?;
seed.zeroize();
Attack Scenario:
resolve_mdoc_device_keyscallsload_seed_bytes(...)returningseed: Zeroizing<Vec<u8>>— good practice, but zeroization only guarantees wipe on drop, andseed.zeroize()is called explicitly only after the happy path ofExtendedSigningKey::from_seed(&seed)succeeds.- If
ExtendedSigningKey::from_seed(&seed)returns anErr(malformed seed length, internal HKDF failure, etc.), the function returns early via.map_err(...)?BEFOREseed.zeroize()executes. - Although
seedis aZeroizing<Vec<u8>>and will be wiped on Drop at scope exit, the window between the error and the Drop call is nondeterministic under async runtimes (tokio) where the future may be suspended, cloned into a debug/core dump, or captured by a panic hook before the Drop executes. - An attacker with local memory-inspection capability (e.g., compromised co-tenant process, malicious sidecar, core dump exfiltration after a crash) during this window can recover the raw BIP-32 seed, compromising every key derivable from it — not just the mdoc device key being resolved.
- This is a defense-in-depth gap rather than a guaranteed exploit, but it widens the blast radius from 'device key' compromise to 'entire seed tree' compromise on any derivation failure.
🔎 Threat Clue: Derived from COMP-001 via EP-004
- Data Flows: load_seed_bytes -> ExtendedSigningKey::from_seed -> seed.zeroize()
Preconditions: Local or process-memory access (core dump, debugger, compromised co-tenant) coincident with a derivation failure, from_seed must actually fail, which requires a malformed/corrupted seed record
Existing Controls: Zeroizing wrapper used for seed and device_private (guarantees eventual wipe on Drop) • zeroize() called explicitly in the happy path
Recommended Mitigations: Wrap derivation in a scope guard / defer pattern that zeroizes on all exit paths including Err • Use Zeroizing consistently and avoid .map_err(...)? before explicit early-wipe on failure paths • Disable core dumps / enable memory encryption for processes handling seed material
⚪ STRIDE-6: HolderIdentity::did() Empty-String Validation Bypass via Whitespace-Only DID
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 4.2 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-20 |
| CAPEC | CAPEC-267 |
| OWASP | A03:2021 - Injection |
Description: consent::create in vta-vault/src/consent.rs allows creation of consent records with semantically invalid data_subject fields due to only checking .trim().is_empty() on grant.holder.did() and not validating did:key format, resulting in acceptance of malformed or non-DID strings as the consent receipt's dpv:hasDataSubject.
Evidence: vta-vault/src/consent.rs:~351-357
if grant.holder.did().trim().is_empty() {
return Err(AppError::Validation("consent holder identity must resolve to a non-empty did:key".into()));
}
Attack Scenario:
HolderIdentity::Subject(&'a str)orHolderIdentity::DeviceKey(&'a str)can wrap any string, including one that is non-empty but not a validdid:key(e.g., a random UUID, an HTML fragment, or a string with embedded control characters).consent::createvalidates onlygrant.holder.did().trim().is_empty()(vta-vault/src/consent.rs) — no regex or DID-syntax validation is performed at this boundary.- The malformed string is written verbatim into
data_subject: grant.holder.did().to_string()in the persistedConsentRecord. - Downstream,
ConsentRecord::verify_proofis documented to bind the proof'sverificationMethodtodataSubject, but ifdid()returns a non-DID string, this comparison may fail in an unexpected way (denial) or, in the worst case, could be exploited to smuggle unexpected characters into records consumed by other tooling (e.g., report generators, DID resolvers) expecting strict DID syntax. - Because this crosses a trust boundary between the presenting VTA (which controls the string internally in intended use, but the type accepts arbitrary &str) and any external consumer of the persisted DPV record, malformed values could propagate into audit logs or compliance reports without a syntax gate catching them early.
🔎 Threat Clue: Derived from COMP-002 via EP-001
- Data Flows: ConsentGrant.holder -> consent::create -> ConsentRecord.data_subject
Preconditions: Caller passes a non-empty but syntactically invalid string into HolderIdentity::Subject/DeviceKey, No upstream DID-format validation exists before this call
Existing Controls: Empty-string check present • did() is a simple pass-through, but callers are internal (resolve_holder_keys / resolve_mdoc_device_keys) that construct DIDs via format!("did:key:{}", ...), reducing likelihood in current call sites
Recommended Mitigations: Add DID-syntax validation (did:key: prefix + multibase check) in consent::create • Make HolderIdentity constructors fallible / validate at construction time rather than at create() time
⚪ STRIDE-7: mdoc doctype Trust-on-First-Store via stored.types.first() Without Signed-MSO Cross-Check
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 6.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-345,CWE-706 |
| CAPEC | CAPEC-141 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: candidate_from_stored in credential_exchange.rs allows DCQL doctype-qualified query matching to trust unsigned metadata due to reading doctype from stored.types.first() (an application-set field) rather than the docType embedded in the cryptographically signed MSO, resulting in potential mismatch between the credential a verifier believes it is querying and the credential actually presented if the types field and the MSO's actual docType ever diverge.
Evidence: vta-service/src/operations/credential_exchange.rs:~505-512
let doctype = match stored.format {
CredentialFormat::MsoMdoc => stored.types.first().cloned(),
_ => None,
};
Attack Scenario:
- At credential receive time (code referenced but not shown,
receive_mdoc), the docType is extracted from the signed MSO and written intoStoredCredential.types[0]— this PR's diff comment states 'receive_mdoc stores the MSO's docType as the credential's only type'. - In
candidate_from_stored(vta-service/src/operations/credential_exchange.rs),let doctype = match stored.format { CredentialFormat::MsoMdoc => stored.types.first().cloned(), ... }reads this application-level field directly rather than re-parsingissued.namespaces/MSO doctype from the freshly-decodedIssuerSignedstructure earlier in the same function. - If any code path (migration script, admin API, storage bug, or a future PR) allows
StoredCredential.typesto be mutated independently of the underlying signed CBOR body — e.g. an admin correction tool, a bulk migration, or a bug in a future format-conversion utility — thedoctypeused for DCQL matching (meta.doctype_value) would silently diverge from the credential's actual signed docType. - A verifier issuing a DCQL query for
doctype_value: "eu.europa.ec.eudi.pid.1"could then be matched against and receive a presentation of a credential whose signed MSO docType is something else entirely, because the matching step never re-validatesstored.types.first()againstissued.namespaces/decoded docType inside the same function. - This is a stored-vs-signed data consistency gap: the verifier's DCQL doctype filter is enforced against mutable metadata, not against the tamper-evident signed content, weakening the guarantee that 'doctype-qualified queries only match the intended document type'.
🔎 Threat Clue: Derived from COMP-002 via EP-003
- Data Flows: StoredCredential.types -> candidate_from_stored -> DCQL doctype match
Preconditions: StoredCredential.types can become desynchronized from the signed MSO's actual docType (via bug, migration, or insider tooling), A verifier relies on doctype_value filtering to distinguish credential types with different trust/assurance levels
Existing Controls: stored.body is separately decoded via IssuerSigned::from_cbor_bytes and used for claims — the claims themselves ARE signed-derived • Comment-documented invariant that receive_mdoc sets types[0] from the signed MSO at receive time
Recommended Mitigations: Re-derive doctype directly from the decoded issued MSO structure inside candidate_from_stored instead of trusting stored.types • Add an invariant check/assertion comparing stored.types.first() against the decoded MSO docType and reject mismatches • Add migration-safety tests ensuring types field cannot diverge from signed body
⚪ STRIDE-8: Downgraded transitive dependency versions in Cargo.lock (socket2, windows-sys, syn) reducing patched-vulnerability coverage
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-1104 |
| CAPEC | CAPEC-437 |
| OWASP | A06:2021 - Vulnerable and Outdated Components |
Description: Cargo.lock in the build pipeline allows reintroduction of superseded transitive dependency versions due to the diff pinning socket2 0.6.5→0.5.10, windows-sys 0.61.2→0.52.0, and syn 3.0.3→1.0.109 without an accompanying Cargo.toml constraint change, resulting in the build consuming older dependency trees that may lack subsequent upstream security fixes.
Evidence: Cargo.lock:multiple
- "socket2 0.6.5",
+ "socket2 0.5.10",
- "windows-sys 0.61.2",
+ "windows-sys 0.52.0",
- "syn 3.0.3",
+ "syn 1.0.109",
Attack Scenario:
- The diff shows Cargo.lock entries for
socket2,windows-sys, andsynbeing downgraded to older versions (0.5.10, 0.52.0, 1.0.109 respectively) alongside the legitimate affinidi-mdoc 0.2.6→0.2.7 upgrade. - No corresponding version constraint change appears in Cargo.toml for these three crates, suggesting the lockfile downgrade is either an artifact of dependency resolution changes triggered by the affinidi-mdoc bump, or an unreviewed manual edit.
- If merged,
cargo build/cargo testin CI will fetch and compile the older, pinned versions rather than the newer ones previously locked, silently reverting any security or correctness fixes shipped between those version ranges. - A downstream consumer relying on
cargo audit/Dependabot alerts tuned to the newer versions could see false negatives if the audit tooling is not re-run against the new lockfile, delaying detection of any known CVEs affecting the older pinned versions. - This is primarily a supply-chain hygiene/process gap: an unreviewed or automated lockfile downgrade merged via PR without explicit justification in the diff/commit message.
🔎 Threat Clue: Derived from COMP-005
- Data Flows: Cargo.toml -> cargo resolve -> Cargo.lock -> CI build
Preconditions: PR is merged without lockfile diff review, Downgraded versions have a subsequently-disclosed vulnerability not present in the newer versions
Existing Controls: Cargo.lock changes are visible in the diff and subject to code review • affinidi-mdoc's own version bump is intentional and documented in comments
Recommended Mitigations: Require CI to flag unexplained transitive-dependency downgrades in Cargo.lock diffs • Re-run cargo audit/cargo deny after every lockfile change • Document rationale for any intentional pin changes in the PR description
⚪ STRIDE-9: Nonce Reuse Across Multiple Credential Presentations in Single vp_token Batch
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.4 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-330,CWE-294 |
| CAPEC | CAPEC-60 |
| OWASP | A02:2021 - Cryptographic Failures |
Description: present_matched_set in credential_exchange.rs allows the same query.nonce to be reused across every matched credential's presentation (both present_single and present_mdoc) due to passing &query.nonce identically into each per-credential signing call rather than deriving a per-credential nonce, resulting in weaker replay-protection granularity across a multi-credential batch presentation.
Evidence: vta-service/src/operations/credential_exchange.rs:~900-960
present_mdoc(&stored, &m.disclosed_paths, &keys.device_private, session, &query.nonce).await?
Attack Scenario:
present_matched_setiteratesmatchedcredentials in a loop; for each, it calls eitherpresent_single(..., &query.nonce, ...)orpresent_mdoc(..., &query.nonce)— the identical&query.noncereference for every credential in the batch (vta-service/src/operations/credential_exchange.rs).- This matches the OID4VP/DCQL spec intent (one nonce per authorization request), but combined with per-credential fresh consent (
valid_until = now + 5min) and per-credential signing keys, an attacker who can capture one signed artifact from the batch and manipulates transport-layer batching/reordering could attempt to replay an individual credential's signed payload into a different concurrent session that happens to reuse the same nonce value if nonce generation upstream (not shown) is weak or predictable. - Because
present_mdoc's SessionTranscript andpresent_single's proof challenge both bind to this shared nonce, if the OID4VP layer ever allows nonce reuse across distinct authorization requests (e.g., a buggy verifier or a caching proxy), signed artifacts from one legitimate session could be replayed into another session sharing the same nonce value, since nothing in this code additionally binds the signature to a per-request unique session identifier beyond the nonce itself. - This is a design-level replay-protection concern rather than a directly exploitable bug in this diff, contingent on upstream nonce-generation weaknesses not visible in the provided files.
🔎 Threat Clue: Derived from COMP-002 via EP-001
- Data Flows: QueryBody.nonce -> present_single/present_mdoc (shared)
Preconditions: Upstream nonce generation for QueryBody.nonce is weak, predictable, or reused across distinct authorization requests, Attacker can intercept and replay signed presentation artifacts into a colliding-nonce session
Existing Controls: Nonce is required (non-optional in QueryBody) • Consent records have short (5 minute) validity windows limiting replay window
Recommended Mitigations: Ensure nonce generation upstream uses a cryptographically secure RNG with sufficient entropy and request-scoped uniqueness • Bind signed presentations to a request-scoped session identifier in addition to the nonce • Add nonce-reuse detection/rejection at the verifier or VTA session layer
⚪ STRIDE-10: No Response-Size or Iteration Limit on disclosed_paths Grouping in present_mdoc BTreeMap Construction
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 3.7 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-770,CWE-400 |
| CAPEC | CAPEC-130 |
| OWASP | A04:2021 - Insecure Design |
Description: present_mdoc in credential_exchange.rs allows unbounded memory allocation for the requested BTreeMap due to iterating disclosed_paths from m.disclosed_paths (derived from DCQL query matching) without an upper bound on the number of namespace/element entries, resulting in a potential resource-exhaustion vector if an authenticated caller can submit a query with an extremely large claims/path array.
Evidence: vta-service/src/operations/credential_exchange.rs:~690-706
for path in disclosed_paths {
if let [namespace, element] = path.as_slice() {
requested.entry(namespace.clone()).or_default().push(element.clone());
}
}
Attack Scenario:
present_mdocbuildsrequested: BTreeMap<String, Vec<String>>by iterating every entry indisclosed_paths(vta-service/src/operations/credential_exchange.rs), which is derived fromm.disclosed_pathsset during DCQL matching against the verifier's query.- An authenticated party able to submit or influence a DCQL query with an extremely large
claimsarray (thousands of [namespace, element] pairs) could causedisclosed_pathsto grow proportionally, since no explicit upper bound is enforced in this function or visible inmatch_held/gather_for_query. - Each entry triggers a
Stringclone andVecpush into the BTreeMap, and the subsequentaffinidi_mdoc::DeviceResponse::create_with_device_authcall processes the full requested set against the mdoc's namespaces. - Repeated requests with maximal claims arrays against a shared VTA service instance could degrade presentation-service latency/memory for concurrent legitimate holders, particularly if this endpoint is not independently rate-limited.
- Impact is bounded by DCQL query validation elsewhere (
query.validate()in match_held) which may already cap array sizes — this threat is contingent on the absence of such caps for theclaimspath array specifically.
🔎 Threat Clue: Derived from COMP-002 via EP-001
- Data Flows: m.disclosed_paths -> present_mdoc -> BTreeMap
Preconditions: query.validate() or DCQL library-level constraints do not cap the number of claim paths, Endpoint lacks independent rate-limiting or request-size limits
Existing Controls: query.validate() call exists in match_held (unknown internal limits) • Authentication (AuthClaims) required to reach the presentation path
Recommended Mitigations: Enforce an explicit maximum on disclosed_paths length before constructing the BTreeMap • Add request-size and rate limits at the API gateway/ingress layer • Add fuzz/load testing for present_mdoc with adversarially large claim path arrays
⚪ STRIDE-11: Missing Explicit Rejection of Malformed [namespace, element] Paths Beyond Silent Skip in present_mdoc
| Field | Detail |
|---|---|
| Category | Tampering, Repudiation |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-354,CWE-693 |
| CAPEC | CAPEC-267 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: present_mdoc in credential_exchange.rs allows silent dropping of malformed claim paths due to the if let [namespace, element] = path.as_slice() pattern discarding any path that is not exactly length 2 without logging or erroring per-path, resulting in a holder unknowingly under-disclosing claims relative to what the verifier's query and the holder's consent actually specified.
Evidence: vta-service/src/operations/credential_exchange.rs:~692-698 and ~800-805
let claims: Vec<String> = m.disclosed_paths.iter().filter_map(|path| path.last().cloned()).collect();
...
if let [namespace, element] = path.as_slice() { ... }
Attack Scenario:
disclosed_paths: &[Vec<String>]inpresent_mdocmay contain paths of unexpected length (e.g., due to a bug in upstream DCQL path resolution, or a crafted query producing paths of length 1 or 3+).- The
if let [namespace, element] = path.as_slice() { ... }pattern only inserts intorequestedwhen the path has exactly 2 segments; any other length silently falls through with noelsebranch, no log, and no error. - If
requestedends up with fewer entries than the holder's consent record'sclaimslist implies (the consent was created based onm.disclosed_paths.iter().filter_map(|path| path.last().cloned()), which succeeds even for malformed paths since.last()works on any non-empty Vec), a mismatch is created: the consent receipt lists claims that were never actually included in the DeviceResponse. - This produces a subtle repudiation/integrity gap — the consent record (signed, persisted) may assert disclosure of a claim that the actual mdoc DeviceResponse never contains, because the claims list for consent is derived independently (via
.last()) from the same-shaped exactness check used for the BTreeMap build. - An auditor comparing the consent receipt's
claimsfield against the actual disclosed DeviceResponse content could find a discrepancy with no error trail explaining why, undermining confidence in the consent-to-disclosure guarantee documented as the core protection invta-vault/src/consent.rs.
🔎 Threat Clue: Derived from COMP-002 via EP-001
- Data Flows: m.disclosed_paths -> claims(consent) / requested(present_mdoc) divergence
Preconditions: Upstream DCQL path resolution ever produces a claim path with length != 2 for an mdoc query, Only exploitable-in-effect if such malformed paths can be legitimately produced (bug-dependent)
Existing Controls: The length-2 pattern match rejects malformed paths from being processed into the actual disclosure • Empty requested map after filtering triggers an explicit Validation error
Recommended Mitigations: Add explicit error/logging when a disclosed_paths entry does not match [namespace, element] shape instead of silent skip • Derive the consent record's claims list from the same validated/filtered path set used to build the actual DeviceResponse disclosure, not from a separately-computed .last() projection • Add integration test asserting consent.claims always equals the actual disclosed element set for mdoc
🍝 PASTA Threat Model
Application Purpose
A verifiable-credential trust infrastructure (VTA/VTC) enabling holders to receive, store, and present W3C Data-Integrity, SD-JWT-VC, and now ISO 18013-5/-7 mdoc credentials to verifiers via OID4VP/DCQL, with per-presentation consent receipts, to support digital identity ecosystems such as eIDAS/EUDI wallets.
Inherent Risks
- Cryptographic key material (BIP-32 seeds, device keys) is a single point of catastrophic compromise across all derived credentials.
- Multi-format credential presentation logic increases the attack surface for format-confusion and format-specific binding weaknesses.
- Consent receipts are legally significant (DPV/GDPR-style) artifacts whose integrity depends on tight coupling to the actual disclosure event.
- New protocol integrations (mdoc/OID4VP session binding) are inherently high-risk during initial rollout due to complex, spec-derived invariants.
Objectives
Risk: Treat unscoped (context_id=None) key access as a high-risk operation requiring elevated scrutiny; Treat any verifier-supplied session parameter as untrusted input requiring validation before cryptographic use
Business: Enable EUDI-wallet-compliant mdoc presentation to expand verifier interoperability; Maintain holder trust through provable, auditable consent for every disclosure
Security: Bind every credential presentation's holder-binding signature to an authenticated verifier session; Ensure consent receipts are cryptographically and traceably linked to the exact disclosure they authorize; Minimize the lifetime and exposure window of derived key material in memory
Financial: Avoid regulatory fines from GDPR/eIDAS non-compliance in consent handling; Avoid incident-response costs from key-compromise events
Compliance: Satisfy ISO 18013-5/-7 mdoc holder-binding requirements; Satisfy DPV/GDPR-aligned consent-receipt evidentiary requirements
Functional: Support DCQL matching and OID4VP presentation across Data-Integrity, SD-JWT-VC, and ISO mdoc formats; Ensure matched-but-unpresentable credentials never silently break batch presentations
Operational: Maintain ACL-gated key derivation with per-context isolation; Keep dependency supply chain (Cargo.lock) auditable and free of unreviewed downgrades
Business Impact Analysis (4)
BIA-1: Credential Presentation (OID4VP/DCQL vp_token Generation) (Critical)
The end-to-end flow where a verifier submits a DCQL query, the VTA matches held credentials, resolves ACL-gated holder/device keys, records consent, and returns a signed vp_token to the verifier.
MTD: 00 days 04:00 hours | RTO: 00 days 01:00 hours | RPO: 00 days 00:15 hours
- Stakeholders: Compliance Officers / Holders / Trust Infrastructure Operators / Verifiers
- Dependencies: Affinidi mdoc/OID4VP/Data-Integrity libraries / AuthClaims authentication service / Key derivation (BIP-32/SLIP-10) subsystem / Vault key-value store
- Disruptions: Malformed or malicious Oid4vpSession causing batch presentation failure / Key derivation subsystem outage preventing signing / Vault store unavailability preventing consent persistence
- Impacts: Verifiers unable to onboard/verify holders, halting business processes reliant on credential checks / Legal exposure if a presentation occurs without a valid, traceable consent receipt / Reputational damage from a publicized holder-binding forgery incident
BIA-2: Holder Key and Device Key Resolution (ACL-Gated Signing Material Access) (Critical)
The privileged subsystem that resolves BIP-32-derived signing keys for a given holder DID or mdoc device key, gated by AuthClaims context/super-admin checks.
MTD: 00 days 01:00 hours | RTO: 00 days 00:30 hours | RPO: 00 days 00:05 hours
- Stakeholders: Security/SRE Team / Trust Infrastructure Operators
- Dependencies: SeedStore / BIP-32/SLIP-10 derivation library / AuthClaims authorization model
- Disruptions: Super-admin token compromise enabling unscoped key access / Seed material exposure via memory scraping during derivation failure
- Impacts: Total compromise of every credential derivable from an exposed root seed / Impersonation of holders in downstream verifier interactions
BIA-3: Consent Receipt Issuance and Audit Trail (High)
Construction, signing, and persistence of DPV-conformant consent records that authorize a specific disclosure of a specific credential's claims to a specific verifier.
MTD: 01 days 00:00 hours | RTO: 00 days 08:00 hours | RPO: 00 days 01:00 hours
- Stakeholders: Compliance Officers / Holders / Legal/DPO Team
- Dependencies: Vault consent store / Holder/device signing keys / ConsentRecord verify_proof logic
- Disruptions: Consent record identifier not bound to the actual mdoc DeviceResponse (STRIDE-4) / Claims list divergence between consent record and actual disclosed content (STRIDE-11)
- Impacts: Inability to prove in a dispute exactly what was disclosed under what consent / Regulatory penalty exposure for unverifiable disclosure records
BIA-4: Build and Dependency Supply Chain Integrity (Medium)
The process by which Cargo.toml/Cargo.lock changes are reviewed and merged, ensuring the compiled artifact only includes vetted, up-to-date dependency versions.
MTD: 07 days 00:00 hours | RTO: 01 days 00:00 hours | RPO: 01 days 00:00 hours
- Stakeholders: DevOps/CI Team / Security/SRE Team
- Dependencies: crates.io registry / CI pipeline (cargo build/test/audit)
- Disruptions: Unreviewed transitive dependency downgrade reintroducing a previously-patched vulnerability
- Impacts: Delayed vulnerability remediation / False sense of security from stale audit baselines
Technical Scope
Roles (4): RO-1 Super Admin · RO-2 Context-Scoped Operator · RO-3 Verifier · RO-4 Holder
Actors (4): AC-1 VTA Service Process · AC-2 Verifier Client · AC-3 Holder Device/Plugin · AC-4 CI/Build Pipeline
Use Cases (3): Verifier-Initiated Credential Presentation · ACL-Gated mdoc Device Key Resolution · VTC Join-Request Presentation Preparation
Attack Trees (4): SC-1: credential_exchange Operations Module · SC-2: holder_keys Operations Module · SC-3: vta-vault consent Module · SC-7: Cargo Dependency Graph / Build Pipeline
Entry Points (6): EP-001 present_query · EP-002 approve_pending_presentation · EP-003 match_vault / match_held · EP-004 resolve_mdoc_device_keys · EP-005 prepare_join_query · EP-006 QueryBody.oid4vp_session deserialization
Risk Registry (7): RISK-001 · RISK-002 · RISK-003 · RISK-004 · RISK-005 · RISK-006 · RISK-007
Threat Actors (4): TA-1 Malicious/Compromised Verifier · TA-2 Insider with Super-Admin Credentials · TA-3 Local Memory-Access Attacker · TA-4 Supply Chain Manipulator
Infrastructure (2): IF-1 VTA Service Deployment · IF-2 CI Build Runners
Trust Boundaries (4): TB-1 External Verifier Boundary · TB-2 VTC Join-Request Boundary · TB-3 Internal Key Management Boundary · TB-4 Vault Storage Boundary
External Entities (3): EE-1 OID4VP Verifier · EE-2 VTC Join-Request Client · EE-3 Crates.io Registry
System Components (7): SC-1 credential_exchange Operations Module · SC-2 holder_keys Operations Module · SC-3 vta-vault consent Module · SC-4 vta-vault present Module · SC-5 vtc-service join_requests present Route · SC-6 Vault Keyspace Store · SC-7 Cargo Dependency Graph / Build Pipeline
Resources And Assets (5): RA-1 BIP-32/SLIP-10 Root Seed · RA-2 mdoc Device Private Key · RA-3 Stored Credentials (mdoc/SD-JWT-VC/Data-Integrity) · RA-4 Consent Records (DPV ConsentRecord) · RA-5 Oid4vpSession Fields (client_id, response_uri, mdoc_generated_nonce)
Technologies And Dependencies (5): TD-1 affinidi-mdoc · TD-2 affinidi-openid4vp · TD-3 affinidi-secrets-resolver · TD-4 zeroize · TD-5 socket2
⚔️ Attack Scenarios (1)
Exploit identified weaknesses
flowchart LR
S0["Verifier-Controlled Session Transcript Forgery in Oid4vpSess"]
S1["mdoc Candidate Admission Bypass via can_bind_mdoc Boolean Ga"]
S2["ACL Context Bypass in resolve_mdoc_device_keys Super-Admin F"]
S0 --> S1
S1 --> S2
📊 Risk Summary
Total Threats: 11
By Severity: Low: 4 · High: 2 · Medium: 5
By Category: Unknown: 11
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
🔧 What to do
| # | Action |
|---|---|
| 1 | 📥 Download attached reports and review the findings and threat model |
| 2 | 🤖 Feed reports to your IDE copilot for fixes or security hardening suggestions |
| 3 | 🛡️ Review threat model for potential risks and recommended countermeasures |
| 4 | 🆘 Questions? Reach out to the Security team |
🛡️ Agentic Sec — AI Security Validation Agent
Why
A VTA could receive, verify and store an mdoc. It could not present one.
This is the last piece — and it needed three things no other format does.
1. An OID4VP session on the query wire
An mdoc's holder binding is a
DeviceAuthsignature over an ISO 18013-7SessionTranscript, whose handover is[clientId, responseUri, nonce, mdocGeneratedNonce]. Two of those exist onlyin an OID4VP exchange — a Trust-Task envelope has no
response_uri.So
QueryBodygains an optionaloid4vp_session, carrying OID4VP's own fieldnames (not this workspace's camelCase) so a verifier copies them straight out of
its authorization request.
Absent, an mdoc is not offered at all — not offered unbound. A
DeviceAuthover invented handover values verifies nowhere and, worse, looks like holder
binding.
The gate lives in
match_held, not in the present path, so matchable andpresentable stay the same set. A matched-but-unpresentable credential bails
the entire
vp_token, taking every other credential the verifier legitimatelyasked for. Mutation-checked: removing the gate fails
an_mdoc_does_not_match_without_an_oid4vp_session.2. Holder identity that is key-shaped
ConsentGrant.holder_did→HolderIdentity::{Subject, DeviceKey}.Every other format names a subject DID. An mdoc names a device key,
discovered at receive (#990). Both variants still resolve to a
did:key,because
ConsentRecord::verify_proofbinds the proof'sverificationMethodtothe data subject — a bare key id there would make every receipt unverifiable.
The variant records provenance that would otherwise be silently lost, rather
than a different kind of value.
3. A P-256 consent receipt
The device key signs its own receipt under
ecdsa-jcs-2019(affinidi-data-integrity 0.7.10),
where every other format uses
eddsa-jcs-2022.This is why that suite was added upstream rather than worked around: signing the
receipt with some other key would break the
verificationMethodbindingabove, and there was no ECDSA Data Integrity suite in the stack at all.
Presentation shape
present_mdocsits besidepresent_single, not inside it — an mdocvp_tokenentry is base64url CBOR of aDeviceResponse, not a W3C VP object.Selective disclosure is by omission: only the
[namespace, element]paths thequery asked for are included.
Breaking changes
At 0.x, and release-plz moves the compatibility fields:
ConsentGrant.holder_did→ConsentGrant.holdermatch_vaulttakes the session;match_heldtakescan_bind_mdocQueryBodygains a fieldTesting
vta-service819 passed,vta-vault104 passed. fmt + clippy clean, zerowarnings across
--all-targets.New tests:
must match with one (otherwise the gate is just breaking mdoc support)
doctypepopulated. DCQL addresses mdoc claims as
[namespace, elementIdentifier]; aflat tree matches nothing and the failure reads as "the holder doesn't have
it"
Mutation-checked: removing the session gate fails the gate test.
Where this leaves mdoc
Receive → verify against configured IACA roots → refuse an unpresentable one →
match by doctype → present with holder binding. Both eIDAS-mandated credential
formats are now supported end to end.