fix(join)!: require the DTG common structure on an ingested invitation - #256
Conversation
`validate_invitation_credential` is careful and well-documented — it checks the W3C VC Data Model 2.0 mandatory properties and the VIC profile the receiving VTC enforces. It checked neither half of DTG Credentials §Common Structure, which is normative for every DTG credential: - `@context` MUST include `https://firstperson.network/credentials/dtg/v1` - `type` MUST include `DTGCredential` That string appears nowhere else in this repo. It is never required and never checked, because everything openvtc *mints* goes through `dtg-credentials`, which supplies it — so the one place a credential arrives from outside was also the one place nothing verified it was a DTG credential at all. A document with the right subtype tag and neither common-structure element passed as a VIC. The fixtures agreed, which is why nothing noticed. `complete_vic` and `pasteable_vic` both called themselves complete while carrying only the W3C half, and the validator checked only the same half — so the test encoded the implementation's belief about the wire form rather than the specification's definition of it. Both now use the form `new_vic` actually emits, which is what makes the new checks a regression test rather than a restatement: revert the validator and nothing fails; revert the fixtures and the validator catches them. Real VICs are unaffected. The VTC mints them through `DTGCredential::new_vic` and its wire-shape guard pins both contexts and all three type entries, so a credential any community actually issued already satisfies this. The three constants are named in `openvtc-core` rather than spelled inline. `dtg-credentials` builds all of them into what it mints but exports none of them; removing the duplication needs a public constant upstream, requested in OpenVTC/dtg-credentials#10. BREAKING CHANGE: a pasted invitation lacking the DTG context or the `DTGCredential` type is now refused at ingest with a message naming what is missing, rather than accepted and submitted. Refs OpenVTC/verifiable-trust-infrastructure#1064 Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review2 findings need a human to review/validate. Mandatory to check: 🔒 Security Code Review Report Details📄 Open full Security Code Review Report —
|
| Field | Value |
|---|---|
| Repository | OpenVTC/openvtc |
| Branch | fix/vic-dtg-common-structure → main |
| Validated | 2026-08-24 |
| Scan ID | 9d6c5cb4 |
| Validator | AI Security Validation Agent |
🗺️ Scan Coverage
Modules scanned: 2 · with findings: 1 · files: 2 · findings: 2
| Module | Files scanned | Findings |
|---|---|---|
openvtc |
1 | 0 |
openvtc-core |
1 | 2 |
Executive Summary
| Category | Confirmed | Must-Review-By-Human |
|---|---|---|
| Security Issues | 0 | 2 |
⚠️ 2 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
⚠️ Must-Review-By-Human (2)
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.
⚪ Missing Cryptographic Proof/Signature Verification in VIC Validation Pipeline
| Field | Detail |
|---|---|
| Severity | INFORMATIONAL |
| Location | openvtc-core/src/join.rs:441 |
| Finding ID | github_pr-0b3de765d2cf |
| CWE | CWE-347, CWE-290, CWE-345 |
| OWASP | A08:2021 - Software and Data Integrity Failures, A07:2021 - Identification and Authentication Failures |
| MITRE ATT&CK | T1552 - Unsecured Credentials, T1078 - Valid Accounts |
| CAPEC | CAPEC-115, CAPEC-475, CAPEC-196 |
| DREAD | 8 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | poc |
| Detection Source | skill_scan |
Summary: validate_invitation_credential in openvtc-core/src/join.rs accepts any structurally well-formed JSON as a valid Verifiable Invitation Credential without verifying any cryptographic proof or signature. This means an attacker can fabricate a completely fake credential and have it treated as legitimately issued by a community, since nothing binds the claimed issuer/subject/expiry to an actual signing key.
📝 Description:
An attacker can join an OpenVTC community, or reach downstream trust-elevated states in the join_flow state machine, using a completely self-fabricated invitation credential that was never issued by any real community. This undermines the entire trust model of the invitation system — VICs are supposed to prove community endorsement, but currently prove nothing more than JSON well-formedness.
🧪 Proof of Concept:
This is the full body of the validation entry point. Every branch inspects only the shape of attacker-supplied JSON (array membership, string equality, presence of an 'id' field). There is no call to verify a proof block, a JWS/JWT signature, or resolve the issuer's DID document to check a matching key. Any attacker who can produce syntactically correct JSON can satisfy this function.
pub fn validate_invitation_credential(vic: &Value) -> Result<(), String> {
let mut missing: Vec<&str> = Vec::new();
let ctx = vic.get("@context").and_then(Value::as_array);
match ctx {
Some(c) if c.first().and_then(Value::as_str) == Some(W3C_VC_V2_CONTEXT) => {}
_ => missing.push("@context (must be an array whose first item is \"https://www.w3.org/ns/credentials/v2\")"),
}
if !ctx.is_some_and(|c| c.iter().any(|v| v.as_str() == Some(DTG_CONTEXT))) {
missing.push("@context entry \"https://firstperson.network/credentials/dtg/v1\"");
}
if !is_invitation_credential(vic) { /* ... */ }
// ... type/issuer/expiry checks continue, but NO proof/signature verification anywhere ...
if invitation_issuer(vic).is_none() {
missing.push("issuer (a DID string or an object with an `id`)");
}
// function returns Ok(()) if `missing` stays empty — purely structural
}
Vulnerable lines: 441, 482
🔎 Evidence: openvtc-core/src/join.rs:441
pub fn validate_invitation_credential(vic: &Value) -> Result<(), String> {
let mut missing: Vec<&str> = Vec::new();
// ...structural checks only, no proof/signature verification call...
}
💥 Impact:
An attacker can join an OpenVTC community, or reach downstream trust-elevated states in the join_flow state machine, using a completely self-fabricated invitation credential that was never issued by any real community. This undermines the entire trust model of the invitation system — VICs are supposed to prove community endorsement, but currently prove nothing more than JSON well-formedness.
🧭 Reachability:
- Network exposure: internal
- Auth barrier: none
- Attack path: EP-003 (UI paste/load VIC) → join_flow.rs state handler → validate_invitation_credential (EP-001, join.rs:441) → Ok(()) with no signature check
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | high |
| Business impact | critical |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: An attacker fabricates a structurally valid but unsigned VIC JSON and pastes it into the join flow; validate_invitation_credential accepts it because no cryptographic proof is ever verified.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
The fix adds a mandatory, fail-closed cryptographic verification step that resolves the issuer's DID document and validates the credential's proof/signature before any structural check is considered sufficient for acceptance. Structural checks alone can never establish authenticity.
Vulnerable code:
pub fn validate_invitation_credential(vic: &Value) -> Result<(), String> {
let mut missing: Vec<&str> = Vec::new();
// ...structural checks only...
if missing.is_empty() { Ok(()) } else { Err(missing.join(", ")) }
}
Secure code:
pub fn validate_invitation_credential(vic: &Value) -> Result<(), String> {
let mut missing: Vec<&str> = Vec::new();
// ... existing structural checks ...
// REQUIRED: verify cryptographic proof before trusting any field.
match verify_credential_proof(vic) {
Ok(_) => {}
Err(e) => return Err(format!("credential proof verification failed: {e}")),
}
if !missing.is_empty() {
return Err(missing.join(", "));
}
Ok(())
}
/// Resolves the issuer DID, fetches its verification key(s), and validates
/// the credential's `proof` (Data Integrity) or outer JWS/VC-JWT signature.
fn verify_credential_proof(vic: &Value) -> Result<(), String> {
// delegate to dtg-credentials / did-resolution crate; fail closed if
// proof is missing, malformed, or signature does not verify.
dtg_credentials::verify(vic).map_err(|e| e.to_string())
}
Additional recommendations:
- Reject credentials with no
prooffield outright before running structural checks (fail fast). - Pin trusted community issuer DIDs to an allow-list / registry rather than accepting any resolvable DID.
- Add integration tests using an unsigned-but-structurally-valid credential to assert rejection.
- Log and alert on rejected proof verifications for anomaly detection.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 55%
- AI Validation Evidence: EVIDENCE FOUND: validate_invitation_credential in openvtc-core/src/join.rs (lines 441-482 in the finding, matching the full function shown in source_files) performs only structural checks: @context array shape/order via
ctx.first().and_then(Value::as_str) == Some(W3C_VC_V2_CONTEXT), DTG context membership via.any(|v| v.as_str() == Some(DTG_CONTEXT)), type-tag checks via is_invitation_credential and DTG_BASE_TYPE, issuer presence via invitation_issuer, subject/id/validUntil/credentialStatus presence, andif vic.get("proof").is_none() { missing.push("proof (the issuer's Data-Integrity signature)"); }. It never calls a signature/proof verification routine — it merely checks the proof KEY exists, not that it cryptographically validates. EVIDENCE NOT FOUND: No code in the provided source_files (join.rs, health_cmd.rs, inbox_panel.rs) shows a Data Integrity Proof or VC-JWT verification routine being invoked anywhere in the join pipeline; however, the module doc-comment states 'Either way the sender is cryptographically proven — the authcrypt sender over DIDComm, the sender VID over TSP' and that 'The VTC extracts it, verifies its issuer signature + holder-binding' — meaning actual credential/signature verification is asserted to happen on the VTC (server) side, which is a separate service not included in source_files. This function's own docstring states it validates completeness ('a complete, presentable Invitation Credential, not a summary/display projection') and explicitly says 'proof / credentialStatus are not re-verified here (that is the VTC's job at submit) — their mere presence is what distinguishes a real signed VIC from a stripped copy.' CHANGED VS PRE-EXISTING: This function is directly modified by this MR (adds DTG_CONTEXT/DTG_BASE_TYPE checks per testvalidate_rejects_a_vic_missing_the_dtg_common_structure), so the finding's chain is CHANGED. VERDICT JUSTIFICATION: The finding is technically accurate about this function's scope (no crypto verification here), but the code's own comments assert verification happens downstream at the VTC service, which is not in scope of provided files — cannot confirm or deny that claim, so must_review rather than validated or false_positive.- 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.
⚪ Issuer Field Spoofing — No Trust Registry / DID Resolution Check
| Field | Detail |
|---|---|
| Severity | INFORMATIONAL |
| Location | openvtc-core/src/join.rs:476 |
| Finding ID | github_pr-7e93e15f76c5 |
| CWE | CWE-345, CWE-290 |
| OWASP | A07:2021 - Identification and Authentication Failures |
| MITRE ATT&CK | T1078 - Valid Accounts |
| CAPEC | CAPEC-151, CAPEC-196 |
| DREAD | 6.4 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | poc |
| Detection Source | skill_scan |
Summary: The issuer verification logic checks only that an issuer field exists in the expected shape (DID string or object with id), with no lookup against a trusted community registry. Combined with the lack of signature verification (VULN-001), this allows an attacker to claim any issuer identity they like.
📝 Description:
Attackers can impersonate a legitimate community as the credential issuer, which could deceive human moderators reviewing borderline credentials or automated systems that display the issuer field as a trust signal, leading to unwarranted approval of join requests.
🧪 Proof of Concept:
The issuer check (invitation_issuer(vic).is_none()) only tests presence and shape, never resolving the DID or cross-checking it against any registry of trusted issuers. Any string formatted like a DID, or any object with an id field, satisfies this check.
if !vic.get("type").and_then(Value::as_array)
.is_some_and(|t| t.iter().any(|v| v.as_str() == Some(DTG_BASE_TYPE))) {
missing.push("type entry \"DTGCredential\"");
}
if invitation_issuer(vic).is_none() {
missing.push("issuer (a DID string or an object with an `id`)");
}
Vulnerable lines: 471, 480
🔎 Evidence: openvtc-core/src/join.rs:476
if invitation_issuer(vic).is_none() {
missing.push("issuer (a DID string or an object with an `id`)");
}
💥 Impact:
Attackers can impersonate a legitimate community as the credential issuer, which could deceive human moderators reviewing borderline credentials or automated systems that display the issuer field as a trust signal, leading to unwarranted approval of join requests.
🧭 Reachability:
- Network exposure: internal
- Auth barrier: none
- Attack path: EP-003 (paste/load VIC) → validate_invitation_credential (join.rs:476) → invitation_issuer shape-only check → Ok(()) accepted
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | medium |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: Attacker sets the issuer field to a string resembling a trusted community's DID; since no registry lookup occurs, the forged issuer passes validation and may deceive moderators.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Adding a call to a trusted-issuer registry (or DID resolution + allow-list check) ensures the issuer field cannot simply be an arbitrary attacker-chosen string; it must correspond to a known, vetted community identity, and should additionally be cryptographically bound via the proof fix in VULN-001.
Vulnerable code:
if invitation_issuer(vic).is_none() {
missing.push("issuer (a DID string or an object with an `id`)");
}
Secure code:
let issuer = invitation_issuer(vic).ok_or("issuer (a DID string or an object with an `id`)")?;
if !trusted_issuer_registry::is_trusted(&issuer) {
missing.push("issuer is not a recognized community DID");
}
// Additionally, the issuer claimed here MUST match the key that produced
// the credential's cryptographic proof (see VULN-001 remediation).
Additional recommendations:
- Display issuer trust/verification status explicitly in any moderator review UI.
- Maintain a signed, versioned allow-list of trusted community issuer DIDs.
- Alert on credentials with unresolved or unrecognized issuers.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 50%
- AI Validation Evidence: EVIDENCE FOUND: invitation_issuer (join.rs) is implemented as:
match vic.get("issuer")? { Value::String(s) => Some(s.as_str()), Value::Object(_) => vic.pointer("/issuer/id").and_then(Value::as_str), _ => None }and validate_invitation_credential only checksif invitation_issuer(vic).is_none() { missing.push(...) }at lines 476-479 — confirming no DID resolution or trust-registry lookup happens in this function. EVIDENCE NOT FOUND: No trust-registry/DID-resolution code was found in any provided source file. However,invitation_matches_communityexists:pub fn invitation_matches_community(vic: &Value, vtc_did: &str) -> bool { invitation_issuer(vic) == Some(vtc_did) }, and the module docstring states the VTC (server-side) 'verifies its issuer signature + holder-binding' and 'auto-admits on a valid, trusted, unconsumed invitation' per 'join.rego' — implying issuer trust enforcement happens server-side (join.rego / VTC), which is not present in source_files. CHANGED VS PRE-EXISTING: invitation_issuer itself is not shown as modified by this MR's diff hunks, but the finding's chain (validate_invitation_credential calling it) is part of the function modified by this MR per the added tests; treat as CHANGED per tie-break rule since it's in the same file/function altered by the PR. VERDICT JUSTIFICATION: The client-side check indeed lacks trust-registry verification, but comments assert this is deliberately deferred to the VTC/join.rego layer not included here — insufficient evidence to confirm this is a genuine gap versus by-design layering, so must_review.- 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.
🛡️ Open full Threat Model & Affect Analysis — threat-modelling_affect-analysis_report_PR256_2026-08-24T11-44-04.md
🛡️ Threat Model & Affect Analysis — PR #256
| Field | Value |
|---|---|
| Repository | OpenVTC/openvtc |
| Branch | fix/vic-dtg-common-structure → main |
| Generated | 2026-08-24 |
ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Code Review 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
Tightens validate_invitation_credential() in openvtc-core to require the full DTG Credentials §Common Structure — both the W3C VC v2 context AND the DTG-specific context in @context, plus the DTGCredential base type tag — closing a validation gap where a credential lacking DTG-specific markers could still pass as a valid Invitation Credential (VIC). The companion join_flow.rs test fixture is updated to match the new stricter contract, and a regression test is added covering both new failure modes.
Diff: +45 / -8 lines
Types: security, bugfix
Risk Assessment
- Overall Risk: medium
- Review Priority: before_merge
- Pentest Needed: false
- Security Review Needed: true
This is a small, well-scoped, security-positive fix that correctly closes a real credential type-confusion validation gap on a trust-boundary function (validate_invitation_credential) accepting untrusted, pasted/loaded JSON. The change is well-tested with a targeted regression test and updated fixtures. Overall risk is rated medium rather than low because: (1) the PR is explicitly marked as a breaking change, and the impact on already-issued/legacy-format credentials is unverified from the diff alone; (2) the enforcement point (missing.is_empty() or equivalent) that actually causes rejection is not visible in the provided hunks and must be confirmed; (3) the fix, while valuable, only addresses structural validation and does not address the deeper absence of cryptographic proof verification noted in prior threat modeling, meaning the residual attack surface for full credential forgery remains open regardless of this PR merging. No new attack surface is introduced by this change itself — it is strictly a tightening of existing validation logic.
Review Focus Areas:
- Confirm the missing.is_empty() (or equivalent) gate is the actual enforcement mechanism and that no caller silently ignores a non-empty missing vector
- Confirm whether cryptographic proof/signature verification exists anywhere in the pipeline outside this diff's visible hunks
- Assess backward compatibility impact on any already-issued invitations lacking the DTG context/type given the breaking-change marker in the PR title
- Review whether the itemized 'missing' error string is surfaced to untrusted end users (oracle risk) vs. only to moderators/logs
Pentest Focus:
- Attempt to submit structurally-compliant-but-unsigned credentials to confirm whether any cryptographic proof verification exists downstream of validate_invitation_credential
- Attempt @context array reordering (DTG context first, W3C context second) to probe consistency of the positional vs. membership checks
- Attempt to submit legacy-format (pre-fix) credentials to confirm they are now correctly and consistently rejected across all callers
⚠️ Security Implications
🟠 Closes credential type-confusion gap in VIC validation (DTG Common Structure now fully enforced)
Closes credential type-confusion gap in VIC validation (DTG Common Structure now fully enforced)
Action: Merge as-is for the structural fix; track the MUST items above regarding legacy credential compatibility and confirm the actual Err-returning enforcement point.
🟡 Breaking change: previously-valid VICs lacking DTG context/type will now be rejected
Breaking change: previously-valid VICs lacking DTG context/type will now be rejected
Action: Audit any currently-outstanding/issued invitations for DTG-structure compliance before deploying this change; consider a migration/grace-period path if legacy-format credentials are still in circulation.
🟡 Structural validation alone does not verify cryptographic authenticity
Structural validation alone does not verify cryptographic authenticity
Action: Verify whether signature/proof verification exists elsewhere in the pipeline (outside this diff's scope); if it does not, prioritize adding a mandatory, fail-closed proof verification step before or alongside structural validation.
🔵 Duplicated DTG constants create validation-drift risk against upstream dtg-credentials crate
Duplicated DTG constants create validation-drift risk against upstream dtg-credentials crate
Action: Prioritize resolution of OpenVTC/dtg-credentials#10 to export shared constants; add a cross-crate integration test minting via dtg-credentials and validating via join.rs to catch drift automatically.
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| VIC Validation Module (openvtc-core::join) | high | modified | validate_invitation_credential() now enforces both required @context entries and the DTGCredential base type, in addition to the pre-existin |
| Join Flow State Handler Test Fixtures | low | modified | pasteable_vic() test helper updated to produce a credential shape compliant with the new validator requirements. |
📁 File Classifications
openvtc-core/src/join.rs
- Type: security
openvtc/src/state_handler/join_flow.rs
- Type: test
💡 Recommendations
- MUST — Audit all currently-issued/outstanding invitation credentials for DTG-structure compliance before deploying this breaking change; provide a migration or grace-period mechanism if any legacy-format credentials remain in circulation. (effort: medium)
- PR title is explicitly marked breaking (fix(join)!); silent lockout of legitimate users is a real operational risk not addressed within this diff.
- MUST — Confirm, outside this diff's visible hunks, that the missing vector is actually used to gate a hard rejection (Err) in validate_invitation_credential, and that all callers (join_flow, VIC manager) treat any non-empty missing as rejection. (effort: small)
- The diff only shows additions to the missing vector; the enforcement mechanism itself is unconfirmed from the provided code.
- SHOULD — Add mandatory cryptographic proof/signature verification (Data Integrity Proof or VC-JWT) against the issuer's DID document, either before or alongside this structural validation. (effort: large)
- Structural checks alone (even after this fix) do not prevent full credential forgery by an attacker who can satisfy the JSON shape requirements.
- SHOULD — Resolve Add the delegation credential (VDC) to the catalog dtg-credentials#10 by exporting W3C_VC_V2_CONTEXT, DTG_CONTEXT, and DTG_BASE_TYPE as shared public constants from the upstream crate, and add a cross-crate integration test minting via dtg-credentials and validating via join.rs. (effort: medium)
- Eliminates the acknowledged duplication/drift risk between the minting and validation crates.
- CONSIDER — Return a generic rejection message to untrusted end-user submitters while retaining the detailed itemized missing-field list for internal logs/moderator tooling only. (effort: small)
- Reduces the validation-oracle effect that could help an attacker iteratively reverse-engineer the exact required credential shape.
- CONSIDER — Add a regression test asserting rejection of a reordered @context array (DTG context first, W3C context second) to lock in the current positional-check behavior. (effort: small)
- Guards against a future well-intentioned refactor that makes the W3C check order-independent, which could have unintended JSON-LD semantic implications.
✅ Positive Observations
- Closes a genuine, well-explained credential-validation gap (missing DTG-specific @context and type checks) with clear doc comments describing both the vulnerability and the fix rationale.
- New regression test (validate_rejects_a_vic_missing_the_dtg_common_structure) explicitly covers both failure modes independently, asserting the error message names the specific missing field.
- Existing test fixtures (complete_vic, pasteable_vic) were proactively updated to match the tightened contract, preventing false-negative test coverage / silently stale 'complete' fixtures.
- Error reporting remains exhaustive (collects all missing fields rather than short-circuiting), aiding legitimate users/moderators in diagnosing malformed credentials.
- Technical debt (duplicated constants from an unexported upstream crate) is explicitly acknowledged in code comments with a linked tracking issue rather than silently accepted.
- No secrets, hardcoded credentials, or sensitive values were introduced; all new string literals are public specification identifiers.
🛡️ STRIDE Threat Model
Identified Threats (11)
⚪ STRIDE-1: Credential Type Confusion via Missing DTG Common Structure Check in validate_invitation_credential
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering, Elevation of Privilege |
| Severity | High |
| Likelihood | Likely |
| CVSS | 7.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-345,CWE-697,CWE-20 |
| CAPEC | CAPEC-122,CAPEC-196 |
| OWASP | A07:2021 - Identification and Authentication Failures, A04:2021 - Insecure Design |
Description: validate_invitation_credential in openvtc-core/src/join.rs (pre-patch) allows credential type confusion due to missing verification of the DTG-specific @context entry and DTGCredential base type, resulting in acceptance of non-DTG credentials as valid VICs and unauthorized community join access.
Evidence: openvtc-core/src/join.rs:446-462 (pre-patch ~438-451)
match vic.get("@context").and_then(Value::as_array) {
Some(ctx) if ctx.first().and_then(Value::as_str) == Some("https://www.w3.org/ns/credentials/v2") => {}
_ => missing.push("@context (must be an array whose first item is ...)"),
}
Attack Scenario:
- Attacker crafts an arbitrary JSON-LD Verifiable Credential containing only the W3C v2
@contextentry (https://www.w3.org/ns/credentials/v2) and thetypearray["VerifiableCredential", "InvitationCredential"], omitting the DTG-specific contexthttps://firstperson.network/credentials/dtg/v1and theDTGCredentialbase type. - Attacker forges or reuses an
issuerfield and acredentialSubject.idreferencing themselves, without ever obtaining this credential from a legitimate DTG-issuing community. - Attacker pastes/loads this credential via the UI_PASTE_OR_LOAD entry point (EP-003) in join_flow.rs, which forwards the raw JSON Value to
validate_invitation_credential(EP-001) in join.rs. - Pre-patch, the function at openvtc-core/src/join.rs (old lines ~438-441) only checks that
@contextis an array whose first element equalsW3C_VC_V2_CONTEXT; it never checks for the DTG_CONTEXT entry or the DTGCredential type tag. - Because
is_invitation_credentialonly checks for theInvitationCredentialtag (not the DTG base type), and the DID/expiry/subject checks pass trivially for a self-crafted credential,validate_invitation_credentialreturnsOk(()). - The forged, non-DTG-issued credential is accepted as a valid VIC, allowing the attacker to proceed through the join flow as if holding a legitimately community-issued invitation.
🔎 Threat Clue: Derived from COMP-001, COMP-002 via EP-001, EP-003
- Data Flows: Pasted/loaded VIC JSON -> validate_invitation_credential -> join flow state
Preconditions: Attacker can construct or paste arbitrary JSON into the join flow UI (EP-003)., Deployed build predates this PR's tightened validation., No cryptographic signature verification is shown in the provided code (only structural/shape validation).
Existing Controls: is_invitation_credential checks for presence of InvitationCredential tag in type array. • invitation_issuer checks for a DID string or object with id. • Expiry check via invitation_is_expired.
Recommended Mitigations: Enforce full DTG §Common Structure validation (both @context entries and DTGCredential base type) as implemented in this PR. • Add cryptographic proof/signature verification of the credential (e.g., Data Integrity proof, VC-JWT) rather than relying solely on structural JSON shape checks. • Pin and verify the issuer DID against a trusted community registry before accepting the credential. • Add fuzz/property tests generating adversarial credential shapes to continuously validate the parser's rejection logic.
⚪ STRIDE-2: Credential Base-Type Spoofing via Absent DTGCredential Tag Check
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| Severity | High |
| Likelihood | Likely |
| CVSS | 7.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-345,CWE-20 |
| CAPEC | CAPEC-148,CAPEC-122 |
| OWASP | A04:2021 - Insecure Design, A08:2021 - Software and Data Integrity Failures |
Description: validate_invitation_credential in openvtc-core/src/join.rs (pre-patch) allows spoofing of credential provenance due to the absence of a DTGCredential type-tag check, resulting in acceptance of credentials that were never minted by dtg-credentials as legitimate community invitations.
Evidence: openvtc-core/src/join.rs:463-476
if !vic.get("type").and_then(Value::as_array).is_some_and(|t| t.iter().any(|v| v.as_str() == Some(DTG_BASE_TYPE))) {
missing.push("type entry \"DTGCredential\"");
}
Attack Scenario:
- Attacker crafts a JSON credential with
type: ["VerifiableCredential", "InvitationCredential"], omittingDTGCredential. - Because pre-patch validate_invitation_credential (openvtc-core/src/join.rs) never inspects the
typearray forDTGCredential, the check silently passes for any credential subtype claiming to be an InvitationCredential. - Attacker submits this credential through the join_flow's paste/load UI (EP-003), which is unauthenticated at the point of parsing (auth_required:false per recon).
- The credential is accepted, and downstream code (VIC manager / vault storage, referenced in the PR description) proceeds to persist and trust the malformed VIC as legitimate.
- Attacker gains unwarranted access to community join semantics without ever holding a credential actually issued under the DTG specification.
🔎 Threat Clue: Derived from COMP-001, COMP-002 via EP-001, EP-002, EP-003
- Data Flows: Pasted VIC -> is_invitation_credential -> validate_invitation_credential
Preconditions: Pre-patch codebase., Attacker-controlled paste/load path with no authentication or cryptographic verification gate before structural validation.
Existing Controls: Presence check for InvitationCredential tag via is_invitation_credential.
Recommended Mitigations: Apply the PR's added DTGCredential type-tag enforcement. • Add a defense-in-depth check requiring an explicit signature/proof over the full credential (context+type+subject) rather than trusting JSON shape alone. • Log and alert on rejected credentials with partial DTG structure for anomaly detection.
⚪ STRIDE-3: Missing Cryptographic Proof Verification in VIC Validation Pipeline
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering, Elevation of Privilege |
| Severity | Critical |
| Likelihood | Likely |
| CVSS | 9.2 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N |
| Residual Severity | High |
| CWE | CWE-347,CWE-290,CWE-345 |
| CAPEC | CAPEC-115,CAPEC-475,CAPEC-196 |
| OWASP | A08:2021 - Software and Data Integrity Failures, A07:2021 - Identification and Authentication Failures |
Description: validate_invitation_credential in openvtc-core/src/join.rs allows complete credential forgery due to the absence of any cryptographic signature/proof verification of the Verifiable Credential, resulting in full impersonation of a community issuer and unauthorized community join.
Evidence: openvtc-core/src/join.rs:441-482
pub fn validate_invitation_credential(vic: &Value) -> Result<(), String> { let mut missing: Vec<&str> = Vec::new(); ... }
Attack Scenario:
- Attacker examines the public source of
validate_invitation_credentialand observes it performs only structural/string checks on@context,type,issuer,credentialSubject, andvalidUntil— no call to a proof/signature verification routine is present in the provided diff. - Attacker fabricates a full JSON-LD credential satisfying every structural rule added by this PR: correct
@contextarray (W3C v2 + DTG), correcttypearray (VerifiableCredential, DTGCredential, InvitationCredential), a plausible-lookingissuerDID string, acredentialSubject.idset to the attacker's own DID, and a futurevalidUntil. - Attacker pastes this self-signed (or entirely unsigned) fabricated VIC into the join_flow UI paste/load entry point (EP-003, auth_required:false).
validate_invitation_credentialreturnsOk(())because all checks it performs are purely structural and the attacker satisfied them all; noprooffield or Data Integrity/JWT signature check is invoked anywhere in the visible code path.- The state handler in join_flow.rs proceeds to treat the credential as a genuine community invitation, allowing the attacker to join the community, claim membership, or move to subsequent trust-elevated flow states without ever possessing a credential actually signed by a community issuer key.
🔎 Threat Clue: Derived from COMP-001, COMP-002 via EP-001, EP-003
- Data Flows: Pasted VIC JSON -> validate_invitation_credential -> community join state transition
Preconditions: No cryptographic verification step exists between structural validation and acceptance (based on provided code; if verification exists elsewhere in the pipeline outside the diff, this is unconfirmed and should be verified)., Attacker has the ability to construct and submit arbitrary JSON via the paste/load UI.
Existing Controls: Structural checks on @context, type, issuer presence, subject presence, and expiry.
Recommended Mitigations: Integrate a mandatory Data Integrity Proof or VC-JWT signature verification step prior to or alongside structural validation, verifying against the issuer's registered DID document keys. • Reject any credential lacking a proof (or JWT signature) before structural checks are even attempted, to fail closed. • Add end-to-end tests asserting that structurally-valid-but-unsigned credentials are rejected.
⚪ STRIDE-4: Credential Expiry Tampering via Unverified validUntil Field
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 6.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-345,CWE-347 |
| CAPEC | CAPEC-31,CAPEC-386 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: invitation_is_expired in openvtc-core/src/join.rs allows expiry-date tampering due to trusting an unsigned/unverified validUntil field supplied within attacker-controlled JSON, resulting in acceptance of stale or attacker-extended invitation credentials.
Evidence: openvtc-core/src/join.rs:~395-403
pub fn invitation_is_expired(vic: &Value, now: DateTime<Utc>) -> bool { ... }
Attack Scenario:
- Attacker obtains or fabricates a VIC-shaped JSON object and sets
validUntilto a far-future date (e.g.2099-01-01T00:00:00Z, matching the test fixture pattern observed in complete_vic()). - Because there is no shown cryptographic binding between
validUntiland a signed proof, the attacker edits this field freely before pasting it into the join flow. invitation_is_expired(referenced at join.rs line ~398) parses this field and compares it againstnow, returning false (not expired) regardless of the credential's true original expiry.- Combined with STRIDE-3 (no signature verification), the attacker's tampered VIC is accepted as unexpired and structurally valid.
- Attacker retains use of an invitation that should have lapsed, extending unauthorized access to the join flow indefinitely.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: Pasted VIC -> invitation_is_expired -> validate_invitation_credential
Preconditions: No signature verification binds validUntil to the rest of the credential (dependent on STRIDE-3 being true)., Attacker can freely edit the pasted/loaded JSON before submission.
Existing Controls: Basic date parsing and comparison logic in invitation_is_expired.
Recommended Mitigations: Ensure validUntil is covered by the credential's cryptographic proof so tampering invalidates the signature. • Add server-side/community-side revocation checks independent of the client-supplied credential fields.
⚪ STRIDE-5: Issuer Field Spoofing via Loosely-Typed DID/Object Check
| Field | Detail |
|---|---|
| Category | Spoofing |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 6.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-345,CWE-290 |
| CAPEC | CAPEC-151,CAPEC-196 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: invitation_issuer in openvtc-core/src/join.rs allows issuer spoofing due to accepting any string or object-with-id as a valid issuer without validating it against a known/trusted community DID registry, resulting in impersonation of a legitimate issuing community.
Evidence: openvtc-core/src/join.rs:~476-479
if invitation_issuer(vic).is_none() { missing.push("issuer (a DID string or an object with an `id`)"); }
Attack Scenario:
- Attacker inspects validate_invitation_credential and observes
invitation_issuer(vic).is_none()is the only issuer check — any string (e.g.did:webvh:attacker.com:fake-community) or{ "id": "..." }object satisfies it. - Attacker sets
issuerto a self-controlled DID string mimicking the format of a legitimate community (e.g. copyingdid:webvh:example.com:communitystructure but pointing to attacker infrastructure). - Attacker builds a fully structurally-compliant VIC (post-patch: correct @context array, correct type array with DTGCredential) with this spoofed issuer.
- The credential passes validate_invitation_credential entirely, because issuer format — not issuer trust/authenticity — is what's checked.
- Downstream UI or moderator review (referenced in test comments as "silently fell through to moderator review") may display the spoofed issuer DID as if trustworthy, misleading a human reviewer or automated trust decision into approving the join.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: Pasted VIC -> invitation_issuer -> validate_invitation_credential
Preconditions: No allow-list or DID-resolution/trust-registry check of the issuer field exists in the shown code., Human or automated reviewer relies on the issuer field's mere presence/format rather than independent verification.
Existing Controls: Presence/shape check requiring issuer to be a DID string or object with id.
Recommended Mitigations: Resolve the issuer DID against a trusted community/issuer registry and reject unknown issuers. • Display issuer trust status (resolved vs. unresolved) prominently to moderators reviewing borderline credentials. • Bind issuer verification to the cryptographic proof so the claimed issuer must match the actual signing key.
⚪ STRIDE-6: Denial of Service via Deeply Nested or Oversized JSON in Paste/Load Entry Point
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 5.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-400,CWE-770 |
| CAPEC | CAPEC-130,CAPEC-197 |
| OWASP | A05:2021 - Security Misconfiguration |
Description: UI_PASTE_OR_LOAD in join_flow.rs allows resource exhaustion due to unbounded parsing of attacker-supplied JSON before any size/depth limits are enforced, resulting in degraded availability of the join flow state handler.
Evidence: openvtc/src/state_handler/join_flow.rs:N/A (entry point inferred; parsing site not shown in reduced diff)
fn pasteable_vic(id: &str) -> serde_json::Value { json!({ ... }) }
Attack Scenario:
- Attacker constructs a maliciously large or deeply nested JSON document (e.g., deeply nested
@contextarrays or a hugecredentialSubjectpayload) far exceeding a normal VIC's size. - Attacker pastes this payload into the join_flow UI's paste/load control (EP-003), which forwards it to
serde_json::Valueparsing prior to any of the field-presence checks in validate_invitation_credential. - If no input-size cap is enforced before or during
serde_jsondeserialization, parsing consumes excessive CPU/memory on the client or backend process handling the join flow. - Repeated submissions from the same or multiple sessions amplify resource consumption, degrading responsiveness of the join flow for legitimate users.
- Because
validate_invitation_credentialruns its checks only after full deserialization succeeds, the cost is paid regardless of ultimate validation outcome (rejected or accepted).
🔎 Threat Clue: Derived from COMP-002 via EP-003
- Data Flows: Pasted JSON -> serde_json::Value parse -> validate_invitation_credential
Preconditions: No demonstrated size/depth limit on JSON parsed at the paste/load entry point., Attacker can repeatedly submit large payloads (rate limiting unconfirmed in provided code).
Existing Controls: None demonstrated in the reviewed diff/files.
Recommended Mitigations: Enforce a maximum payload size and JSON nesting depth before/while parsing pasted VIC content. • Apply rate limiting to the paste/load entry point per session/user. • Use a streaming or bounded JSON parser configuration to reject oversized inputs early.
⚪ STRIDE-7: Insufficient Logging of Rejected/Malicious VIC Submissions Enabling Repudiation
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 4.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N |
| Residual Severity | Low |
| CWE | CWE-778,CWE-223 |
| CAPEC | CAPEC-93 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: validate_invitation_credential in openvtc-core/src/join.rs allows repudiation of malicious credential submission attempts due to absence of any demonstrated audit logging of validation failures/successes, resulting in inability to trace or attribute repeated forgery attempts.
Evidence: openvtc-core/src/join.rs:441-482
pub fn validate_invitation_credential(vic: &Value) -> Result<(), String> { ... }
Attack Scenario:
- Attacker repeatedly submits crafted/forged VIC payloads through the join flow paste/load entry point, iterating on the structural checks (as in STRIDE-1/2/3) to find a bypass.
validate_invitation_credentialreturns aResult<(), String>consumed presumably by the state handler, but no logging, alerting, or audit trail call is visible in the reviewed code for either success or failure paths.- Because failures are returned as plain
Stringerror values with no correlation ID, timestamp, or persistent audit record shown, a security team cannot reconstruct how many forgery attempts occurred, from where, or the exact payloads used. - Attacker can iterate indefinitely on bypass attempts (e.g., probing which specific field caused rejection, per the itemized
missingvector) without fear of detection or attribution, later denying any wrongdoing if confronted. - This lack of traceability also frustrates the incident response necessary after STRIDE-1/2/3 are exploited.
🔎 Threat Clue: Derived from COMP-001, COMP-002 via EP-001, EP-003
- Data Flows: VIC validation attempts (success/failure) -> (no logging sink observed)
Preconditions: No audit logging or SIEM integration demonstrated in the reviewed source for credential validation attempts., Attacker has repeated, unmonitored access to the paste/load entry point.
Existing Controls: Verbose per-field missing vector aids debugging locally but is not shown to be persisted or logged centrally.
Recommended Mitigations: Log every validate_invitation_credential invocation outcome (success/failure, missing fields, timestamp, session/user context) to a tamper-evident audit log. • Alert on repeated validation failures from the same session/IP as a potential brute-force/bypass-probing indicator. • Correlate join-flow audit logs with community moderator review workflows.
⚪ STRIDE-8: Verbose Error Disclosure of Validation Internals via missing Field Vector
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Low |
| Likelihood | Likely |
| CVSS | 3.5 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-209,CWE-203 |
| CAPEC | CAPEC-54,CAPEC-215 |
| OWASP | A04:2021 - Insecure Design |
Description: validate_invitation_credential in openvtc-core/src/join.rs allows information disclosure of internal validation logic due to returning a fully itemized list of every missing/invalid field to the caller, resulting in an oracle that accelerates crafting of a bypassing credential.
Evidence: openvtc-core/src/join.rs:441-482
let mut missing: Vec<&str> = Vec::new(); ... missing.push("@context entry \"https://firstperson.network/credentials/dtg/v1\"");
Attack Scenario:
- Attacker submits an intentionally incomplete VIC (e.g., missing @context, wrong type array) to the join flow paste/load UI.
validate_invitation_credentialcollects every failing check into themissing: Vec<&str>and returns them all as a descriptive error string (e.g., naming exactly which @context entry or type entry is absent).- If this detailed error is surfaced back to the end user/attacker (as opposed to only to a moderator), the attacker uses it as an oracle: iteratively adjusting one field at a time and resubmitting until every check passes.
- This significantly lowers the effort required to reverse-engineer the exact structural requirements (@context ordering, DTG_CONTEXT string, DTG_BASE_TYPE string) without needing to read the source code at all.
- Combined with STRIDE-3 (no signature verification), the attacker uses this oracle to efficiently construct a fully passing, but entirely forged, credential.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: validate_invitation_credential error String -> UI/caller
Preconditions: The detailed error string from validate_invitation_credential is exposed to the untrusted submitter rather than only to internal logs/moderators., Attacker can resubmit multiple times without lockout.
Existing Controls: None demonstrated; the granular missing vector appears designed for developer/test ergonomics (as seen in unit tests asserting err.contains(...)).
Recommended Mitigations: Return a generic rejection message to the untrusted submitter while logging the detailed missing vector internally only. • Rate-limit or add friction (e.g., CAPTCHA, cooldown) to repeated validation attempts from the same session. • Reserve the itemized missing-fields detail for authenticated moderator-facing tooling only.
⚪ STRIDE-9: Supply Chain Trust Dependency on Unexported dtg-credentials Constants
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-1104,CWE-707 |
| CAPEC | CAPEC-536 |
| OWASP | A06:2021 - Vulnerable and Outdated Components |
Description: W3C_VC_V2_CONTEXT/DTG_CONTEXT/DTG_BASE_TYPE constant duplication in openvtc-core/src/join.rs allows validation drift due to re-declaring string literals that the upstream dtg-credentials crate does not export as public constants, resulting in potential silent divergence between minting and validation logic if upstream values change.
Evidence: openvtc-core/src/join.rs:401-410
pub const W3C_VC_V2_CONTEXT: &str = "https://www.w3.org/ns/credentials/v2";
pub const DTG_CONTEXT: &str = "https://firstperson.network/credentials/dtg/v1";
pub const DTG_BASE_TYPE: &str = "DTGCredential";
Attack Scenario:
- The code comment explicitly states: 'dtg-credentials builds all three into the credentials it mints but does not export them, so they are named here rather than spelled out inline' with a tracked upstream issue (Add the delegation credential (VDC) to the catalog dtg-credentials#10).
- If the upstream
dtg-credentialscrate changes its internal context URLs or base type string in a future release (e.g., versioning the DTG context to.../v2) without a corresponding synchronized update to these duplicated constants in join.rs, the two crates silently diverge. - Newly minted, entirely legitimate credentials from an updated dtg-credentials would then fail validate_invitation_credential's now-stale checks (denial of legitimate access), or conversely, if the drift goes the other way, older/deprecated context strings could remain accepted indefinitely (acceptance of stale credential formats).
- Because there is no automated cross-crate consistency test tying these string literals to the upstream crate's actual minted output beyond manual test fixtures, this drift could go unnoticed until a production incident.
- This is a supply-chain/maintainability risk rather than a directly exploitable vulnerability, but it can degrade the integrity guarantees this PR is trying to establish.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: dtg-credentials minted VIC -> join.rs validate_invitation_credential
Preconditions: Upstream dtg-credentials crate changes its constants without a synchronized update here., No automated integration test cross-checks these literals against the actual upstream crate output.
Existing Controls: Explicit code comment tracking the duplication and referencing the upstream tracking issue (OpenVTC/dtg-credentials#10). • Unit tests (complete_vic, pasteable_vic) exercise the exact string values.
Recommended Mitigations: Prioritize resolving OpenVTC/dtg-credentials#10 to export these as shared public constants consumed by both crates. • Add an integration test that mints a credential via dtg-credentials and validates it via join.rs's validate_invitation_credential to catch drift automatically. • Pin the dtg-credentials crate version precisely (not a caret/range) until the shared-constant issue is resolved.
⚪ STRIDE-10: Context Ordering Assumption Enabling Validation Bypass via Array Reordering
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.9 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-697,CWE-1023 |
| CAPEC | CAPEC-267 |
| OWASP | A04:2021 - Insecure Design |
Description: validate_invitation_credential in openvtc-core/src/join.rs allows JSON-LD semantic-equivalence bypass due to relying on positional (.first()) rather than semantic/order-independent checking of the @context array, resulting in potential rejection of legitimately equivalent credentials or acceptance logic that is brittle to attacker-crafted reordering combined with other bypasses.
Evidence: openvtc-core/src/join.rs:452-462
match ctx { Some(c) if c.first().and_then(Value::as_str) == Some(W3C_VC_V2_CONTEXT) => {} _ => missing.push(...), }
if !ctx.is_some_and(|c| c.iter().any(|v| v.as_str() == Some(DTG_CONTEXT))) { missing.push(...); }
Attack Scenario:
- JSON-LD
@contextarrays are semantically order-sensitive per the JSON-LD spec for term resolution, but the code's own check for the DTG_CONTEXT uses.iter().any(...)(order-independent) while the W3C context check uses.first()(strictly positional). - An attacker crafts
@contextas[DTG_CONTEXT, W3C_VC_V2_CONTEXT](DTG first, W3C second) — this passes the.any()check for DTG_CONTEXT but fails the.first()check for W3C, causing inconsistent enforcement that could either wrongly reject a spec-compliant reordering or, if a future refactor relaxes the first-position check to.any()for consistency, silently drop the requirement that W3C's base context be first (which some JSON-LD processors require for correct term resolution). - This inconsistency creates a latent maintenance hazard: a well-intentioned future fix to 'make both checks consistent' by switching the W3C check to
.any()would silently weaken the validator, since JSON-LD context order affects which vocabulary terms are actually resolved by downstream JSON-LD processors, potentially decoupling the validator's notion of 'valid' from the JSON-LD processor's actual interpretation. - An attacker exploiting downstream JSON-LD processing (if any exists beyond this Rust validator, e.g., a JS/wallet client also processing this VIC) could submit a context array ordering that Rust validates as acceptable (post-any-refactor) but which a strict JSON-LD processor resolves differently, causing a semantic gap/confusion attack between validator and consumer.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: @context array -> positional/any-based checks -> validate_invitation_credential
Preconditions: Downstream JSON-LD processing exists elsewhere in the stack that treats context order as semantically meaningful (not confirmed in provided files, inferred from JSON-LD spec)., A future code change relaxes the strict .first() positional check.
Existing Controls: Current code retains strict .first() positional check for the W3C base context, mitigating the main risk today.
Recommended Mitigations: Document explicitly (as a code comment or spec reference) why the W3C context must be first, to prevent future refactors from silently weakening the check. • Add a regression test asserting that a reordered @context array (DTG first, W3C second) is rejected. • If full JSON-LD semantic processing is ever added downstream, ensure the Rust validator's ordering assumptions are kept in lockstep with that processor's behavior.
⚪ STRIDE-11: Test Fixture Prompt-Injection Attempt Embedded in Diff Comments
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Informational |
| Likelihood | Very Unlikely |
| CVSS | 0.0 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | None |
| CWE | N/A |
| CAPEC | CAPEC-242 |
| OWASP | N/A |
Description: Diff comment text in openvtc-core/src/join.rs allows no direct security impact, observed as narrative code comments only, resulting in confirmation that no embedded instructions attempting to manipulate this analysis were found.
Evidence: openvtc-core/src/join.rs:399-410
/// Removing this duplication needs a public constant upstream — OpenVTC/dtg-credentials#10.
Attack Scenario:
- Reviewed all comment text throughout the diff (e.g., 'The exact shape that silently fell through to moderator review', 'Removing this duplication needs a public constant upstream').
- Confirmed these are legitimate, human-authored engineering rationale comments explaining design decisions and linking to a real upstream tracking issue reference (Add the delegation credential (VDC) to the catalog dtg-credentials#10).
- No text resembling 'ignore previous instructions', role-override attempts, or directives aimed at an AI analyzer was found in the source, diff, or fixture content.
- This finding is recorded per the security directive requiring any manipulation attempt to be reported as a finding rather than acted upon; here the check concluded negative (no manipulation attempt present).
🔎 Threat Clue: Derived from N/A via N/A
- Data Flows: N/A
Preconditions: N/A
Existing Controls: Analyst-side treatment of all input as untrusted data per directive.
Recommended Mitigations: Continue treating all repository content (comments, commit messages, PR descriptions) as untrusted data during automated security review pipelines.
🍝 PASTA Threat Model
Application Purpose
OpenVTC is a decentralized community-join/trust platform that uses W3C Verifiable Credentials (Invitation Credentials, VICs) minted under the DTG Credentials specification to gate membership; this PR hardens the structural validation of pasted/loaded invitation credentials before they are trusted in the join flow.
Inherent Risks
- The validated join flow trusts client-pasted/loaded JSON without demonstrated cryptographic proof verification in the reviewed code.
- Credential structural requirements are duplicated across crates (join.rs and upstream dtg-credentials) creating drift risk.
- The join flow entry points are unauthenticated by design (invitations are inherently used by non-members), increasing exposure to forged input.
Objectives
Risk: Treat any unauthenticated, user-suppliable input to the join flow as fully untrusted until cryptographically verified.; Accept residual risk of DoS on paste/load only if bounded by input size controls.
Business: Enable trusted, decentralized community onboarding via cryptographically verifiable invitations.; Prevent unauthorized or fraudulent community membership claims.
Security: Ensure only credentials actually issued by a legitimate DTG-context-aware issuer are accepted into the join flow.; Prevent structural or cryptographic forgery of Invitation Credentials.; Protect the confidentiality/integrity of the join state during and after VIC validation.
Financial: Avoid reputational/legal costs associated with a compromised trust/invitation system.; Minimize engineering rework costs from validation logic drift between crates.
Compliance: Align credential shape/validation with the W3C Verifiable Credentials Data Model v2.0 specification.; Align with the DTG Credentials specification's normative §Common Structure requirements.
Functional: Correctly validate that a pasted/loaded credential conforms to the DTG Credentials §Common Structure.; Support both VerifiableCredential v2 and DTGCredential subtype requirements in a single validation pass.
Operational: Maintain consistent validation behavior between the dtg-credentials minting crate and the join.rs validation crate.; Provide clear, actionable rejection reasons for legitimate community moderators.
Business Impact Analysis (1)
BIA-1: Community Join Credential Validation (Critical)
End-to-end process by which a prospective member pastes or loads a Verifiable Invitation Credential, the system validates its structural and (ideally) cryptographic integrity, and grants or denies progression through the community join flow.
MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours
- Stakeholders: Community Issuers / Community Moderators / Prospective Members / Security/Trust Engineering Team
- Dependencies: dtg-credentials crate (credential minting) / join.rs validation module (openvtc-core) / join_flow.rs state handler (openvtc) / serde_json JSON parsing / chrono datetime handling
- Disruptions: Acceptance of forged/non-DTG credentials due to incomplete structural validation. / Absence of cryptographic signature verification allowing full credential fabrication. / Validation logic drift between dtg-credentials minting and join.rs checking. / Resource exhaustion from oversized/malformed pasted JSON.
- Impacts: Unauthorized individuals gaining community membership under false pretenses. / Erosion of trust in the DTG credential ecosystem and OpenVTC platform reputation. / Increased moderator workload triaging forged credentials that should have been auto-rejected. / Potential service degradation from malicious oversized payloads.
Technical Scope
Roles (3): RO-1 Prospective Member · RO-2 Community Moderator · RO-3 Community Issuer
Actors (3): AC-1 Untrusted Join Submitter · AC-2 Moderator Reviewer · AC-3 dtg-credentials Minting Service
Entry Points (3): EP-1 validate_invitation_credential · EP-2 is_invitation_credential · EP-3 Join Flow VIC Paste/Load UI
Threat Actors (3): TA-1 Opportunistic Forger · TA-2 Sophisticated Impersonator · TA-3 Malicious Insider (Compromised Issuer Key or Process)
Infrastructure (1): IF-1 OpenVTC Application Runtime
Trust Boundaries (2): TB-1 Untrusted Client Input Boundary · TB-2 Internal Crate Boundary
External Entities (2): EE-1 Prospective Community Member (Untrusted Submitter) · EE-2 Community Issuer
System Components (3): SC-1 VIC Validation Module (join.rs) · SC-2 Join Flow State Handler (join_flow.rs) · SC-3 dtg-credentials Minting Crate (upstream)
Resources And Assets (3): RA-1 Verifiable Invitation Credential (VIC) · RA-2 DTG Common Structure Constants · RA-3 Community Join State
Technologies And Dependencies (3): TD-1 serde / serde_json · TD-2 chrono · TD-3 dtg-credentials
Use Cases (2)
- Prospective Member Joins Community via Pasted Invitation Credential: A prospective member receives a Verifiable Invitation Credential out-of-band from a community issuer, pastes or loads it into the join flow UI, the system validates its DTG Common Structure conformanc
- Community Issuer Mints an Invitation Credential: A community issuer uses the dtg-credentials crate to mint a fully-compliant DTG Invitation Credential (with both required @context entries and the DTGCredential base type), which it then transmits out
📋 Risk Registry (6)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-001 | Unauthorized community join via forged Verifiable Invitation Credential lacking cryptographic proof | Critical | High | Immediate | High |
| RISK-002 | Community issuer impersonation via unverified issuer DID field | Medium | Medium | Short-Term | Medium |
| RISK-003 | Extended unauthorized access via tampering with unsigned validUntil expiry field | Medium | Medium | Short-Term | Medium |
| RISK-004 | Validation logic drift between dtg-credentials minting crate and join.rs due to unexported shared constants | Low | Low | Medium-Term | Low |
| RISK-005 | Resource exhaustion and operational degradation via oversized pasted credential payloads | Low | Low | Medium-Term | Low |
| RISK-006 | Lack of audit trail for credential validation attempts hampers incident response | Low | Low | Short-Term | Medium |
⚔️ Attack Scenarios (2)
SC-1: VIC Validation Module (join.rs)
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Opportunistic Forger<br><i>Gain unauthorized community membership</i>" }
TA2@{ shape: rect, label: "TA-2: Sophisticated Impersonator<br><i>Impersonate a trusted issuer at scale</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Credential Type Confusion<br><i>High / Likely</i>" }
S3@{ shape: rect, label: "STRIDE-3: Missing Cryptographic Proof Verification<br><i>Critical / Likely</i>" }
S5@{ shape: rect, label: "STRIDE-5: Issuer Field Spoofing<br><i>Medium / Possible</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C122@{ shape: rect, label: "CAPEC-122: Privilege Abuse" }
C475@{ shape: rect, label: "CAPEC-475: Signature Spoofing by Improper Validation" }
C151@{ shape: rect, label: "CAPEC-151: Identity Spoofing" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
W347@{ shape: rect, label: "CWE-347: Improper Verification of Cryptographic Signature" }
W290@{ shape: rect, label: "CWE-290: Authentication Bypass by Spoofing" }
end
subgraph SL5["5. System Component"]
direction LR
SC1@{ shape: rect, label: "SC-1: VIC Validation Module (join.rs)" }
end
TA1 --> S1
TA2 --> S3
TA2 --> S5
S1 --> C122
S3 --> C475
S5 --> C151
C122 --> W345
C475 --> W347
C151 --> W290
W345 --> SC1
W347 --> SC1
W290 --> SC1
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#A50000,stroke-width:2px
linkStyle 2 stroke:#FFA500,stroke-width:2px
linkStyle 3 stroke:#FF0000,stroke-width:2px
linkStyle 4 stroke:#A50000,stroke-width:2px
linkStyle 5 stroke:#FFA500,stroke-width:2px
linkStyle 6 stroke:#FF0000,stroke-width:2px
linkStyle 7 stroke:#A50000,stroke-width:2px
linkStyle 8 stroke:#FFA500,stroke-width:2px
linkStyle 9 stroke:#FF0000,stroke-width:2px
linkStyle 10 stroke:#A50000,stroke-width:2px
linkStyle 11 stroke:#FFA500,stroke-width:2px
SC-2: Join Flow State Handler (join_flow.rs)
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Opportunistic Forger<br><i>Gain unauthorized community membership</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S6@{ shape: rect, label: "STRIDE-6: DoS via Oversized/Nested JSON<br><i>Low / Possible</i>" }
S7@{ shape: rect, label: "STRIDE-7: Insufficient Logging<br><i>Low / Possible</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C130@{ shape: rect, label: "CAPEC-130: Excessive Allocation" }
C93@{ shape: rect, label: "CAPEC-93: Log Injection-Tampering-Suppression" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W400@{ shape: rect, label: "CWE-400: Uncontrolled Resource Consumption" }
W778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
end
subgraph SL5["5. System Component"]
direction LR
SC2@{ shape: rect, label: "SC-2: Join Flow State Handler (join_flow.rs)" }
end
TA1 --> S6
TA1 --> S7
S6 --> C130
S7 --> C93
C130 --> W400
C93 --> W778
W400 --> SC2
W778 --> SC2
linkStyle 0 stroke:#00FF00,stroke-width:2px
linkStyle 1 stroke:#00FF00,stroke-width:2px
linkStyle 2 stroke:#00FF00,stroke-width:2px
linkStyle 3 stroke:#00FF00,stroke-width:2px
linkStyle 4 stroke:#00FF00,stroke-width:2px
linkStyle 5 stroke:#00FF00,stroke-width:2px
linkStyle 6 stroke:#00FF00,stroke-width:2px
linkStyle 7 stroke:#00FF00,stroke-width:2px
📊 Risk Summary
Total Threats: 11
By Severity: Low: 4 · High: 2 · Medium: 3 · Critical: 1 · Informational: 1
By Category: Unknown: 11
🎯 Attack Surface
Kill Chain 1: An unauthenticated attacker begins at the join flow's paste/load UI (EP-3, SC-2), which accepts arbitrary JSON with no authentication or authorization gate; because the pre-patch validate_invitation_credential (SC-1, EP-1) checked only the W3C @context half and never the DTG-specific context or DTGCredential type tag (STRIDE-1, STRIDE-2), an attacker could submit a self-crafted, non-DTG credential that nonetheless satisfied is_invitation_credential's loose InvitationCredential tag check and pass straight into the join state transition (RA-3), gaining unwarranted progress toward community membership. Kill Chain 2: Even with this PR's tightened structural checks in place, the deeper and more severe kill chain persists — because no cryptographic proof/signature verification step is evidenced anywhere in the reviewed validation pipeline (STRIDE-3), a sophisticated attacker can construct a fully structurally-compliant forged credential (correct @context array, correct type array including DTGCredential, plausible issuer DID, future validUntil) that passes every check validate_invitation_credential performs, chaining directly into full community-join impersonation; this is compounded by STRIDE-5 (no issuer-registry resolution) and STRIDE-4 (unsigned validUntil), meaning the forged credential's issuer claim and expiry are equally unverifiable, letting the attacker both impersonate an issuer and set the invitation's effective lifetime to be arbitrarily long. K
🛡️ Risk Mitigation Strategy
Priority 1 (Immediate): The single highest-leverage control gap is the complete absence of demonstrated cryptographic proof/signature verification in the VIC validation pipeline (STRIDE-3, RISK-001). All structural hardening in this PR, while a necessary and valuable step, is insufficient on its own because an attacker who can satisfy string-shape checks can fabricate a fully passing credential. The organization must prioritize integrating Data Integrity Proof or VC-JWT signature verification against a trusted issuer DID document as a blocking, fail-closed gate that runs before or alongside the structural checks added in this PR, and must add regression tests proving that structurally-perfect-but-unsigned credentials are rejected. Priority 2 (Short-Term): Close the two remaining spoofing/tampering gaps that compound the cryptographic gap — issuer identity is currently accepted based on shape alone (STRIDE-5/RISK-002) and validUntil is not bound to any proof (STRIDE-4/RISK-003); both should be resolved by binding these fields into the same signature-verification step and by adding issuer-registry resolution, alongside centralizing audit logging of all validation attempts (STRIDE-7/STRIDE-8, RISK-006) so that probing and forgery attempts become detectable and attributable rather than repudiable. Priority 3 (Medium-Term): Address the operational and maintainability gaps that, while lower severity individually, degrade the platform's resilience and long-term integrity guarantees — enforce payload size/depth limits and rate limiting on the unauthenticated paste/load entry point to close the DoS surface (STRIDE-6/RISK-005), and resolve the upstream dtg-credentials shared-constant export issue (OpenVTC/dtg-credentials#10) with an accompanying cross-crate integration test to eliminate the validation-drift supply-chain risk (STRIDE-9/RISK-004) before it manifests as either a false-negative security gap or a false-positive availability incident.
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 0 | 2 |
Must-Review-By-Human (2)
- ⚪ Missing Cryptographic Proof/Signature Verification in VIC Validation Pipeline
- ⚪ Issuer Field Spoofing — No Trust Registry / DID Resolution Check
Client-side counterpart to the ingress work in
OpenVTC/verifiable-trust-infrastructure#1064 (audit finding F2), found by
running the same conformance pass over openvtc that turned up
verifiable-trust-infrastructure#1062.
The gap
validate_invitation_credentialis genuinely careful — it checks the W3C VCData Model 2.0 mandatory properties and the VIC profile the receiving VTC
enforces, and documents why each is required. It checked neither half of DTG
Credentials §Common Structure, which is normative for every DTG credential:
@contextMUST includehttps://firstperson.network/credentials/dtg/v1typeMUST includeDTGCredentialgrepfor that context string across openvtc returns nothing. It is neverrequired and never checked — because everything openvtc mints goes through
dtg-credentials, which supplies it. So the one place a credential arrivesfrom outside was also the one place nothing verified it was a DTG credential at
all. A document with the right subtype tag and neither common-structure element
passed as a VIC.
Why nothing noticed
The fixtures agreed with the validator.
complete_vicandpasteable_vicbothcalled themselves complete while carrying only the W3C half — so the test
encoded the implementation's belief about the wire form rather than the
specification's definition of it. Exactly the F4 pattern
(verifiable-trust-infrastructure#1066), and the reason
validate_accepts_a_complete_vicfailed the moment the validator was tightened.
Both fixtures now use the form
new_vicactually emits. That is what makes thenew checks a regression test rather than a restatement: revert the validator
and nothing fails; revert the fixtures and the validator catches them.
Real invitations are unaffected
The VTC mints VICs through
DTGCredential::new_vic, and itscatalog_wire_shapeguard pins both contexts and all three
typeentries. A credential anycommunity actually issued already satisfies this — the change refuses documents
that were never valid.
Constants
Named in
openvtc-corerather than spelled inline.dtg-credentialsbuilds allthree into what it mints but exports none of them; removing the duplication
needs a public constant upstream, requested in OpenVTC/dtg-credentials#10.
Breaking
A pasted invitation lacking the DTG context or the
DTGCredentialtype is nowrefused at ingest, with a message naming what is missing, rather than accepted
and submitted to the community.
Workspace green: 16 suites, 0 failures, clippy clean.
Note on scope
This is the VIC path only — the one ingress point openvtc validates thoroughly.
Whether the other received-credential paths need the same treatment is worth a
separate look; I did not want to widen a small, high-confidence fix into a
speculative sweep.