Skip to content

feat(vta-service)!: present ISO mdoc credentials over OID4VP - #993

Merged
stormer78 merged 2 commits into
mainfrom
feat/mdoc-present
Aug 16, 2026
Merged

feat(vta-service)!: present ISO mdoc credentials over OID4VP#993
stormer78 merged 2 commits into
mainfrom
feat/mdoc-present

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

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 DeviceAuth signature over an ISO 18013-7
SessionTranscript, whose handover is
[clientId, responseUri, nonce, mdocGeneratedNonce]. Two of those exist only
in an OID4VP exchange
— a Trust-Task envelope has no response_uri.

So QueryBody gains an optional oid4vp_session, carrying OID4VP's own field
names (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 DeviceAuth
over invented handover values verifies nowhere and, worse, looks like holder
binding.

The gate lives in match_held, not in the present path, so matchable and
presentable stay the same set
. A matched-but-unpresentable credential bails
the entire vp_token, taking every other credential the verifier legitimately
asked 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_didHolderIdentity::{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_proof binds the proof's verificationMethod to
the 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 verificationMethod binding
above, and there was no ECDSA Data Integrity suite in the stack at all.

Presentation shape

present_mdoc sits beside present_single, not inside it — an mdoc
vp_token entry is base64url CBOR of a DeviceResponse, not a W3C VP object.
Selective disclosure is by omission: only the [namespace, element] paths the
query asked for are included.

Breaking changes

At 0.x, and release-plz moves the compatibility fields:

  • ConsentGrant.holder_didConsentGrant.holder
  • match_vault takes the session; match_held takes can_bind_mdoc
  • QueryBody gains a field

Testing

vta-service 819 passed, vta-vault 104 passed. fmt + clippy clean, zero
warnings across --all-targets.

New tests:

  • the gate, both directions — an mdoc must not match without a session, and
    must
    match with one (otherwise the gate is just breaking mdoc support)
  • candidate shape — claims keyed by namespace, not flat, plus doctype
    populated. DCQL addresses mdoc claims as [namespace, elementIdentifier]; a
    flat 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.

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>
@stormer78
stormer78 requested a review from a team as a code owner August 16, 2026 13:46
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>
@stormer78
stormer78 merged commit 23a2d11 into main Aug 16, 2026
14 of 15 checks passed
@stormer78
stormer78 deleted the feat/mdoc-present branch August 16, 2026 14:30
@stormer78 stormer78 mentioned this pull request Aug 16, 2026
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🛡️ AI Agentic Security Review

⚠️ Security Report — 2 confirmed issues

PR #993verifiable-trust-infrastructure • Review the attached reports for details and recommended actions.


🤖 AI-Generated — This review validates findings against source code.
Remediation suggestions should be tested before applying. Engineers own the final implementation.
When in doubt, consult the Security team.


🎯 Scope: changes only. This review covers only the code introduced by this MR/PR's diff, so a clean result means "no new issues" — not "no issues at all." Whole-codebase coverage is handled by the scheduled repository scans.

📊 Summary

Severity Issues
🟠 High 1
🔵 Low 1
Total Confirmed 2
⚠️ Must-Review-By-Human 3

⚠️ 3 finding(s) need human review — the automated validation was inconclusive (insufficient evidence). These are not dismissed; please have a developer / the Security team read and decide.


⚠️ Must-Review-By-Human (3) — click to collapse
  • 🟠 Verifier-supplied OID4VP session fields used unvalidated in mdoc DeviceAuth SessionTranscript — 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).
  • 🟡 Consent record identifier discarded for mdoc presentations, breaking consent-to-presentation binding — 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 `&conse…
  • 🟡 mdoc candidate admission relies solely on Option::is_some(), not structural session validity, risking multi-credential vp_token DoS — 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…

These were validated up to a point but need a human to make the final call.


📎 Reports

🔒 Security Validation Report (mandatory review — confirmed, materialised security issues)

📄 Open full Security Validation Report — validation_report_PR993_2026-08-16T15-08-35.md

🛡️ Security Validation Report — PR #993

Field Value
Repository OpenVTC/verifiable-trust-infrastructure
Branch feat/mdoc-presentmain
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 explicit seed.zeroize() call that follows. EVIDENCE NOT FOUND: No scope-guard, defer, or Drop-based immediate wipe mechanism visible beyond Zeroizing<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.identifier into 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 from session.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-presentmain
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:

  1. A malicious or compromised verifier sends a QueryBody with an oid4vp_session object where client_id and response_uri are attacker-controlled values not validated against any registered verifier identity (vta-sdk/src/protocols/credential_exchange.rs, Oid4vpSession struct).
  2. present_query (vta-service/src/operations/credential_exchange.rs) passes query.oid4vp_session.as_ref() straight into match_vault, which only checks session.is_some() via can_bind_mdoc — it never validates the session's contents against the actual verifier_did or any expected redirect target.
  3. present_mdoc builds SessionTranscript::new_oid4vp(&session.client_id, &session.response_uri, nonce, &session.mdoc_generated_nonce) using these unvalidated fields verbatim.
  4. 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.
  5. 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_did or 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:

  1. match_vault computes can_bind_mdoc purely from session.is_some() (vta-service/src/operations/credential_exchange.rs, match_vault function), not from whether the session is actually usable/well-formed.
  2. A verifier or code path can supply an Oid4vpSession with empty or malformed client_id/response_uri strings that still satisfy Option::is_some().
  3. match_held admits the mdoc candidate since can_bind_mdoc is true, and the mdoc is now bundled into the matched set alongside other credentials the verifier legitimately requested.
  4. present_matched_set later calls present_mdoc, which may still succeed (producing a transcript with empty/garbage values) or fail with an AppError.
  5. If present_mdoc fails, the error propagates and bails the entire present_matched_set operation (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:

  1. 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.
  2. resolve_mdoc_device_keys (vta-service/src/operations/holder_keys.rs) executes None => auth.require_super_admin()? as the sole gate for such keys.
  3. 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_keys for ANY unscoped device key across the entire VTA instance, not just keys belonging to their own tenant/context.
  4. The attacker obtains device_private (Zeroizing<Vec>) and consent_secret for that key, enabling them to sign arbitrary DeviceAuth presentations and consent receipts impersonating the legitimate holder.
  5. 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:

  1. In present_matched_set, for the MsoMdoc branch, consent::create(...) is called and its result stored in consent (vta-service/src/operations/credential_exchange.rs).
  2. The very next line is let _ = &consent; — the consent record's identifier is never passed to present_mdoc, unlike the non-mdoc branch where present_single receives &consent.identifier explicitly.
  3. present_mdoc builds and signs the DeviceResponse purely from stored, disclosed_paths, device_private, session, and nonce — the resulting COSE_Sign1 has no cryptographic or structural reference to the consent record ID that was just created and persisted.
  4. 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.
  5. 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:

  1. resolve_mdoc_device_keys calls load_seed_bytes(...) returning seed: Zeroizing<Vec<u8>> — good practice, but zeroization only guarantees wipe on drop, and seed.zeroize() is called explicitly only after the happy path of ExtendedSigningKey::from_seed(&seed) succeeds.
  2. If ExtendedSigningKey::from_seed(&seed) returns an Err (malformed seed length, internal HKDF failure, etc.), the function returns early via .map_err(...)? BEFORE seed.zeroize() executes.
  3. Although seed is a Zeroizing<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.
  4. 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.
  5. 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:

  1. HolderIdentity::Subject(&'a str) or HolderIdentity::DeviceKey(&'a str) can wrap any string, including one that is non-empty but not a valid did:key (e.g., a random UUID, an HTML fragment, or a string with embedded control characters).
  2. consent::create validates only grant.holder.did().trim().is_empty() (vta-vault/src/consent.rs) — no regex or DID-syntax validation is performed at this boundary.
  3. The malformed string is written verbatim into data_subject: grant.holder.did().to_string() in the persisted ConsentRecord.
  4. Downstream, ConsentRecord::verify_proof is documented to bind the proof's verificationMethod to dataSubject, but if did() 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.
  5. 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:

  1. At credential receive time (code referenced but not shown, receive_mdoc), the docType is extracted from the signed MSO and written into StoredCredential.types[0] — this PR's diff comment states 'receive_mdoc stores the MSO's docType as the credential's only type'.
  2. 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-parsing issued.namespaces/MSO doctype from the freshly-decoded IssuerSigned structure earlier in the same function.
  3. If any code path (migration script, admin API, storage bug, or a future PR) allows StoredCredential.types to 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 — the doctype used for DCQL matching (meta.doctype_value) would silently diverge from the credential's actual signed docType.
  4. 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-validates stored.types.first() against issued.namespaces/decoded docType inside the same function.
  5. 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:

  1. The diff shows Cargo.lock entries for socket2, windows-sys, and syn being 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.
  2. 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.
  3. If merged, cargo build/cargo test in 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.
  4. 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.
  5. 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:

  1. present_matched_set iterates matched credentials in a loop; for each, it calls either present_single(..., &query.nonce, ...) or present_mdoc(..., &query.nonce) — the identical &query.nonce reference for every credential in the batch (vta-service/src/operations/credential_exchange.rs).
  2. 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.
  3. Because present_mdoc's SessionTranscript and present_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.
  4. 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:

  1. present_mdoc builds requested: BTreeMap<String, Vec<String>> by iterating every entry in disclosed_paths (vta-service/src/operations/credential_exchange.rs), which is derived from m.disclosed_paths set during DCQL matching against the verifier's query.
  2. An authenticated party able to submit or influence a DCQL query with an extremely large claims array (thousands of [namespace, element] pairs) could cause disclosed_paths to grow proportionally, since no explicit upper bound is enforced in this function or visible in match_held/gather_for_query.
  3. Each entry triggers a String clone and Vec push into the BTreeMap, and the subsequent affinidi_mdoc::DeviceResponse::create_with_device_auth call processes the full requested set against the mdoc's namespaces.
  4. 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.
  5. 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 the claims path 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:

  1. disclosed_paths: &[Vec<String>] in present_mdoc may 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+).
  2. The if let [namespace, element] = path.as_slice() { ... } pattern only inserts into requested when the path has exactly 2 segments; any other length silently falls through with no else branch, no log, and no error.
  3. If requested ends up with fewer entries than the holder's consent record's claims list implies (the consent was created based on m.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.
  4. 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.
  5. An auditor comparing the consent receipt's claims field 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 in vta-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
Loading

📊 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

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