feat(persona): mask sensitive facts in the identity pane - #282
Conversation
A fact's claim type says how carefully its value is shown to its own holder, and the identity pane now reads that: `high`-sensitivity types are painted reduced, and `s` shows the selected fact and only that one. The table is a vendored copy of the masking half of the persona claim-type registry (`specs/persona/_shared/0.1/claim-types.json`), because the agent serves no such table — there is no `persona/claim-types/list` task, and the registry's own §6 lists adding one as an open question. Unregistered tokens and the open `x:` namespace resolve to the conservative default the registry specifies: masked entirely. Sensitivity, not the mask style, decides whether to mask; the style decides how. `email.work` is `normal` with an `emailLocal` style, and is shown whole to its own holder — masking everything would teach the reveal key as a reflex, and a reveal pressed by reflex protects nothing. Masking here is not a security control and nothing in the code says it is. The value has already been fetched, decrypted by the agent and parked in this process; the mask defends against someone reading the terminal over a shoulder and against a screenshot. The control that would matter is on the read path — an `includeSensitive` flag on `persona/attribute/list`, so a listing that did not ask is never sent sensitive values — and it does not exist. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The claim-type registry draft was revised while this was being written, in two ways that this vendored copy has to follow. **Masking is no longer gated on sensitivity.** §3.3 now makes the two independent: any type whose style is not `none` is masked on screen, whatever its sensitivity, and `sensitivity: high` means the value is additionally withheld from a listing that did not ask for sensitive values. That was the tangle worth untangling — the first draft left `email.*` carrying an `emailLocal` style that no rule could ever apply, while the registry's own §1 used `a•••@example.com` to motivate the table. An email address is worth hiding from the person behind you without being worth withholding from every listing. It also puts the honesty caveat on firmer ground rather than softer: the half of the registry that is not cosmetic is now named as a read-path control, and it is exactly the one that does not exist. Nothing here can act on `sensitivity`; it is carried so a caller can read it. **Families resolve by longest registered prefix.** §4 adds a rule this copy previously argued against: a token with no exact entry takes the longest registered prefix together with the unregistered floor, most protective per axis. Without it a gated family is leavable by inventing a token — `payment.giftCard` resolved to a floor whose `release` is weaker than every registered member of the family it plainly belongs to. Because the more protective answer wins, a family can only tighten: an unregistered `name.somethingNew` still lands on the floor. `x:` never inherits a family. Prefixes match on `.` boundaries, so `paymentx.token` is not in the `payment` family. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Two follow-ups to the registry's revised §3.3, where `mask` and `sensitivity` became independent decisions. The registry now states that a mask applies to a rendering, never to what is stored or sent. The editor already fills from the value rather than from `display_value`, so nothing was wrong; the failure it avoids is quiet enough to be worth a test, because a form filled from the masked form would save eight bullets over a card number and the write would be perfectly well-formed. The rest is wording. "Sensitive" is now a specific claim about one axis — withheld from a listing that did not ask — and using it loosely for "masked" is the conflation §3.3 exists to end. The comments and test names that meant the mask now say the mask. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review3 findings need a human to review/validate. Mandatory to check: 🔒 Security Code Review Report Details🛡️ Security Code Review Report — PR #282
🗺️ Scan CoverageModules scanned: 3 · with findings: 1 · files: 10 · findings: 6
Executive Summary
🔒 Security Issues
|
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | openvtc-core/src/persona/claim_types.rs:8 |
| Finding ID | github_pr-1f23ae8f5d32 |
| CWE | CWE-1104, CWE-664 |
| OWASP | A06:2021 - Vulnerable and Outdated Components, A08:2021 - Software and Data Integrity Failures |
| MITRE ATT&CK | T1195 - Supply Chain Compromise (analogous drift/integrity risk) |
| CAPEC | CAPEC-176 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | theoretical |
| Detection Source | skill_scan |
🧠 AI Triage:
- Triaged severity: MEDIUM
- This is a process/maintenance gap (no CI check for a manually-vendored classification table), not a directly attacker-exploitable injection or auth bypass. There's no CVSS, no exploit maturity beyond 'theoretical', and exploitation requires an external precondition (upstream registry drift) plus absence of manual re-sync — not something an attacker can trigger on demand. The impact (potential PII/sensitive-claim exposure via under-masking) is real but conditional and indirect, consistent with medium severity rather than high/critical.
- Composite score: 4.9
- Environment: production
🔎 Evidence: openvtc-core/src/persona/claim_types.rs:8
//! It is vendored because **the agent does not serve this table.** ...
//! Re-sync by hand against the file named above when the registry moves; the whole of
//! the copy is the private `TABLE` in this file plus [`UNREGISTERED`].
🧭 Reachability:
- Network exposure: none
- Auth barrier: none
- Attack path: Upstream registry change (external) → no CI/version check → stale local TABLE constant (lines ~150-181) → resolve() via longest_registered_prefix() → under-masked render() output → EP-001/EP-002
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Pin a hash/version of the upstream registry snapshot and add a CI job that fetches and diffs the canonical file against the vendored TABLE, failing the build on divergence, so drift is caught mechanically rather than relying on a doc-comment reminder.
Vulnerable code:
// No build-time or CI verification exists linking TABLE to the upstream
// claim-types.json; only a doc comment instructs manual re-sync.
Secure code:
// Add a version/hash constant asserted at build or startup:
const UPSTREAM_REGISTRY_SHA256: &str = "<hash-of-claim-types.json-at-vendoring-time>";
#[cfg(test)]
fn assert_registry_matches_upstream() {
// CI job fetches/diffs canonical claim-types.json and fails build on divergence
}
Additional recommendations:
- Auto-generate claim_types.rs's TABLE from claim-types.json via codegen to eliminate manual transcription entirely.
- Add a scheduled CI job that periodically re-checks the upstream registry for changes even without a local PR trigger.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 90%
- AI Validation Evidence: EVIDENCE FOUND: Module doc explicitly states 'It is vendored because the agent does not serve this table... Re-sync by hand against the file named above when the registry moves' (lines 8-12), confirming TABLE (lines ~150-180) is manually maintained with no automated sync/CI check visible in the provided files. EVIDENCE NOT FOUND: No CI configuration files were provided to confirm or deny existence of an automated diff/check against the canonical claim-types.json; cannot verify whether such a check exists elsewhere in the repo outside provided source_files. CHANGED VS PRE-EXISTING: TABLE and its doc comments are part of claim_types.rs, added by this MR — CHANGED. VERDICT JUSTIFICATION: The described risk (classification drift due to manual vendoring) is real and acknowledged by the code's own comments, but this is a process/operational risk (CWE-1104) rather than a directly exploitable code vulnerability traceable to a specific input->sink; requires human judgment on acceptable risk tolerance for this MR.
- 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.
🟡 Missing read-path sensitivity control allows bulk disclosure of high-sensitivity persona attributes
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | openvtc-core/src/persona/claim_types.rs:27 |
| Finding ID | github_pr-54d0152de252 |
| CWE | CWE-359, CWE-200, CWE-668 |
| OWASP | A01:2021 - Broken Access Control |
| MITRE ATT&CK | T1552 - Unsecured Credentials, T1005 - Data from Local System |
| CAPEC | CAPEC-116, CAPEC-37 |
| DREAD | 8.2 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
🧠 AI Triage:
- Severity reassessed: HIGH → MEDIUM — Confirmed reachable code-level design flaw (CWE-359/200/668) with a clear, concrete attack path (EP-005) and high business impact (regulated financial/government-ID data exposure), but lacks confirmed production deployment, requires basic authentication/pairing to reach the DIDComm task, and has no weaponized/public exploit — these gaps prevent escalation to critical per the severity gates (Environment and Exploit-availability criteria not fully met).
- Composite score: 5.9
- Environment: production
Summary: The persona claim-type registry documents a Sensitivity::High read-path control that should withhold sensitive attribute values from listings, but this control does not exist anywhere in the reachable agent/task layer — only a coarse include_values boolean is available, so any listing call with values enabled returns every sensitive value unfiltered.
📝 Description:
Any component (legitimate client, malicious plugin, compromised dependency, or debug tooling) capable of calling persona_attribute_list with include_values=true receives every stored attribute value in the clear — including full payment card numbers, passport numbers, national ID numbers, tax IDs, and mobile numbers — with no server-side sensitivity gate to prevent it. The UI masking in this repo has no bearing on this exposure since it runs strictly after the value has already left agent custody.
🧪 Proof of Concept:
The module's own header proves, by direct statement of the code authors, that the read-path control needed to prevent bulk disclosure of sensitive values is absent from the system entirely. The masking implemented in this file operates strictly after the point where the exposure has already occurred.
//! Masking here happens *after* the value has been fetched, decrypted by the
//! agent, sent over DIDComm and parked in this process's memory. Everything
//! that could read it before still can. What it defends against is a person
//! reading the terminal over a shoulder, and a screenshot or a screen share
//! carrying a card number to an audience that was never asked.
//!
//! # Masking is the half of the registry this client can honour
//!
//! The registry's §3.3 makes `mask` and `sensitivity` two decisions, not one,
//! and they land in two different places:
//! ...
//! - **`sensitivity: high`** means the value is *withheld from a listing that
//! did not explicitly ask for sensitive values*. That is the half that is not
//! cosmetic, it is a read-path control, and **it does not exist**:
//! `persona_attribute_list` takes `include_values` and nothing finer
Vulnerable lines: 20, 40
🔎 Evidence: openvtc-core/src/persona/claim_types.rs:27
//! - **`sensitivity: high`** means the value is *withheld from a listing that
//! did not explicitly ask for sensitive values*. That is the half that is not
//! cosmetic, it is a read-path control, and **it does not exist**:
//! `persona_attribute_list` takes `include_values` and nothing finer, so a
//! listing this pane asks for values in is sent every card number the holder
//! holds.
💥 Impact:
Any component (legitimate client, malicious plugin, compromised dependency, or debug tooling) capable of calling persona_attribute_list with include_values=true receives every stored attribute value in the clear — including full payment card numbers, passport numbers, national ID numbers, tax IDs, and mobile numbers — with no server-side sensitivity gate to prevent it. The UI masking in this repo has no bearing on this exposure since it runs strictly after the value has already left agent custody.
Confidentiality: High — full disclosure of high-sensitivity persona attributes (payment card numbers, passport/national ID numbers, tax IDs, mobile numbers) to any caller of the listing task with include_values=true · Integrity: None · Availability: None
🧭 Reachability:
- Network exposure: internal
- Auth barrier: basic
- Attack path: EP-005 (persona_attribute_list DIDComm task, include_values=true) → agent returns all attribute values unfiltered by sensitivity → client process memory → claim_types::resolve()/render() only cosmetically mask afterward
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | high |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: Any caller able to invoke persona_attribute_list with include_values=true receives every sensitive attribute value unfiltered because no sensitivity-aware read-path control exists at the protocol/task layer, a gap the module's own comments confirm.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Add an explicit, separately-gated parameter for sensitive-value inclusion at the task/protocol layer (not just the client rendering layer), defaulting to false, and enforce it server-side (agent) so the client never receives unmasked high-sensitivity values unless explicitly and audibly requested.
Vulnerable code:
// persona_attribute_list task (external to this repo) takes only:
// include_values: bool
// — no sensitivity-aware filter exists
Secure code:
// Proposed task signature change (agent-side):
// persona_attribute_list {
// include_values: bool,
// include_sensitive_values: bool, // default: false
// }
// Agent enforces: if !include_sensitive_values { redact any attribute whose
// claim_types::resolve(type).sensitivity == High before serialising response }
Additional recommendations:
- Audit-log every listing request that resolves to sensitive-value disclosure.
- Require explicit per-attribute consent/authorization before a listing may return Sensitivity::High values.
- Add client-side UI warning/consent step when requesting bulk sensitive listings.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 90%
- AI Validation Evidence: EVIDENCE FOUND: claim_types.rs module header explicitly states 'persona_attribute_list takes include_values and nothing finer, so a listing this pane asks for values in is sent every card number the holder holds' (lines 27-32). pool.rs's list() function calls client.persona_attribute_list(None, include_values, None, None, None) with only a boolean include_values flag passed through to the VTA SDK — no per-sensitivity filtering parameter exists in this call. EVIDENCE NOT FOUND: The actual VTA SDK implementation of persona_attribute_list (in vta_sdk crate) is not provided, so I cannot confirm the wire protocol truly lacks any sensitivity filter at the server/agent side beyond what the doc comment claims; also identity_panel.rs / persona_actions.rs (the actual callers that decide whether to request include_values=true) are not provided, so the real-world reachability of 'any caller can request all sensitive values' cannot be fully traced end-to-end in the given files. CHANGED VS PRE-EXISTING: claim_types.rs is the file under review in this MR (feat/mask-sensitive-facts) and its doc comments are new; pool.rs's list()/persona_attribute_list call chain is part of the same feature area but its inclusion in the diff's changed-files list is not confirmed here — however per instructions, since claim_types.rs (part of the finding chain) is clearly changed, treat as CHANGED. VERDICT JUSTIFICATION: The finding is essentially a self-documented design gap in the module itself (the module explicitly says it cannot enforce this), not a coding bug the MR introduces as exploitable in isolation — it's a genuine architectural limitation acknowledged by the code's own comments, but full exploitability (i.e., that any untrusted actor can actually trigger bulk disclosure) depends on server-side agent behavior not present in source_files. Keeping as must_review for human confirmation of actual exploit path via the VTA agent.
- 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.
🟡 Display-only masking may be mistaken for a confidentiality control by downstream integrators
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | openvtc-core/src/persona/claim_types.rs:158 |
| Finding ID | github_pr-783f99b46979 |
| CWE | CWE-1021, CWE-200 |
| OWASP | A01:2021 - Broken Access Control |
| MITRE ATT&CK | T1005 - Data from Local System |
| CAPEC | CAPEC-116 |
| DREAD | 4.2 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | theoretical |
| Detection Source | skill_scan |
🧠 AI Triage:
- Triaged severity: MEDIUM
- The scanner's own exploitability (low), business impact (low), and exploit maturity (theoretical) assessments, combined with the complete absence of a confirmed vulnerable consumer code path (identity_panel.rs/persona_actions.rs were not available), indicate this is a speculative architectural weakness rather than a demonstrable confidentiality flaw. No CVSS score, no attacker-reachable network surface, no auth-bypass or data-exfiltration mechanism exists in the code shown — only a hypothetical future misuse scenario. This does not meet Medium's bar for 'reachable and exploitable under specific conditions' since the exploit precondition (a coding mistake in unseen code) is unconfirmed and unfalsifiable from current evidence.
- Composite score: 4.8
- Environment: production
Summary: render() returns a plain String masked cosmetically, with no type-level barrier stopping a downstream integrator from also handling the raw unmasked value elsewhere under the mistaken belief that render() already provides confidentiality; this is a design-risk inference, not a confirmed misuse site since the consuming files were not provided.
📝 Description:
If an unaudited consumer (not present in the provided files) independently logs, exports, or copies the raw attribute value while relying on render() elsewhere for 'the masking', the sensitive value could reach an unintended audience (log files, exported reports, clipboard history) despite the on-screen pane appearing correctly masked.
🧪 Proof of Concept:
render() accepts and operates on a plain borrowed string with no compile-time guarantee that this is the only path by which the underlying secret is consumed; any parallel handling of the same raw value elsewhere in a caller is entirely unguarded by this API.
impl ClaimTypeDefaults {
#[must_use]
pub fn masks_by_default(self) -> bool {
self.mask.hides_anything()
}
/// The value as this type shows it — masked when the type asks for it.
#[must_use]
pub fn render(self, text: &str) -> String {
if self.masks_by_default() {
self.mask.apply(text)
} else {
text.to_string()
}
}
}
Vulnerable lines: 153, 175
🔎 Evidence: openvtc-core/src/persona/claim_types.rs:158
#[must_use]
pub fn render(self, text: &str) -> String {
if self.masks_by_default() {
self.mask.apply(text)
} else {
text.to_string()
}
}
💥 Impact:
If an unaudited consumer (not present in the provided files) independently logs, exports, or copies the raw attribute value while relying on render() elsewhere for 'the masking', the sensitive value could reach an unintended audience (log files, exported reports, clipboard history) despite the on-screen pane appearing correctly masked.
Confidentiality: Low-Medium — contingent on an unverified, unprovided downstream misuse; would result in disclosure of masked-appearing but actually sensitive values via a secondary channel (logs, exports) · Integrity: None · Availability: None
🧭 Reachability:
- Network exposure: none
- Auth barrier: none
- Attack path: EP-002 (render() call site in an unprovided consumer such as identity_panel.rs) → raw secret &str held alongside masked String output → potential misuse of raw value in a parallel, unaudited sink
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | low |
| Business impact | low |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: A downstream consumer of ClaimTypeDefaults::render() could, in principle, also independently mishandle the raw unmasked value elsewhere, since no type-level barrier prevents it — though no such consumer code was available to confirm this in practice.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Introducing a newtype without Display/Debug forces every consumption site to go through an explicit, reviewable unmasking call, preventing accidental propagation of the raw secret to logs, exports, or other sinks that don't call render().
Vulnerable code:
pub fn render(self, text: &str) -> String {
if self.masks_by_default() {
self.mask.apply(text)
} else {
text.to_string()
}
}
Secure code:
// Wrap raw values in a non-Display, non-Debug newtype at the point of receipt
// so any attempt to print/log/export the raw value without going through an
// explicit, audited unmask function fails to compile.
pub struct RawAttributeValue(String);
impl RawAttributeValue {
pub fn render(&self, defaults: ClaimTypeDefaults) -> String {
defaults.render(&self.0)
}
// No Display/Debug impl — raw value cannot be accidentally printed.
}
Additional recommendations:
- Add a clippy/CI lint or grep-based check flagging any direct use of raw attribute values that bypasses ClaimTypeDefaults::render().
- Document explicit DO/DON'T examples for consumers such as identity_panel.rs and persona_actions.rs.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 90%
- AI Validation Evidence: EVIDENCE FOUND: render() function (lines 158-165 approx) is exactly as quoted: 'pub fn render(self, text: &str) -> String { if self.masks_by_default() { self.mask.apply(text) } else { text.to_string() } }'. It takes &str and returns String with no distinct wrapper type differentiating masked output from raw secret. pool.rs's PoolAttribute::value_line also passes raw strings around without a secret-wrapper type. EVIDENCE NOT FOUND: No SecretString/zeroize-style wrapper type exists anywhere in claim_types.rs or pool.rs; searched for any 'Zeroize', 'Secret', 'redact' type definitions — none found. However, whether any actual downstream caller/misuse occurs (identity_panel.rs is not provided) cannot be confirmed. CHANGED VS PRE-EXISTING: render() and ClaimTypeDefaults are part of claim_types.rs, the file added in this MR (feat/mask-sensitive-facts) — CHANGED. VERDICT JUSTIFICATION: This is a design/type-safety observation (CWE-1021) rather than a concretely exploitable vulnerability with a traced input->sink; it describes a theoretical risk of future misuse rather than demonstrated exploitation in the given code. Human review appropriate to judge whether this rises to an actionable finding vs an accepted design tradeoff (the module explicitly disclaims itself as not a security control).
- 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.
Details
🛡️ Threat Model & Affect Analysis — PR #282
| Field | Value |
|---|---|
| Repository | OpenVTC/openvtc |
| Branch | feat/mask-sensitive-facts → main |
| Generated | 2026-09-07 |
ℹ️ 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
Introduces a new vendored persona claim-type registry module (claim_types.rs) that classifies claim types by sensitivity and mask style, enabling the identity pane to display sensitive facts (payment cards, government IDs, phone numbers, emails, DOB) masked by default with a single-fact 's'-to-reveal mechanism. The CHANGELOG documents the feature and explicitly disclaims it as a non-security-control, cosmetic shoulder-surfing/screenshot mitigation only.
Diff: +510 / -0 lines
Types: feature, security, docs
⚠️ Security Implications
🟠 PR surfaces (but does not fix) missing sensitivity-based read-path control in persona_attribute_list
PR surfaces (but does not fix) missing sensitivity-based read-path control in persona_attribute_list
Action: Add a sensitivity-aware filter (e.g., excludeHighSensitivity or per-attribute sensitivity request parameter) to the persona_attribute_list agent task, and until available, treat any bulk include_values=true call against personas with high-sensitivity attributes as a flagged/audited operation.
⚪ Conservative default-deny masking for unknown and extension-namespace claim types
Conservative default-deny masking for unknown and extension-namespace claim types
Action: No action needed; maintain this default-deny posture in any future modification of resolve().
🟡 No compile-time or type-level enforcement preventing downstream misuse of render() as if it were a real confidentiality control
No compile-time or type-level enforcement preventing downstream misuse of render() as if it were a real confidentiality control
Action: Introduce a non-Display/non-Debug wrapper type for raw high-sensitivity claim values, and/or add a CI lint (clippy custom lint or grep-based check) flagging direct printing/logging of raw attribute values that bypass ClaimTypeDefaults::render().
🟡 Unzeroized intermediate allocations during mask application may retain sensitive plaintext fragments in memory
Unzeroized intermediate allocations during mask application may retain sensitive plaintext fragments in memory
Action: Adopt a zeroizing secret type (e.g., the zeroize or secrecy crate) for high-sensitivity claim values from receipt through rendering, and minimize/immediately wipe intermediate buffers used during mask computation.
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| openvtc-core::persona::claim_types (new module) | medium | new | A brand-new library module was added providing resolve() and ClaimTypeDefaults::render() to classify and mask persona claim values for displ |
| Identity Pane / 's'-to-reveal UI (referenced but not in diff) | high | modified | Per CHANGELOG, these files (not included in this diff/source payload) are modified to call the new claim_types module and implement the sing |
| persona_attribute_list (external agent task, out of repo) | critical | modified | Not code-changed by this PR, but the PR's own documentation newly and explicitly calls attention to the fact that this external, DIDComm-ser |
📁 File Classifications
openvtc-core/src/persona/claim_types.rs
- Type: security
CHANGELOG.md
- Type: docs
🛡️ STRIDE Threat Model
Identified Threats (10)
🟠 STRIDE-1: Sensitive Data Over-Exposure via Missing Read-Path Enforcement in persona_attribute_list
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | High |
| Likelihood | Very Likely |
| CVSS | 7.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | High |
| CWE | CWE-359,CWE-200,CWE-668 |
| CAPEC | CAPEC-116,CAPEC-37 |
| OWASP | A01:2021 - Broken Access Control, A04:2021 - Insecure Design |
Description: persona_attribute_list agent task in COMP-003 allows sensitive value disclosure due to the absence of a sensitivity-aware filtering rule (only include_values boolean exists), resulting in bulk exposure of high-sensitivity attributes (payment cards, passports, tax IDs) to any listing caller that requests values
Evidence: openvtc-core/src/persona/claim_types.rs:27-40
//! - **`sensitivity: high`** means the value is *withheld from a listing that\n//! did not explicitly ask for sensitive values*. That is the half that is not\n//! cosmetic, it is a read-path control, and **it does not exist**:\n//! `persona_attribute_list` takes `include_values` and nothing f
Attack Scenario:
- An attacker-controlled or compromised UI/agent-facing component calls the
persona_attribute_listDIDComm task withinclude_values: true(EP-005). - The agent has no concept of
Sensitivity::Highas documented inclaim_types.rsmodule header ('persona_attribute_list takes include_values and nothing finer'), so it returns ALL attribute values, including gov IDs, payment cards, and tax IDs, regardless of the claim type's sensitivity classification. - The caller receives the full unmasked value set over the wire/DIDComm channel and stores it in local process memory.
claim_types::resolve()is only invoked afterward by rendering code (e.g.,identity_panel.rs, not provided) purely for display masking — it has no ability to prevent the value from having already left the agent.- Any code path, malicious plugin, debug log, crash dump, or memory-scraping technique that touches the in-memory listing response obtains the full sensitive value set, bypassing the pane's cosmetic mask entirely.
🔎 Threat Clue: Derived from COMP-001, COMP-003 via EP-005
- Data Flows: persona_attribute_list -> agent -> client process memory
Preconditions: Attacker (or compromised component) can invoke or observe results of persona_attribute_list with include_values: true., No agent-side, protocol-level sensitivity filter exists (confirmed by module doc comments).
Existing Controls: Module explicitly documents the gap and warns downstream consumers not to treat masking as a security control. • UI-layer masking (out of scope file) reduces shoulder-surfing/screen-share exposure of rendered values.
Recommended Mitigations: Add a sensitivity filter parameter to the persona_attribute_list (or persona/attribute/list) agent task so listings can explicitly request/exclude high-sensitivity values. • Default persona_attribute_list to include_values: false for high-sensitivity claim types unless explicit per-attribute consent/authorization is proven. • Log and audit every listing request that resolves to include_values: true on high-sensitivity attribute sets. • Add client-side warning/consent UI when a caller requests bulk sensitive-value listings.
🟡 STRIDE-2: False Sense of Confidentiality via Cosmetic-Only Masking Misinterpreted as Security Control
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-1021,CWE-200 |
| CAPEC | CAPEC-116 |
| OWASP | A04:2021 - Insecure Design |
Description: render() function in ClaimTypeDefaults in COMP-001 allows a misplaced trust assumption due to downstream consumers (e.g. identity_panel.rs, persona_actions.rs — not provided) potentially treating masked display as equivalent to actual data protection, resulting in developers or integrators skipping real access controls believing the UI mask already provides confidentiality
Evidence: openvtc-core/src/persona/claim_types.rs:18-23
//! # This is not a security control, and it must not be described as one\n//!\n//! Masking here happens *after* the value has been fetched, decrypted by the\n//! agent, sent over DIDComm and parked in this process's memory.
Attack Scenario:
- A downstream developer integrates
ClaimTypeDefaults::render()(EP-002) into a new UI surface, log line, export function, or plugin without reading the extensive module-level caveat. - They assume
mask.hides_anything()== 'this value is protected' and therefore pipe the already-masked display string to a log, but separately also pass the raw unmasked value to another output path (e.g., clipboard-copy, export-to-file), believing the earlier masking step already satisfied any confidentiality requirement for the underlying data. - Because the module provides no compile-time or runtime enforcement (no
SecretString, no zeroization, no distinct wrapper type preventing accidental unmasked propagation) tyingSensitivity::Highto actual data handling, the unmasked value flows to the unintended sink. - Attacker who can observe that secondary sink (clipboard history, exported file, log aggregator) recovers the full sensitive value despite the pane 'looking secure'.
🔎 Threat Clue: Derived from COMP-001, COMP-008 via EP-002, EP-004
- Data Flows: ClaimTypeDefaults::render output -> UI rendering / logging / export
Preconditions: A downstream consumer of ClaimTypeDefaults exists that does not fully honor the documented boundary (plausible given 9/10 files not shown, including identity_panel.rs and persona_actions.rs referenced in CHANGELOG as consuming this masking logic)., No type-level enforcement (e.g., a wrapper that prevents printing/copying the raw value) accompanies the sensitivity classification.
Existing Controls: Extensive doc-comments explicitly disclaim security-control status. • CHANGELOG explicitly states 'This is not a security control and the pane does not claim it is.'
Recommended Mitigations: Introduce a distinct Rust newtype (e.g. SensitiveValue) that does not implement Display/Debug by default, forcing explicit, auditable unmasking calls at every consumption site. • Add lint/CI rule (e.g., clippy custom lint or grep-based check) flagging any direct use of raw attribute values bypassing ClaimTypeDefaults::render(). • Extend documentation with concrete DO/DON'T code examples for consumers (identity_panel.rs, persona_actions.rs).
🟡 STRIDE-3: Registry Drift via Unenforced Manual Vendoring of claim_types.rs
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 5.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-1104,CWE-664 |
| CAPEC | CAPEC-176 |
| OWASP | A06:2021 - Vulnerable and Outdated Components, A08:2021 - Software and Data Integrity Failures |
Description: TABLE constant in claim_types.rs allows classification drift due to manual, unversioned, unchecked vendoring against the upstream claim-types.json registry, resulting in a persona claim type being classified with a weaker sensitivity/mask than the authoritative spec, causing accidental exposure of newly-added or newly-reclassified sensitive fields
Evidence: openvtc-core/src/persona/claim_types.rs:8-12
//! Re-sync by hand against the file named above when the registry moves; the whole of\n//! the copy is the private `TABLE` in this file plus [`UNREGISTERED`].
Attack Scenario:
- The upstream registry
dtgwg-trust-tasks-tf/specs/persona/_shared/0.1/claim-types.jsonis updated to add a new sensitive claim type or to reclassify an existing type's sensitivity from Normal to High (e.g., a new biometric or health-adjacent claim family). - Because there is no automated sync, checksum, or version-pinning mechanism referenced in the code (only a doc comment instructing humans to 're-sync by hand'), the vendored
TABLEconstant inclaim_types.rs(lines ~150-180) is not updated. - A new persona attribute of that claim type is added to a user's persona and displayed through
resolve()andrender(). - Because the token doesn't match any TABLE entry or family prefix, it correctly falls back to
UNREGISTERED(High/Full) per the current fallback logic — BUT if the new type happens to share a prefix with an existing, more permissively-configured family (e.g., a new low-sensitivity token added under an existing family whose entry is Normal/None, such asname.*ororg.*), the drift causes an under-masked display for what should now be a sensitive value. - Attacker observing the pane (shoulder-surf, screen-share, screenshot) or a downstream log sees the value in the clear because the local vendored copy never learned the reclassification.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: Upstream registry -> manual sync -> TABLE constant -> resolve()
Preconditions: Upstream registry evolves independently of this repository., No CI check cross-validates TABLE against the canonical claim-types.json., New/reclassified claim type happens to fall under an existing lenient family prefix.
Existing Controls: Doc comments direct maintainers to manually re-sync against the named source-of-truth file. • Unit tests validate current TABLE behavior but cannot detect upstream drift.
Recommended Mitigations: Add a CI job that fetches/diffs the canonical claim-types.json against the vendored TABLE and fails the build on divergence. • Embed a version/hash constant of the upstream registry snapshot and assert it at build or startup time. • Add automated codegen from claim-types.json to claim_types.rs to eliminate manual transcription risk.
🟡 STRIDE-4: Sensitive Value Persistence in Unprotected Process Memory Post-Rendering
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 4.8 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-226,CWE-316 |
| CAPEC | CAPEC-37,CAPEC-545 |
| OWASP | A04:2021 - Insecure Design |
Description: render() and apply() functions in COMP-001 allow sensitive data lingering due to the use of owned String allocations without zeroization or secure-memory wrapping for high-sensitivity values (payment cards, passports, tax IDs), resulting in recoverable plaintext secrets in process memory, swap, or core dumps after use
Evidence: openvtc-core/src/persona/claim_types.rs:80-99
let chars: Vec<char> = text.chars().collect();\n...\nlet tail: String = chars[chars.len() - keep..].iter().collect();\nformat!("{}{tail}", BULLET.to_string().repeat(chars.len() - keep))
Attack Scenario:
ClaimTypeDefaults::render(self, text: &str) -> String(EP-002) is called with a raw sensitive value such as a full payment card number.- Even when
masks_by_default()is true, the rawtext: &strparameter (the unmasked plaintext) must already exist in memory to be passed in, andMaskStyle::applyperforms additional heap allocations (chars: Vec<char>,tail: String,format!results) that copy fragments of the original secret. - None of these allocations use a zeroizing allocator or
Dropimplementation that wipes memory; standard RustString/Vec<char>drop simply deallocates without zeroing. - An attacker with local memory-read capability (compromised process, debugger attach, core dump analysis after a crash, swap-file forensics on a non-encrypted disk) can recover the plaintext card/passport/tax-ID value long after the UI has moved on to a masked display.
- This is compounded by 'reveal' logic (EP-003,
identity_panel.rs, not provided) which per the CHANGELOG stores/holds the unmasked value transiently for the 's' keypress reveal feature, extending the plaintext's memory lifetime.
🔎 Threat Clue: Derived from COMP-001, COMP-007, COMP-008 via EP-002, EP-003
- Data Flows: Decrypted attribute value -> render()/apply() -> transient String allocations -> process memory
Preconditions: Attacker has local code execution, debugger access, or access to memory/swap/core-dump artifacts on the host running the OpenVTC client., Host disk/swap is unencrypted or improperly secured.
Existing Controls: None identified in the provided source; module explicitly limits its threat model to shoulder-surfing/screen-sharing, not memory forensics.
Recommended Mitigations: Use a zeroizing secret type (e.g., the secrecy or zeroize crate) for high-sensitivity claim values from the point of DIDComm receipt through rendering and reveal. • Minimize the lifetime of raw secret values by immediately overwriting buffers after use. • Disable or encrypt swap for hosts handling high-sensitivity persona data; document this as an operational control. • Avoid intermediate Vec<char>/String copies of secrets where possible; operate on byte slices with explicit wipe-after-use.
🔵 STRIDE-5: Reveal-State Race Condition Enabling Unintended Persistent Disclosure
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.5 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:P/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-362,CWE-841 |
| CAPEC | CAPEC-26 |
| OWASP | A04:2021 - Insecure Design |
Description: 's' keypress reveal handler in COMP-007 allows unintended sustained disclosure due to potential TOCTOU/state-tracking flaws in resetting the reveal flag on selection change or tab switch (logic not shown, only described in CHANGELOG), resulting in a masked fact remaining revealed longer than intended, e.g. during a screen share
Evidence: openvtc/src/ui/pages/main/components/identity_panel.rs:N/A
N/A (file not provided; behavior inferred from CHANGELOG.md description of 's' reveal feature)
Attack Scenario:
- Holder presses 's' (EP-003) to reveal one masked fact in the identity panel, per CHANGELOG: 'moving the selection, changing tab or re-reading puts it back.'
- The reveal state is presumably tracked in UI component state in
identity_panel.rs(not provided in this analysis) rather than being re-derived idempotently from current render context. - If the reset-on-selection-change/tab-switch logic has an edge case (e.g., async re-render race, rapid key events, or a code path that changes displayed data without changing 'selection' index, such as a background refresh of persona attributes), the revealed value could remain shown after the holder believes it was hidden.
- An onlooker or screen-recording capturing the pane during this window observes the sensitive value despite the holder's expectation that it reverted to masked.
- Because this logic lives entirely in files not provided for analysis (identity_panel.rs, persona_actions.rs), this threat is inferred from the documented UX behavior and represents a plausible state-management defect class (CWE-362/CWE-841) rather than a confirmed code-level bug.
🔎 Threat Clue: Derived from COMP-007, COMP-008 via EP-003, EP-004
- Data Flows: Keypress event -> reveal-state toggle -> row render
Preconditions: Reveal state managed via mutable UI state without provably correct invalidation on all state-changing events., Attacker or observer has visual/screen-recording access during the vulnerable window.
Existing Controls: Documented intended behavior: reveal reverts on selection change, tab change, or re-read. • Single-fact reveal scope (not bulk) limits blast radius per CHANGELOG.
Recommended Mitigations: Derive reveal state strictly from current selection index each render pass rather than persisting a sticky boolean flag. • Add automatic reveal timeout (e.g., auto-mask after N seconds) independent of selection-change events. • Add UI/integration tests covering rapid tab-switch and background-refresh scenarios to confirm reveal state resets correctly.
🔵 STRIDE-6: Family-Prefix Boundary Confusion Enabling Sensitivity Downgrade
| Field | Detail |
|---|---|
| Category | Spoofing, Information Disclosure |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-863,CWE-20 |
| CAPEC | CAPEC-267 |
| OWASP | A04:2021 - Insecure Design |
Description: longest_registered_prefix function in COMP-001 allows a sensitivity classification error due to reliance on '.' boundary matching that could be crafted around by an attacker who controls or influences claim-type token naming upstream, resulting in a crafted token inheriting a more permissive family classification than intended
Evidence: openvtc-core/src/persona/claim_types.rs:215-224
fn longest_registered_prefix(claim_type: &str) -> Option<ClaimTypeDefaults> {\n let mut cut = claim_type.len();\n while let Some(dot) = claim_type[..cut].rfind('.') {\n if let Some(found) = entry(&claim_type[..dot]) {\n return Some(found);\n }\n cut = dot;\n
Attack Scenario:
- Code correctly guards against
paymentx.fooinheriting thepaymentfamily via.boundary checks (longest_registered_prefix, lines ~215-224) — confirmed correct bya_family_matches_on_segment_boundariestest. - However, if an attacker (e.g., a malicious credential issuer or a compromised persona pool source, COMP-002/COMP-003) can mint arbitrary claim-type token strings for a persona attribute (not validated by this module — validation happens, if at all, in
pool.rs/profile.rs, not provided), they could craft a token such asname.creditCardNumberthat inherits the lenientnamefamily (Normal/None) rather than being classified High/Full, because the resolver has no semantic understanding of the token's actual content — it trusts the family prefix. - This causes a genuinely sensitive value (a credit card number mislabeled under the
name.*namespace) to render unmasked viarender()(EP-002), because the classification is driven entirely by string prefix matching, not by actual data content or a verified/signed claim-type registry. - The mislabeled but unmasked sensitive value is then displayed in the identity panel, exposing it to shoulder-surfing/screen-share despite being objectively high-sensitivity data.
🔎 Threat Clue: Derived from COMP-001, COMP-002, COMP-003 via EP-001
- Data Flows: Persona attribute claim_type string -> resolve() -> render()
Preconditions: Attacker or malicious/compromised upstream source controls the claim_type string attached to a persona attribute., No validation exists elsewhere in the pipeline to ensure claim_type strings semantically match their declared family and are only assignable by a trusted, closed vocabulary source.
Existing Controls: Strict '.' boundary matching prevents naive prefix-based bypass (paymentx does not match payment). • Exact-token matches take precedence over prefix inheritance.
Recommended Mitigations: Validate claim_type tokens against a closed, versioned vocabulary at the point of attribute ingestion (in pool.rs/profile.rs, not shown) rather than trusting arbitrary strings supplied at persona-attribute creation time. • Reject or quarantine any inbound persona attribute whose claim_type is not present in the canonical registry (treat truly unknown tokens as UNREGISTERED, which the code already does, but ensure this floor is applied upstream before display, not only at render time). • Consider content-based heuristics (e.g., Luhn-check-like detection) as a defense-in-depth signal for future hardening, independent of claim_type trust.
🔵 STRIDE-7: Denial-of-Service via Pathological Claim-Type Token Length in resolve()
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 2.4 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:L |
| Residual Severity | Low |
| CWE | CWE-400,CWE-1050 |
| CAPEC | CAPEC-130 |
| OWASP | A04:2021 - Insecure Design |
Description: resolve() function in COMP-001 allows minor CPU amplification due to repeated linear TABLE scans (entry()) inside a while-loop over rfind('.') for attacker-influenced claim_type strings, resulting in increased per-call latency proportional to token length and dot-count under a crafted extremely long claim_type value
Evidence: openvtc-core/src/persona/claim_types.rs:191-199
pub fn resolve(claim_type: &str) -> ClaimTypeDefaults {\n if let Some(exact) = entry(claim_type) { return exact; }\n if claim_type.starts_with(EXTENSION_PREFIX) { return UNREGISTERED; }\n longest_registered_prefix(claim_type).map_or(UNREGISTERED, |family| family.tightest(UNREGISTERED))\n}
Attack Scenario:
- An attacker or malicious upstream persona source crafts a persona attribute with an extremely long
claim_typestring containing many.characters (e.g., thousands of dots), which is not length-validated in this module. resolve()(EP-001) callslongest_registered_prefix, which loops callingclaim_type[..cut].rfind('.')andentry()(a linear O(n) scan over the ~29-entry TABLE) for every dot found, giving roughly O(d * n) work where d is dot-count.- If many such crafted attributes are rendered in a persona attribute listing (identity_panel.rs, not shown), the cumulative CPU cost could measurably slow down UI rendering, though the small TABLE size (29 entries) and typical claim-type lengths make this a low-severity, low-likelihood issue rather than a true DoS vector.
- No panic or memory exhaustion occurs (string slicing on
.boundaries is safe on UTF-8 char boundaries as.is single-byte ASCII), so worst case is CPU/latency degradation, not crash.
🔎 Threat Clue: Derived from COMP-001 via EP-001
- Data Flows: Persona attribute claim_type -> resolve() -> longest_registered_prefix() loop
Preconditions: Attacker controls or influences claim_type strings on persona attributes ingested into the pool., No maximum-length validation for claim_type strings prior to reaching resolve().
Existing Controls: Small, fixed-size TABLE (about 29 entries) bounds per-scan cost. • Rust's safe string handling prevents any panic/UB from pathological input.
Recommended Mitigations: Enforce a reasonable maximum length (e.g., 256 bytes) on claim_type strings at ingestion (pool.rs/profile.rs). • Consider a HashMap-based lookup for exact-match TABLE entries to reduce per-call cost, though current scale makes this a low-priority optimization. • Cache resolve() results per distinct claim_type string within a rendering pass to avoid repeat computation.
🔵 STRIDE-8: Reveal Action Absence of Audit Logging Enabling Repudiation of Sensitive Data Access
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.8 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-778,CWE-223 |
| CAPEC | CAPEC-93 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: 's' key reveal action in COMP-007 allows unattributable sensitive-data viewing due to absence of any documented audit/logging mechanism when a masked high-sensitivity fact is revealed, resulting in inability to later prove who viewed a specific sensitive attribute value or when, undermining accountability in shared/multi-user or forensic scenarios
Evidence: openvtc/src/state_handler/persona_actions.rs:N/A
N/A (file not provided; inferred absence of audit logging from CHANGELOG description and absence of any logging crate usage in claim_types.rs)
Attack Scenario:
- A local user (or someone with access to an unlocked/shared session) presses 's' (EP-003) to reveal a high-sensitivity fact such as a payment card number or passport ID.
- Nothing in the provided code, module design, or CHANGELOG entry indicates any audit trail, timestamped log, or event record is created when a reveal action occurs.
- Later, if the sensitive value is misused (e.g., a card number is memorized and used fraudulently by a session co-user), there is no local evidence to attribute which session/user performed the reveal, undermining incident response and accountability.
- This is a design gap compounding STRIDE-1: because the read-path control does not exist upstream either, there is no layered accountability at either the agent or the client for sensitive-value access.
🔎 Threat Clue: Derived from COMP-007 via EP-003
- Data Flows: Keypress -> reveal state -> unlogged display of sensitive value
Preconditions: Local session shared or accessible by multiple parties (e.g., unattended terminal, shared kiosk-style deployment)., No external audit logging layer wraps the reveal feature.
Existing Controls: None identified for the reveal action specifically in the provided/described code.
Recommended Mitigations: Emit a structured audit log entry (attribute id, claim_type, timestamp, session/user identifier) on every reveal action. • Consider requiring a secondary confirmation (e.g., re-auth or explicit confirm dialog) before revealing High-sensitivity claim types specifically. • Expose reveal-audit logs to the holder so they can review their own access history for anomalies.
⚪ STRIDE-9: Unicode Normalization Ambiguity in EmailLocal Masking Enabling Homoglyph-Based Confusion
| Field | Detail |
|---|---|
| Category | Spoofing |
| Severity | Informational |
| Likelihood | Very Unlikely |
| CVSS | 1.6 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | None |
| CWE | CWE-1007,CWE-176 |
| CAPEC | CAPEC-632 |
| OWASP | A03:2021 - Injection |
Description: MaskStyle::EmailLocal apply function in COMP-001 allows minor visual-spoofing ambiguity due to naive split_once('@') and first-character display without Unicode normalization or confusable-character detection, resulting in a masked email display that could visually resemble a different email address than the one actually held
Evidence: openvtc-core/src/persona/claim_types.rs:103-112
Self::EmailLocal => match text.split_once('@') {\n Some((local, domain)) if !local.is_empty() && !domain.is_empty() => {\n let first = local.chars().next().unwrap_or(BULLET);\n format!("{first}{}@{domain}", BULLET.to_string().repeat(3))\n }\n _ => full(),\n}
Attack Scenario:
- A persona attribute of claim_type
email.personaloremail.workcontains an address using Unicode homoglyphs or right-to-left override characters in the local part (e.g., a Cyrillic 'а' instead of Latin 'a', or an RTL control character), which is technically valid UTF-8 and passed through unchanged by the masking module. MaskStyle::EmailLocal.apply()(lines ~103-112) extracts only the firstcharvialocal.chars().next()and displays{first}•••@{domain}, with no confusable-character normalization (e.g., NFKC) or bidi-control stripping.- A holder reviewing their masked identity panel sees a rendered first-character-plus-domain string that may visually mislead them about which of two similar-looking stored addresses is truly associated with a given persona/context, since only one character of the local part is ever shown.
- This is a low-impact UX/spoofing edge case rather than a data-exfiltration vector, since the domain portion (the more security-relevant part) is always shown in full.
🔎 Threat Clue: Derived from COMP-001 via EP-002
- Data Flows: Persona email attribute -> MaskStyle::EmailLocal.apply() -> identity panel display
Preconditions: Attacker can influence the stored email value (e.g., via a malicious credential issuer) to include homoglyph or bidi-control characters in the local part., Holder relies on the single visible character to distinguish between similar addresses.
Existing Controls: Domain (the higher-signal portion for phishing/spoofing detection) is always shown in full, unmasked. • Fixed fallback to Full mask on malformed/empty local or domain parts.
Recommended Mitigations: Apply Unicode normalization (NFKC) and strip bidi control characters before computing the displayed first character. • Consider flagging/rejecting claim values containing bidi override or confusable characters at ingestion time. • Document this as a known, accepted low-risk limitation given already-limited masking guarantees.
🔵 STRIDE-10: No Explicit-Override Storage Enabling Coarse-Grained Sensitivity Assignment Only
| Field | Detail |
|---|---|
| Category | Elevation of Privilege |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 2.8 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-284,CWE-863 |
| CAPEC | CAPEC-122 |
| OWASP | A01:2021 - Broken Access Control |
Description: resolve() function in COMP-001 allows an unaddressable per-holder tightening/loosening gap due to Rule 1 (holder explicit override) being explicitly documented as unimplemented ('there is nowhere to store one'), resulting in holders being unable to mark an atypical value (e.g., a nickname stored under a normally-sensitive family, or a normally-public value they consider private) with a stricter or looser policy than the vendored default
Evidence: openvtc-core/src/persona/claim_types.rs:175-180
/// 1. Rule 1 — a holder's explicit override — is **not implemented, because\n/// there is nowhere to store one.** `persona/attribute/put` has no\n/// `sensitivity` or `mask` member and the SDK's attribute carries neither
Attack Scenario:
- A holder stores a persona attribute they consider especially sensitive (e.g., a personal nickname used as a safety pseudonym) under a claim_type that the vendored TABLE classifies as Normal/None (e.g.,
name.given). - The holder has no mechanism, per the doc comment ('Rule 1 ... is not implemented, because there is nowhere to store one. persona/attribute/put has no sensitivity or mask member'), to override this and force masking for that specific attribute.
- The value is rendered unmasked by default in the identity panel (EP-004) purely because of its claim_type family classification, regardless of the individual holder's actual privacy preference for that specific value.
- In a shoulder-surfing, screen-share, or over-the-shoulder scenario, this specific value is disclosed against the holder's actual (but unexpressable) preference — a design gap rather than a code defect, but one with direct confidentiality impact for atypical holder needs (e.g., safety-sensitive pseudonyms).
🔎 Threat Clue: Derived from COMP-001, COMP-008 via EP-001, EP-004
- Data Flows: Holder attribute creation (out of scope) -> resolve() default -> render()
Preconditions: Holder has a legitimate need to override the default classification for a specific attribute., No SDK/agent-side schema field exists yet to carry such an override (confirmed by doc comment).
Existing Controls: Doc comment clearly flags this as a known, deliberate, temporary gap pending SDK/protocol support. • Conservative UNREGISTERED floor at least protects entirely unknown types by default.
Recommended Mitigations: Extend persona/attribute/put and the SDK attribute schema to carry optional per-attribute sensitivity/mask override fields. • Implement Rule 1 in resolve() (or an overload taking an optional per-attribute override) as the highest-priority resolution step once the schema supports it, exactly as the doc comment anticipates. • In the interim, provide holders a client-local-only 'always mask this specific fact' preference stored outside the persona record, applied purely at render time.
🍝 PASTA Threat Model
Application Purpose
OpenVTC is a decentralized identity/persona client that lets holders manage verifiable-credential-derived personal attributes (identity, payment, government ID, contact facts) and displays them via a terminal UI, with this specific change adding client-side visual masking of sensitive facts using a vendored copy of a shared persona claim-type registry.
Inherent Risks
- The client depends on a DIDComm agent it does not control for actual decryption and delivery of sensitive persona attribute values, so client-side controls cannot compensate for agent-side data exposure.
- The upstream claim-type registry is maintained in a separate specification repository, creating an inherent synchronization/drift risk for any vendored copy.
- The masking feature is explicitly documented as non-security-control, creating latent risk that downstream integrators or future maintainers will misunderstand its guarantees over time.
Objectives
Risk: Accept UI-layer masking as a low-assurance defense-in-depth measure only, not a substitute for agent-side access control.; Treat any unregistered or ambiguous claim type conservatively (masked/high-sensitivity) by default.
Business: Provide holders a trustworthy, terminal-based interface for managing decentralized identity/persona attributes.; Differentiate OpenVTC via privacy-respecting, low-footprint display of sensitive personal facts.
Security: Prevent casual/shoulder-surfing disclosure of sensitive persona facts in the terminal UI.; Ensure the masking layer is not mistaken for, or relied upon as, an actual confidentiality/access control.
Financial: Avoid liability and remediation costs associated with sensitive data (payment card, government ID) exposure incidents.; Minimize engineering cost of maintaining a vendored registry copy versus building/depending on a served registry.
Compliance: Support data minimization and privacy-by-design principles relevant to GDPR/CCPA-style handling of personal and financial identifiers.; Avoid unmasked display of payment card and government ID data in shared/observable environments.
Functional: Resolve any claim_type token to a masking policy consistent with the shared persona registry.; Allow a holder to selectively reveal exactly one masked fact on demand via a keypress.
Operational: Keep the vendored claim-type table synchronized with the canonical registry over time.; Ensure UI rendering performance remains responsive even with many persona attributes.
Business Impact Analysis (2)
BIA-1: Sensitive Persona Attribute Listing and Display (High)
The end-to-end process by which a holder's persona attributes are fetched via DIDComm from the agent, classified by claim type, and displayed (masked or revealed) in the terminal identity panel.
MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours
- Stakeholders: Compliance Officers / Holders / OpenVTC Client Maintainers / Persona Registry Maintainers
- Dependencies: DIDComm Agent / Persona Attribute Store / Persona Claim-Type Registry / Terminal UI Renderer
- Disruptions: Agent returns unfiltered sensitive values to a listing request due to missing read-path enforcement. / Vendored claim-type table drifts from canonical registry, causing under-masking. / Reveal-state logic fails to reset, exposing a fact longer than intended during a screen share.
- Impacts: Exposure of payment card or government ID data to an unauthorized viewer. / Regulatory exposure under data protection law for mishandling sensitive personal data. / Loss of holder trust in the platform's privacy claims.
BIA-2: Claim-Type Registry Synchronization (Medium)
The manual process of keeping the vendored TABLE in claim_types.rs consistent with the canonical dtgwg-trust-tasks-tf claim-types.json registry as it evolves.
MTD: 30 days 00:00 hours | RTO: 07 days 00:00 hours | RPO: N/A
- Stakeholders: OpenVTC Client Maintainers / Persona Registry Maintainers
- Dependencies: dtgwg-trust-tasks-tf specification repository / claim_types.rs vendored TABLE
- Disruptions: Registry adds/reclassifies a claim type without a corresponding manual update to TABLE. / Maintainer overlooks re-sync during a release cycle.
- Impacts: Newly sensitive claim types displayed unmasked until manually corrected. / Erosion of confidence in the masking feature's accuracy.
Technical Scope
Roles (3): RO-1 Holder · RO-2 Local Observer · RO-3 Compromised Process / Local Attacker
Actors (3): AC-1 Holder User · AC-2 DIDComm Agent Service · AC-3 OpenVTC Terminal Client Process
Entry Points (5): EP-1 claim_types::resolve · EP-2 ClaimTypeDefaults::render · EP-3 Reveal Keypress Handler · EP-4 Identity Panel Row Rendering · EP-5 persona_attribute_list Agent Task
Threat Actors (4): TA-1 Shoulder-Surfing Observer · TA-2 Malicious/Compromised Upstream Data Source · TA-3 Local Forensic/Memory Attacker · TA-4 Agent-Side Insider or Compromised Agent
Infrastructure (1): IF-1 OpenVTC Terminal Client Host
Trust Boundaries (3): TB-1 DIDComm Agent Boundary · TB-2 OpenVTC Client Process Boundary · TB-3 Local Host / Display Boundary
External Entities (2): EE-1 DIDComm Agent · EE-2 Persona Claim-Type Registry (dtgwg-trust-tasks-tf)
System Components (4): SC-1 claim_types Registry Module · SC-2 DIDComm Agent (persona_attribute_list task) · SC-3 Identity Panel UI Component · SC-4 Persona Pool / Attribute Store
Resources And Assets (3): RA-1 Persona Attribute Values · RA-2 Vendored Claim-Type Table · RA-3 Reveal State
Technologies And Dependencies (2): TD-1 Rust Standard Library (String/Vec/char handling) · TD-2 DIDComm Protocol
Use Cases (3)
- Identity Panel Sensitive Fact Display: A holder opens the identity panel; each persona attribute's claim_type is resolved via claim_types::resolve() and rendered masked or unmasked according to its default classification.
- Single Fact Reveal via Keypress: A holder selects a masked fact row and presses 's' to temporarily reveal its full value; the reveal reverts automatically on selection change, tab switch, or re-read.
- Persona Attribute Listing Retrieval: The client requests a persona attribute listing from the DIDComm agent, optionally including values, to populate the identity panel and other UI surfaces.
📋 Risk Registry (8)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-1 | Sensitive persona attribute values may be exfiltrated in bulk because no protocol-level sensitivity filter exists on listing requests. | High | High | Immediate | High |
| RISK-2 | Developers may mistake cosmetic UI masking for an actual confidentiality control, leading to unmasked value leakage via unreviewed downstream paths. | Medium | Medium | Short-Term | Medium |
| RISK-3 | Manual vendoring of the claim-type table risks drifting from the canonical registry, causing under-masking of newly sensitive claim types. | Medium | Medium | Medium-Term | Medium |
| RISK-4 | Sensitive values remain in unprotected process memory after masking, recoverable via memory forensics or crash artifacts. | Medium | Medium | Medium-Term | Medium |
| RISK-5 | Reveal-state management defects could cause a masked fact to remain visible longer than intended during shared/recorded sessions. | Low | Low | Short-Term | Low |
| RISK-6 | No audit trail exists for reveal actions on high-sensitivity facts, undermining accountability in shared-session scenarios. | Low | Low | Medium-Term | Low |
| RISK-7 | A malicious or compromised upstream data source could mislabel a sensitive attribute under a lenient claim-type family to bypass masking. | Low | Low | Long-Term | Medium |
| RISK-8 | Holders cannot override the default sensitivity classification for atypical values, leaving some values under- or over-protected relative to individual need. | Low | Low | Long-Term | High |
⚔️ Attack Scenarios (3)
SC-2: DIDComm Agent (persona_attribute_list task)
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC2@{ shape: rect, label: "SC-2: DIDComm Agent (persona_attribute_list task)" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE359@{ shape: rect, label: "CWE-359: Exposure of Private Info" }
CWE668@{ shape: rect, label: "CWE-668: Exposure of Resource to Wrong Sphere" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC116@{ shape: rect, label: "CAPEC-116: Excavation" }
CAPEC37@{ shape: rect, label: "CAPEC-37: Retrieve Embedded Sensitive Data" }
end
subgraph SL4["4. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Sensitive Data Over-Exposure via Missing Read-Path Enforcement<br><i>High / Very Likely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA4@{ shape: rect, label: "TA-4: Agent-Side Insider or Compromised Agent<br><i>Exfiltrate bulk sensitive values</i>" }
end
SC2 --> CWE359
SC2 --> CWE668
CWE359 --> CAPEC116
CWE668 --> CAPEC37
CAPEC116 --> S1
CAPEC37 --> S1
S1 --> TA4
linkStyle 0 stroke:#FF0000, stroke-width:2px
linkStyle 1 stroke:#FF0000, stroke-width:2px
linkStyle 2 stroke:#FF0000, stroke-width:2px
linkStyle 3 stroke:#FF0000, stroke-width:2px
linkStyle 4 stroke:#FF0000, stroke-width:2px
linkStyle 5 stroke:#FF0000, stroke-width:2px
linkStyle 6 stroke:#FF0000, stroke-width:2px
SC-1: claim_types Registry Module
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC1@{ shape: rect, label: "SC-1: claim_types Registry Module" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE1021@{ shape: rect, label: "CWE-1021: Improper Restriction of Rendered UI Layers" }
CWE1104@{ shape: rect, label: "CWE-1104: Use of Unmaintained Third Party Components" }
CWE226@{ shape: rect, label: "CWE-226: Sensitive Info in Resource Not Removed Before Reuse" }
CWE863@{ shape: rect, label: "CWE-863: Incorrect Authorization" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC176@{ shape: rect, label: "CAPEC-176: Configuration/Environment Manipulation" }
CAPEC267@{ shape: rect, label: "CAPEC-267: Leverage Alternate Encoding" }
CAPEC545@{ shape: rect, label: "CAPEC-545: Pull Data from System Resources" }
end
subgraph SL4["4. Threats"]
direction LR
S2@{ shape: rect, label: "STRIDE-2: False Sense of Confidentiality via Cosmetic-Only Masking<br><i>Medium / Likely</i>" }
S3@{ shape: rect, label: "STRIDE-3: Registry Drift via Unenforced Manual Vendoring<br><i>Medium / Likely</i>" }
S4@{ shape: rect, label: "STRIDE-4: Sensitive Value Persistence in Process Memory<br><i>Medium / Possible</i>" }
S6@{ shape: rect, label: "STRIDE-6: Family-Prefix Boundary Confusion<br><i>Low / Unlikely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA2@{ shape: rect, label: "TA-2: Malicious/Compromised Upstream Data Source<br><i>Mislabel claim types</i>" }
TA3@{ shape: rect, label: "TA-3: Local Forensic/Memory Attacker<br><i>Recover plaintext from memory</i>" }
end
SC1 --> CWE1021
SC1 --> CWE1104
SC1 --> CWE226
SC1 --> CWE863
CWE1021 --> CAPEC176
CWE1104 --> CAPEC176
CWE226 --> CAPEC545
CWE863 --> CAPEC267
CAPEC176 --> S2
CAPEC176 --> S3
CAPEC545 --> S4
CAPEC267 --> S6
S2 --> TA2
S3 --> TA2
S4 --> TA3
S6 --> TA2
linkStyle 0 stroke:#FF0000, stroke-width:2px
linkStyle 1 stroke:#FF0000, stroke-width:2px
linkStyle 2 stroke:#FF0000, stroke-width:2px
linkStyle 3 stroke:#FF0000, stroke-width:2px
linkStyle 4 stroke:#FF0000, stroke-width:2px
linkStyle 5 stroke:#FF0000, stroke-width:2px
linkStyle 6 stroke:#FF0000, stroke-width:2px
linkStyle 7 stroke:#FF0000, stroke-width:2px
linkStyle 8 stroke:#FFA500, stroke-width:2px
linkStyle 9 stroke:#FFA500, stroke-width:2px
linkStyle 10 stroke:#FFA500, stroke-width:2px
linkStyle 11 stroke:#00FF00, stroke-width:2px
linkStyle 12 stroke:#FFA500, stroke-width:2px
linkStyle 13 stroke:#FFA500, stroke-width:2px
linkStyle 14 stroke:#FFA500, stroke-width:2px
linkStyle 15 stroke:#00FF00, stroke-width:2px
SC-3: Identity Panel UI Component
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC3@{ shape: rect, label: "SC-3: Identity Panel UI Component" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE362@{ shape: rect, label: "CWE-362: Race Condition" }
CWE778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC26@{ shape: rect, label: "CAPEC-26: Leveraging Race Conditions" }
CAPEC93@{ shape: rect, label: "CAPEC-93: Log Injection-Tampering" }
end
subgraph SL4["4. Threats"]
direction LR
S5@{ shape: rect, label: "STRIDE-5: Reveal-State Race Condition<br><i>Low / Possible</i>" }
S8@{ shape: rect, label: "STRIDE-8: Reveal Action Absence of Audit Logging<br><i>Low / Possible</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Shoulder-Surfing Observer<br><i>Opportunistically capture visible data</i>" }
end
SC3 --> CWE362
SC3 --> CWE778
CWE362 --> CAPEC26
CWE778 --> CAPEC93
CAPEC26 --> S5
CAPEC93 --> S8
S5 --> TA1
S8 --> TA1
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: 10
By Severity: Low: 5 · High: 1 · Medium: 3 · Informational: 1
By Category: Information Disclosure: 6 · Tampering: 1 · Spoofing: 2 · Denial of Service: 1 · Repudiation: 1 · Elevation of Privilege: 1
🎯 Attack Surface
Kill Chain 1: The most severe path begins outside this repository at the DIDComm agent boundary (TB-1/SC-2), where the persona_attribute_list task's lack of a sensitivity-aware filter (STRIDE-1) allows any caller requesting include_values: true to receive every attribute value in the clear, including payment cards and government IDs; this single design gap upstream renders the entire client-side masking layer in claim_types.rs cosmetic rather than protective for that exfiltration path, since resolve()/render() are only invoked after the sensitive data has already left the agent and landed in client memory. Kill Chain 2: Within the client process itself (TB-2/SC-1), an attacker who can influence claim_type token assignment upstream (a malicious or compromised persona/credential source) could chain STRIDE-6 (family-prefix boundary confusion) with STRIDE-3 (registry drift) — mislabeling a sensitive value under a lenient family, or exploiting a stale vendored table entry, to make render() display truly sensitive data unmasked in the identity panel (SC-3), directly feeding a shoulder-surfing or screen-share observer (TA-1). Kill Chain 3: Independently, even correctly-masked and correctly-revealed values remain vulnerable at the memory layer: STRIDE-4 (unzeroized transient String/Vec allocations during masking and reveal) combined with STRIDE-5 (reveal-state race conditions extending plaintext exposure windows) gives a local forensic attacker (TA-3) or a screen-recording bystander (TA-1) two independent opportunities — memory/swap/core-dump analysis or a mistimed reveal — to recover plaintext that the UI believed it had already hidden, with STRIDE-8's absence of reveal audit logging (Repudiation) ensuring no such access is ever attributable after the fact.
🛡️ Risk Mitigation Strategy
Priority 1: The highest-priority gap is architectural and lies outside this file but is directly enabled by this PR's framing — the agent-side persona_attribute_list task has no sensitivity-aware read-path control (RISK-1/STRIDE-1), meaning the very half of the registry this client cannot honour is precisely the half that provides real confidentiality. This must be escalated to the agent/protocol team immediately: add a sensitivity or excludeHighSensitivity parameter to the listing task, and until it exists, treat any bulk include_values: true call against personas containing High-sensitivity attributes as a flagged, audited operation. Priority 2: Short-term, harden the client against misuse and misunderstanding of the masking layer itself — introduce a non-Display secret wrapper type to prevent accidental raw-value propagation (RISK-2), and fix reveal-state handling to derive strictly from current selection with an automatic timeout rather than relying on discrete reset events (RISK-5). Priority 3: Medium-term, close the operational drift and forensic-exposure gaps — implement CI-enforced synchronization between the vendored TABLE and the canonical claim-types.json registry (RISK-3), adopt zeroizing memory handling for high-sensitivity values through the render/reveal pipeline (RISK-4), and add structured audit logging for reveal actions to restore accountability (RISK-6). Priority 4: Long-term, address the design-level flexibility gaps — extend the persona attribute schema to support holder-specified per-attribute sensitivity overrides (RISK-8), and add upstream claim_type vocabulary validation at attribute-ingestion time to prevent family-prefix mislabeling from bypassing masking entirely (RISK-7). None of these mitigations should be described as making the masking layer itself into a security control; the module's own documentation correctly frames masking as a cosmetic, shoulder-surfing defense, and the mitigation strategy should preserve and reinfo
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 0 | 3 |
Must-Review-By-Human (3)
- 🟡 Unenforced manual vendoring of claim-type sensitivity table risks classification drift and under-masking
- 🟡 Missing read-path sensitivity control allows bulk disclosure of high-sensitivity persona attributes (triaged HIGH→MEDIUM)
- 🟡 Display-only masking may be mistaken for a confidentiality control by downstream integrators
What
A fact's claim type says how its value should be shown. The identity pane now
reads that: a type carrying a mask style is painted reduced, and
sshows theselected fact — and only that one.
openvtc-core/src/persona/claim_types.rs—Sensitivity,MaskStyle(
none/last2/last4/emailLocal/full, asclaim-types.jsondefines them) and
resolve().PoolAttribute::display_valueandResolvedClaim::display_valuereturn themasked form;
PoolAttribute::revealed_valueis the separate call that doesnot, so reading a value in the clear is something a call site had to name
rather than a
boolit passed through.son the Your facts tab reveals the selected fact. Moving the selection,changing tab, toggling
v, or a re-read puts the mask back — the render alsochecks that the grant still names the selected row, so a list that re-sorted
under a stale grant cannot open a row nobody chose.
Why a vendored table
The agent serves no claim-type registry. There is no
persona/claim-types/listtask — the registry's own §6 lists adding one as an open question — so a client
that wants a default has to ship it. The module header says so, names
specs/persona/_shared/0.1/claim-types.jsonas the file to re-sync against, andkeeps the copy to the two members a pane actually reads (
sensitivity,mask);a copy of
releaseandoidcthat nothing reads is a copy that goes staleunnoticed.
Two axes, and only one of them is ours
The registry's §3.3 makes
maskandsensitivityindependent decisions:maskis a rendering. Any style butnoneis masked, whatever thesensitivity — which is why
email.workis masked here despite beingnormal.An address is worth hiding from the person behind you without being worth
withholding from every listing.
sensitivity: highmeans the value is withheld from a listing that didnot explicitly ask for sensitive values.
Sensitivityis carried so a callercan read it, and nothing in this change acts on it — see below.
The first commit here gated masking on sensitivity, following the earlier draft.
The registry has since been revised, and the revision is right: the old rule left
email.*carrying anemailLocalstyle no rule could ever apply, while theregistry's own §1 used
a•••@example.comto motivate the table.Resolving a type
§4, minus the rule this client cannot reach:
to store one.
persona/attribute/puthas nosensitivityormaskmemberand the SDK's attribute carries neither. When there is, it belongs above
everything below.
payment.cardshows its last foureven though the
paymentfamily entry isfull, because someone decided thatabout that token.
floor, most protective per axis. Without it a gated family is leavable by
inventing a token:
payment.giftCardwould resolve to a floor whosereleaseis weaker than every registered member of the family it plainly belongs to.
Because the more protective answer wins, a family can only ever tighten — an
unregistered
name.somethingNewstill lands on the floor.high/full.Prefixes match on
.boundaries (paymentx.tokenis notpayment.*), andx:never inherits a family —
x:payment.cardis a token the registry has never seenthat happens to read like one it has.
Masked is not absent
••••••••and(no value)are the same shape on a row, and a holder who readsthe first as the second concludes they never stored the thing they are looking
at. Every masked row carries
masked — s to show; a revealed one carriesshowing — s to mask.is_masked()is the predicate, deliberately not a fourthdisplay string.
A full mask is a fixed eight bullets rather than one per character: the length of
a passport number is itself a hint. Every style falls back to a full mask when
the value does not have the shape it assumes —
last4over four characters,emailLocalover something with no@— because a mask that silently stopsmasking on the values it was misapplied to is worse than no mask.
The honesty caveat, stated in the code
Client-side masking is not a security control, and nothing here claims it is.
The value has already been fetched, decrypted by the agent, sent over DIDComm and
parked in this process's memory. Everything that could read it before still can.
What the mask defends against is a person reading the terminal over a shoulder,
and a screenshot or screen share carrying a card number to an audience nobody
asked.
The control that would matter is the
sensitivityhalf — a listing that did notask for sensitive values is never sent them. It does not exist:
persona_attribute_listtakesinclude_valuesand nothing finer, so a listingthis pane asks values for is sent every card number the holder holds. The
registry says the same thing in §3.1: "masking a value already fetched defends a
screen; it is no defence against a log, a crash dump, or the memory of the
process holding it." The mask is what makes that missing control visible,
not a substitute for it. The pane says as much on screen, once, where a holder is
deciding whether to trust it:
Faces
A face's detail view masks too, and points at where a value can be read one at a
time. It gets no reveal of its own: a face has no cursor over its claims, so the
only reveal it could offer is the blanket one the mask exists to avoid.
Tests
Refusals paired with successes throughout: a masked value masks and reveals; a
type with no style is shown whole; an unregistered token is masked; a family is
inherited but can only tighten; a prefix matches on segment boundaries;
x:never inherits; a value too short for its style is masked whole rather than
shown; a stale value keeps saying it is stale; masking counts characters, not
bytes. On the pane: exactly the masked facts are marked, the reveal opens only
the selected row, the note stays off a screen with nothing masked, and
son anempty list is not a verb. The vocabulary guard
(
the_pane_speaks_the_agreed_vocabulary) now renders a masked fact and a maskedclaim, so the new copy is scanned by it.
cargo fmt --all,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace— all clean.Notes for the registry draft
Both things this implementation first hit are fixed by the revision it now
follows. What is left:
maskStylesis prose, not a schema:last2/last4do not say what happenswhen the value is shorter than the tail they keep. This implementation masks
fully — the alternative is a "masked" card number printed whole — but two
clients could reasonably differ. Worth a sentence in
CLAIM-TYPES.md.fulldoes not say whether the mask is a fixed width or tracks the value'slength. Tracking it leaks the length of a passport number or a date; this
implementation fixes the width at eight.
persona/attribute/putcarries nosensitivityormaskmember, so a holder cannot record the override the ruleresolves first. The rule is correct; the write path has not caught up.