From a356cecdc997c23381e7be3c78cf45ddf58c69dc Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 16:51:02 +0200 Subject: [PATCH 1/4] feat(persona): mask sensitive facts in the identity pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 27 ++ openvtc-core/src/persona/claim_types.rs | 318 ++++++++++++++++++ openvtc-core/src/persona/mod.rs | 6 + openvtc-core/src/persona/pool.rs | 125 ++++++- openvtc-core/src/persona/profile.rs | 67 +++- openvtc/src/state_handler/actions/mod.rs | 6 + .../src/state_handler/main_page/content.rs | 12 + openvtc/src/state_handler/persona_actions.rs | 109 ++++++ .../pages/main/components/identity_panel.rs | 267 ++++++++++++++- openvtc/src/ui/pages/main/mod.rs | 17 + 10 files changed, 932 insertions(+), 22 deletions(-) create mode 100644 openvtc-core/src/persona/claim_types.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 418008f..da55032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Facts that are sensitive by what they are stay masked, and say so.** The + identity pane resolves each fact's claim type against the persona claim-type + registry (`specs/persona/_shared/0.1/claim-types.json`) and paints the + `high`-sensitivity ones reduced — `••••••••••••4242` for a card, `••••••••••56` + for a mobile number, `••••••••` for a date of birth. A type the registry does + not list, and anything under `x:`, resolves to the conservative default: + masked entirely. `s` shows the selected fact and only that one; moving the + selection, changing tab or re-reading puts it back. + + A masked row says *masked — s to show*, because `••••••••` and *(no value)* + are the same shape and one of them is a wrong answer about what the holder + holds. + + **This is not a security control and the pane does not claim it is.** The + value has already been fetched, decrypted by the agent and parked in this + process; masking it defends against someone reading the terminal over a + shoulder and against a screenshot, and against nothing else. 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 yet. + + The table is a **vendored copy**: the agent serves no claim-type registry, so + `openvtc-core/src/persona/claim_types.rs` ships one and names the file to + re-sync it against. + ### Changed - **The join flow and communities panel speak the persona vocabulary too.** The diff --git a/openvtc-core/src/persona/claim_types.rs b/openvtc-core/src/persona/claim_types.rs new file mode 100644 index 0000000..449ddcc --- /dev/null +++ b/openvtc-core/src/persona/claim_types.rs @@ -0,0 +1,318 @@ +//! What a claim type says about showing its own value — a **vendored copy** of +//! the masking half of the persona claim-type registry. +//! +//! Source of truth: +//! `dtgwg-trust-tasks-tf/specs/persona/_shared/0.1/claim-types.json`, with the +//! reasoning beside it in `CLAIM-TYPES.md`. This module carries two of that +//! file's four per-type members — `sensitivity` and `mask` — because those are +//! the two a pane needs to decide how to paint a row. `release` and `oidc` are +//! deliberately absent: nothing here releases anything, and a copy of a table +//! nobody reads is a copy that goes stale unnoticed. +//! +//! It is vendored because **the agent does not serve this table.** There is no +//! `persona/claim-types/list` task — the registry's own §6 lists adding one as +//! an open question — so a client that wants a default has to ship it. Re-sync +//! by hand against the file named above when the registry moves; the whole of +//! the copy is [`TABLE`] plus [`UNREGISTERED`]. +//! +//! # This is not a security control, and it must not be described as one +//! +//! 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. +//! +//! The control that would actually matter is on the read path — an +//! `includeSensitive` flag on `persona/attribute/list`, so a listing that did +//! not ask for sensitive values is never *sent* them. It does not exist: +//! `persona_attribute_list` takes `include_values` and nothing finer. The +//! registry says the same thing in §3.1 — "masking a value already fetched is +//! theatre; the control that matters is on the read path" — and the mask is +//! what makes that missing control visible rather than a substitute for it. +//! +//! # Only `sensitivity` decides whether to mask +//! +//! `mask` says *how* a value is reduced, not *whether* it is. A `normal` type +//! carrying a style (`email.work` is `normal` + `emailLocal`) is shown in full +//! to its own holder; the style is there for the surfaces that reduce a value +//! for someone else. Reading the style as the trigger would mask a work email +//! address in a holder's own list, which teaches the reveal key as a reflex — +//! and a reveal pressed by reflex protects nothing. + +/// How carefully a value is shown **to its own holder**. +/// +/// Not linkability, and not what it takes to release the value — the registry's +/// §3 keeps those three apart because a mechanism that reads one as a proxy for +/// another hides the wrong things and warns about the wrong things. A payment +/// card is highly sensitive and barely linkable; a nickname can be the reverse. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Sensitivity { + /// Shown as it is held. + #[default] + Normal, + /// Masked until the holder asks for this one value. + High, +} + +/// How a value is reduced when it is shown masked. The styles, and their +/// wording, are `claim-types.json`'s `maskStyles`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum MaskStyle { + /// Shown in full. The value is not one a shoulder can steal. + #[default] + None, + /// Final two characters shown; everything before them replaced. + Last2, + /// Final four characters shown; everything before them replaced. + Last4, + /// First character of the local part, then the domain in full. + EmailLocal, + /// No characters shown. + Full, +} + +/// The bullet a replaced character is drawn as. `*` reads as a footnote and `x` +/// as data; `•` is neither, and is what every other masked field in the +/// ecosystem uses. +const BULLET: char = '•'; + +/// Width of a fully masked value. +/// +/// Fixed, rather than one bullet per character held: the length of a passport +/// number or a date of birth is itself a hint, and a mask that leaks it has +/// given away the one thing the style exists to withhold. It also keeps a +/// column stable while the holder pages down a list. +const FULL_MASK_WIDTH: usize = 8; + +impl MaskStyle { + /// Reduce `text` to the form this style shows. + /// + /// Every style falls back to [`Full`](MaskStyle::Full) rather than to the + /// clear text when the value does not have the shape the style assumes — + /// a `last4` over three characters, an `emailLocal` over something with no + /// `@`. The alternative is a mask that silently stops masking on exactly + /// the values it was misapplied to. + #[must_use] + pub fn apply(self, text: &str) -> String { + let full = || BULLET.to_string().repeat(FULL_MASK_WIDTH); + match self { + Self::None => text.to_string(), + Self::Full => full(), + Self::Last2 | Self::Last4 => { + let keep = if self == Self::Last2 { 2 } else { 4 }; + let chars: Vec = text.chars().collect(); + // Strictly longer, not "at least": a four-character value under + // `last4` would be shown whole by a rule that says it is masked. + if chars.len() <= keep { + return full(); + } + let tail: String = chars[chars.len() - keep..].iter().collect(); + format!("{}{tail}", BULLET.to_string().repeat(chars.len() - keep)) + } + Self::EmailLocal => match text.split_once('@') { + // The domain is what makes an address recognisable to its + // owner; the local part is what makes it usable to anyone else. + Some((local, domain)) if !local.is_empty() && !domain.is_empty() => { + let first = local.chars().next().unwrap_or(BULLET); + format!("{first}{}@{domain}", BULLET.to_string().repeat(3)) + } + _ => full(), + }, + } + } + + /// Whether showing a value through this style actually withholds anything. + #[must_use] + pub fn hides_anything(self) -> bool { + !matches!(self, Self::None) + } +} + +/// What one claim type says about showing its value. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ClaimTypeDefaults { + pub sensitivity: Sensitivity, + pub mask: MaskStyle, +} + +impl ClaimTypeDefaults { + /// Whether a value of this type is shown masked by default. + #[must_use] + pub fn masks_by_default(self) -> bool { + self.sensitivity == Sensitivity::High && 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() + } + } +} + +/// What a type this registry has never seen resolves to. +/// +/// The conservative answer, and the registry's §4 rule 3: a vocabulary nobody +/// has reasoned about is exactly the one nothing is known about, and an unknown +/// value rendered in the clear is a decision nobody made. It applies to +/// unregistered tokens and to the open `x:` namespace alike — the registry +/// gives both the same defaults. +pub const UNREGISTERED: ClaimTypeDefaults = ClaimTypeDefaults { + sensitivity: Sensitivity::High, + mask: MaskStyle::Full, +}; + +/// The vendored table, in `claim-types.json`'s order so the two diff against +/// each other by eye. +const TABLE: &[(&str, Sensitivity, MaskStyle)] = &[ + ("name.legal", Sensitivity::Normal, MaskStyle::None), + ("name.given", Sensitivity::Normal, MaskStyle::None), + ("name.family", Sensitivity::Normal, MaskStyle::None), + ("name.display", Sensitivity::Normal, MaskStyle::None), + // A former name is the one a holder most often keeps in order to answer a + // question once and never show again. + ("name.previous", Sensitivity::High, MaskStyle::Full), + ("person.birthDate", Sensitivity::High, MaskStyle::Full), + ("person.pronouns", Sensitivity::Normal, MaskStyle::None), + ("person.locale", Sensitivity::Normal, MaskStyle::None), + ("email.personal", Sensitivity::Normal, MaskStyle::EmailLocal), + ("email.work", Sensitivity::Normal, MaskStyle::EmailLocal), + // High because a mobile number is both a strong join key and an + // authentication factor: its harm is account takeover, not embarrassment. + ("phone.mobile", Sensitivity::High, MaskStyle::Last2), + ("phone.landline", Sensitivity::High, MaskStyle::Last2), + ("address.postal", Sensitivity::High, MaskStyle::Full), + ("address.country", Sensitivity::Normal, MaskStyle::None), + ("gov.id.passport", Sensitivity::High, MaskStyle::Last4), + ("gov.id.driverLicence", Sensitivity::High, MaskStyle::Last4), + ("gov.id.national", Sensitivity::High, MaskStyle::Last4), + ("gov.taxId", Sensitivity::High, MaskStyle::Last4), + ("payment.card", Sensitivity::High, MaskStyle::Last4), + ("payment.cardExpiry", Sensitivity::High, MaskStyle::Full), + ("payment.iban", Sensitivity::High, MaskStyle::Last4), + ("payment.accountNumber", Sensitivity::High, MaskStyle::Last4), + ("account.handle", Sensitivity::Normal, MaskStyle::None), + ("url.homepage", Sensitivity::Normal, MaskStyle::None), + ("org.name", Sensitivity::Normal, MaskStyle::None), + ("org.role", Sensitivity::Normal, MaskStyle::None), +]; + +/// Resolve a claim type to how its value is shown. +/// +/// The match is exact. A token is not resolved by walking up its family — +/// `payment.giftCard` is unregistered, not "a `payment.*`" — because the +/// families are a naming convention rather than a scope, and inheriting from a +/// prefix would let a *new* `x:payment.…` inherit a registered family's +/// answer. Unregistered is already the conservative one; there is nothing a +/// prefix walk could add but a way to be wrong. +#[must_use] +pub fn resolve(claim_type: &str) -> ClaimTypeDefaults { + TABLE + .iter() + .find(|(token, _, _)| *token == claim_type) + .map_or(UNREGISTERED, |(_, sensitivity, mask)| ClaimTypeDefaults { + sensitivity: *sensitivity, + mask: *mask, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The registered types resolve to what the vendored table says, including + /// the two that are `high` without being `full` — the styles exist so a + /// holder can still recognise their own card and their own number. + #[test] + fn registered_types_resolve_to_their_entry() { + assert_eq!(resolve("name.legal").sensitivity, Sensitivity::Normal); + assert_eq!(resolve("payment.card").mask, MaskStyle::Last4); + assert_eq!(resolve("phone.mobile").mask, MaskStyle::Last2); + assert!(resolve("gov.id.passport").masks_by_default()); + assert!(!resolve("org.role").masks_by_default()); + } + + /// An unregistered token and an `x:` extension get the conservative answer. + /// + /// This is the case the whole default exists for: a build that has never + /// heard of a vocabulary knows nothing about what it holds, and showing it + /// in the clear would be a decision nobody made. + #[test] + fn an_unknown_type_masks_fully() { + for token in ["x:employer.badge", "medical.condition", "", "payment"] { + let resolved = resolve(token); + assert_eq!(resolved.sensitivity, Sensitivity::High, "{token}"); + assert_eq!(resolved.mask, MaskStyle::Full, "{token}"); + } + } + + /// A family prefix is not a scope. `payment.giftCard` is a token this build + /// has never seen, and inheriting `payment.card`'s entry would be a guess + /// dressed as a lookup. + #[test] + fn a_family_prefix_does_not_inherit() { + assert_eq!(resolve("payment.giftCard"), UNREGISTERED); + assert_eq!(resolve("x:payment.card"), UNREGISTERED); + } + + /// `normal` sensitivity is shown whole even when the type names a style — + /// see the module header. `email.work` is the case in the registry today. + #[test] + fn a_normal_type_is_not_masked_by_its_style() { + let email = resolve("email.work"); + assert_eq!(email.mask, MaskStyle::EmailLocal); + assert!(!email.masks_by_default()); + assert_eq!(email.render("alice@example.com"), "alice@example.com"); + } + + /// Each style keeps exactly the characters it says it keeps. + #[test] + fn each_style_keeps_what_it_says_it_keeps() { + assert_eq!(MaskStyle::None.apply("Alice"), "Alice"); + assert_eq!( + MaskStyle::Last4.apply("4242424242424242"), + "••••••••••••4242" + ); + assert_eq!(MaskStyle::Last2.apply("+61400123456"), "••••••••••56"); + assert_eq!( + MaskStyle::EmailLocal.apply("alice@example.com"), + "a•••@example.com" + ); + assert_eq!(MaskStyle::Full.apply("1990-01-01"), "••••••••"); + } + + /// A fully masked value is a fixed width, so the mask does not report the + /// length of what it is hiding. + #[test] + fn a_full_mask_does_not_leak_the_length() { + assert_eq!( + MaskStyle::Full.apply("1990-01-01"), + MaskStyle::Full.apply("a much longer secret value"), + ); + } + + /// A value too short for its style is masked entirely rather than shown. + /// + /// The failure this refuses is a mask that stops masking on the values it + /// was misapplied to: `last4` over a four-character card number is the + /// whole number, printed by code that believes it is redacting. + #[test] + fn a_value_too_short_for_its_style_is_masked_whole() { + assert_eq!(MaskStyle::Last4.apply("4242"), "••••••••"); + assert_eq!(MaskStyle::Last2.apply("7"), "••••••••"); + assert_eq!(MaskStyle::EmailLocal.apply("not-an-address"), "••••••••"); + assert_eq!(MaskStyle::EmailLocal.apply("@example.com"), "••••••••"); + assert_eq!(MaskStyle::EmailLocal.apply("alice@"), "••••••••"); + } + + /// Masking counts characters, not bytes: a multi-byte value must not panic + /// on a slice boundary, and must keep the count the style promises. + #[test] + fn masking_counts_characters_not_bytes() { + assert_eq!(MaskStyle::Last2.apply("naïve café"), "••••••••fé"); + } +} diff --git a/openvtc-core/src/persona/mod.rs b/openvtc-core/src/persona/mod.rs index 995b833..c582880 100644 --- a/openvtc-core/src/persona/mod.rs +++ b/openvtc-core/src/persona/mod.rs @@ -13,6 +13,11 @@ //! ([`binding`]). Those live in the VTA, not in `Config`, and every function //! here is a round-trip to it. //! +//! [`claim_types`] is the one exception and makes no round-trip at all: it is a +//! vendored copy of the claim-type registry's masking table, carried here +//! because the agent does not serve that table. Its header says what that is +//! worth and what it is not. +//! //! This crate holds the persona; the agent holds what the persona says. They join on //! the `(context_id, persona_did)` pair every community membership already //! carries. @@ -40,6 +45,7 @@ //! identity, so it returns its error and the panel says so. pub mod binding; +pub mod claim_types; pub mod disclosure; pub mod pool; pub mod profile; diff --git a/openvtc-core/src/persona/pool.rs b/openvtc-core/src/persona/pool.rs index 1854f20..d34f77b 100644 --- a/openvtc-core/src/persona/pool.rs +++ b/openvtc-core/src/persona/pool.rs @@ -32,6 +32,7 @@ use vta_sdk::client::VtaClient; use vta_sdk::protocols::persona::{Provenance, ValueType}; use crate::errors::OpenVTCError; +use crate::persona::claim_types::{self, ClaimTypeDefaults}; /// Where an attribute's value came from, reduced to what a panel can act on. /// @@ -177,22 +178,66 @@ impl PoolAttribute { } } - /// The value as one line, or the reason there is none. + /// How this attribute's type says its value should be shown. + #[must_use] + pub fn sensitivity(&self) -> ClaimTypeDefaults { + claim_types::resolve(&self.claim_type) + } + + /// Whether [`display_value`](Self::display_value) is showing a reduced form + /// of a value we are holding. + /// + /// The caller needs this to say *masked* rather than let the row read as + /// empty: `••••••••` and "(no value)" are one glance apart, and one of them + /// is a wrong answer about what the holder holds. + #[must_use] + pub fn is_masked(&self) -> bool { + !self.stale && self.value.is_some() && self.sensitivity().masks_by_default() + } + + /// The value as one line, or the reason there is none — masked when its + /// claim type asks for that. /// /// Three readings kept apart on purpose, because collapsing any two of them /// misinforms the holder about their own data: we did not ask; we asked and - /// the source could not answer; here it is. + /// the source could not answer; here it is. Masking adds a fourth — *we + /// have it and are not painting it* — which is why it is + /// [`is_masked`](Self::is_masked) rather than a fourth string here. #[must_use] pub fn display_value(&self, values_requested: bool) -> String { + self.value_line(values_requested, false) + } + + /// The same line with the mask lifted, for a holder who asked for this one + /// value. + /// + /// A separate method rather than a `reveal: bool` on + /// [`display_value`](Self::display_value), so that reading a sensitive + /// value in the clear is something a call site had to *name*. A boolean + /// gets passed through, and the caller that ends up passing `true` is + /// rarely the one that meant to. + #[must_use] + pub fn revealed_value(&self, values_requested: bool) -> String { + self.value_line(values_requested, true) + } + + fn value_line(&self, values_requested: bool, reveal: bool) -> String { if self.stale { return match &self.stale_reason { Some(reason) => format!("stale · {reason} — can no longer be proven"), None => "stale — can no longer be proven".to_string(), }; } + let shown = |text: String| { + if reveal { + text + } else { + self.sensitivity().render(&text) + } + }; match &self.value { - Some(Value::String(s)) => s.clone(), - Some(other) => other.to_string(), + Some(Value::String(s)) => shown(s.clone()), + Some(other) => shown(other.to_string()), None if values_requested => "(no value)".to_string(), None => "(hidden)".to_string(), } @@ -478,6 +523,78 @@ mod tests { assert!(parse_typed_value("maybe", ValueType::Boolean).is_err()); } + /// A high-sensitivity value is masked by its type, and reads back whole + /// only when a caller asks for that one value. + /// + /// The pairing is the point: the mask has to be liftable, or a holder + /// cannot check their own card number; and lifting it has to be a + /// different call, or it is not a decision anyone made. + #[test] + fn a_sensitive_value_is_masked_until_it_is_asked_for() { + let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); + attr.claim_type = "payment.card".into(); + attr.value = Some(Value::String("4242424242424242".into())); + + assert!(attr.is_masked()); + assert_eq!(attr.display_value(true), "••••••••••••4242"); + assert_eq!(attr.revealed_value(true), "4242424242424242"); + } + + /// A type the registry lists as `normal` is shown as it is held. Masking + /// every fact would teach the reveal key as a reflex, and a reveal pressed + /// by reflex protects nothing. + #[test] + fn a_normal_value_is_not_masked() { + let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); + attr.value = Some(Value::String("alice@example.com".into())); + assert!(!attr.is_masked()); + assert_eq!(attr.display_value(true), "alice@example.com"); + } + + /// A vocabulary this build has never seen is masked, because nothing here + /// knows what it holds. Same rule for the open `x:` namespace. + #[test] + fn an_unregistered_type_is_masked() { + let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); + attr.claim_type = "x:employer.badge".into(); + attr.value = Some(Value::String("A-1174".into())); + assert!(attr.is_masked()); + assert_eq!(attr.display_value(true), "••••••••"); + } + + /// Masked and absent are different states, and a caller has to be able to + /// tell them apart — `••••••••` and "(no value)" are one glance apart on a + /// row, and one of them is a wrong answer about what the holder holds. + #[test] + fn masked_is_not_the_same_state_as_absent() { + let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); + attr.claim_type = "person.birthDate".into(); + assert!(!attr.is_masked(), "nothing held is nothing to mask"); + assert_eq!(attr.display_value(true), "(no value)"); + assert_eq!(attr.display_value(false), "(hidden)"); + + attr.value = Some(Value::String("1990-01-01".into())); + assert!(attr.is_masked()); + } + + /// A stale value keeps saying it is stale. The reason it cannot be shown is + /// not that it is sensitive, and a mask over it would hide the one thing + /// the holder needs to act on. + #[test] + fn a_stale_sensitive_value_still_says_it_is_stale() { + let mut attr = PoolAttribute::from_wire(&wire("credentialBacked")); + attr.claim_type = "gov.id.passport".into(); + attr.value = Some(Value::String("P1234567".into())); + attr.stale = true; + attr.stale_reason = Some("revoked".into()); + + assert!(!attr.is_masked()); + assert_eq!( + attr.display_value(true), + "stale · revoked — can no longer be proven" + ); + } + /// A label is what the holder sees; falling back to the vocabulary token /// keeps an unlabelled row identifiable rather than blank. #[test] diff --git a/openvtc-core/src/persona/profile.rs b/openvtc-core/src/persona/profile.rs index e320ce9..6d10238 100644 --- a/openvtc-core/src/persona/profile.rs +++ b/openvtc-core/src/persona/profile.rs @@ -37,6 +37,7 @@ use vta_sdk::client::VtaClient; use vta_sdk::protocols::persona::ProfileEntry; use crate::errors::OpenVTCError; +use crate::persona::claim_types; use crate::persona::pool::ProvenanceKind; /// A profile as a list row. @@ -137,21 +138,37 @@ impl ResolvedClaim { } } - /// The value as one line. Mirrors + /// The value as one line, masked when its claim type asks for that. Mirrors /// [`PoolAttribute::display_value`](crate::persona::pool::PoolAttribute::display_value), /// minus the "hidden" case: a resolve was asked for, so an absent value is /// an answer rather than a question that was never put. + /// + /// There is no `revealed_value` counterpart here, and that is a decision + /// rather than an omission. A resolved claim has no identity of its own to + /// reveal *one* of — a face is read as a whole — so the only reveal this + /// type could offer is the blanket one the mask exists to avoid. A holder + /// who wants to check a value reads it among their facts, one at a time. #[must_use] pub fn display_value(&self) -> String { if self.stale { return "stale — can no longer be proven".to_string(); } + let shown = |text: String| claim_types::resolve(&self.claim_type).render(&text); match &self.value { - Some(Value::String(s)) => s.clone(), - Some(other) => other.to_string(), + Some(Value::String(s)) => shown(s.clone()), + Some(other) => shown(other.to_string()), None => "(no value)".to_string(), } } + + /// Whether [`display_value`](Self::display_value) is reducing a value we + /// hold, so a face can say *masked* rather than let the row read as empty. + #[must_use] + pub fn is_masked(&self) -> bool { + !self.stale + && self.value.is_some() + && claim_types::resolve(&self.claim_type).masks_by_default() + } } /// One profile, read in full. @@ -402,10 +419,14 @@ mod tests { /// A resolved claim with no `attributeId` is an inline value, and saying so /// is the whole reason it is a distinct type from a pool attribute. + /// + /// The type is a registered `normal` one so the assertion is about the + /// inline value and not about the mask: `nickname` is not in the claim-type + /// registry, and an unregistered token resolves to masked. #[test] fn a_resolved_claim_without_an_attribute_id_is_inline() { let claim = ResolvedClaim::from_wire(&serde_json::json!({ - "type": "nickname", + "type": "name.display", "value": "Ace", "valueType": "string", "provenance": { "kind": "selfAsserted" }, @@ -427,6 +448,44 @@ mod tests { assert!(claim.display_value().contains("can no longer be proven")); } + /// A face shows a sensitive value masked too, and says that it did. + /// + /// The face detail view is a screen a holder opens to check what a + /// community sees, which is exactly the screen someone else is most likely + /// to be looking at over their shoulder. + #[test] + fn a_sensitive_claim_is_masked_on_a_face() { + let claim = ResolvedClaim::from_wire(&serde_json::json!({ + "type": "phone.mobile", + "value": "+61400123456", + "valueType": "string", + "provenance": { "kind": "selfAsserted" }, + })); + assert!(claim.is_masked()); + assert_eq!(claim.display_value(), "••••••••••56"); + + let normal = ResolvedClaim::from_wire(&serde_json::json!({ + "type": "name.given", + "value": "Alice", + "valueType": "string", + })); + assert!(!normal.is_masked()); + assert_eq!(normal.display_value(), "Alice"); + } + + /// Masked and absent stay distinguishable here too — a face that shows + /// nothing and a face whose value is merely not painted are different + /// answers to "what does this community see". + #[test] + fn a_masked_claim_is_not_an_absent_one() { + let absent = ResolvedClaim::from_wire(&serde_json::json!({ + "type": "phone.mobile", + "value": Value::Null, + })); + assert!(!absent.is_masked()); + assert_eq!(absent.display_value(), "(no value)"); + } + /// The put ordering: ticked entries first, preserved forms after, so the /// profile resolves in the order the holder saw in the picker. #[test] diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index c59e428..8fb3df5 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -205,6 +205,12 @@ pub enum PersonaAction { /// a listing fetched without values does not hold them, which is what makes /// this an opt-in rather than a blindfold. ToggleValues, + /// Show the selected fact's value unmasked, or stop showing it. + /// + /// One fact, not a mode: a claim type the registry calls sensitive is + /// masked even in a listing that asked for values, and this lifts that for + /// the row under the cursor only. Moving the selection puts it back. + RevealValue(usize), // ── Attributes ─────────────────────────────────────────────────────── /// Open the editor on a new attribute. diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index ec63e83..8a511a5 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -746,6 +746,18 @@ pub struct IdentityState { /// holder asking to see their own identity, so it is a keypress they make /// on purpose and a network round-trip, not a display flag. pub show_values: bool, + /// The one fact whose value is being shown unmasked, by `attribute_id`. + /// + /// One, and only while it is also the selected row — the render checks + /// both. Sensitivity is a property of the claim type, so a card number and + /// a date of birth are masked even in a listing the holder asked to see + /// (`openvtc_core::persona::claim_types`), and lifting that is a per-fact + /// act rather than a mode the pane can be left in. + /// + /// Cleared by moving the selection, changing tab, or a re-read. A reveal + /// that outlived the row it was granted for would be a global unmask + /// arrived at one keypress at a time. + pub revealed_attribute: Option, // ── Profiles (from the agent) ──────────────────────────────────────── pub profiles: Arc<[openvtc_core::persona::profile::ProfileSummary]>, diff --git a/openvtc/src/state_handler/persona_actions.rs b/openvtc/src/state_handler/persona_actions.rs index 348f966..ee8c0a4 100644 --- a/openvtc/src/state_handler/persona_actions.rs +++ b/openvtc/src/state_handler/persona_actions.rs @@ -117,6 +117,9 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect p.confirm = PersonaConfirm::None; p.open_profile = None; p.status_message = None; + // A reveal is granted to one row on one tab. Coming back to the + // facts should find them masked again, not still open. + p.revealed_attribute = None; // Read on arrival, once. The agent-served tabs are not polled: a // pane nobody has opened should not be asking the agent about the // holder's identity every few seconds. @@ -127,6 +130,10 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect } PersonaAction::Select(index) => { let p = &mut state.main_page.content_panel.identity; + // The selection moved, so the reveal it was granted for is over. + // Carrying it to the next row is how "one value" becomes "all of + // them", one press of ↓ at a time. + p.revealed_attribute = None; match p.tab { PersonaTab::Personas => p.persona_selected = *index, PersonaTab::Attributes => p.attribute_selected = *index, @@ -140,11 +147,30 @@ pub(crate) fn apply(state: &mut State, action: &PersonaAction) -> PersonaEffect PersonaAction::ToggleValues => { let p = &mut state.main_page.content_panel.identity; p.show_values = !p.show_values; + p.revealed_attribute = None; // A re-read, not a redraw: a listing fetched without values does // not hold them. Flipping a display flag over data already in // memory would mean the values had been read all along. PersonaEffect::Read } + PersonaAction::RevealValue(index) => { + let p = &mut state.main_page.content_panel.identity; + let Some(attr) = p.attributes.get(*index) else { + return PersonaEffect::None; + }; + // A second press puts it back, so the key the holder used to show + // the value is also the one that hides it again. + p.revealed_attribute = match &p.revealed_attribute { + Some(id) if id == &attr.attribute_id => None, + _ => Some(attr.attribute_id.clone()), + }; + // No read: this lifts a mask over a value already in memory, which + // is exactly why the mask is not a security control. The read-path + // control — a listing that is never *sent* sensitive values — + // would belong here and does not exist; see + // `openvtc_core::persona::claim_types`. + PersonaEffect::None + } // ── Attributes ─────────────────────────────────────────────────── PersonaAction::AttributeNew => { @@ -751,6 +777,10 @@ impl PersonaOutcome { if let Ok(list) = attributes { p.attribute_selected = p.attribute_selected.min(list.len().saturating_sub(1)); p.attributes = list.into(); + // The list under the reveal has been rebuilt and may be + // ordered differently, so the grant no longer names a row + // the holder chose. + p.revealed_attribute = None; } if let Ok(list) = profiles { p.profile_selected = p.profile_selected.min(list.len().saturating_sub(1)); @@ -1068,6 +1098,85 @@ mod tests { assert!(matches!(personas(&state).mode, PersonaMode::Attribute(_))); } + /// A reveal names one fact, is put back by the same key, and never becomes + /// a read. + /// + /// Lifting the mask touches nothing but this pane: the value is already in + /// memory, which is the whole of why the mask is not a control. The control + /// that would be one is a listing that is never sent sensitive values, and + /// it does not exist yet. + #[test] + fn a_reveal_names_one_fact_and_toggles_off() { + let mut state = state_with(IdentityState { + attributes: vec![attribute("01A"), attribute("01B")].into(), + show_values: true, + ..IdentityState::default() + }); + + let effect = apply(&mut state, &PersonaAction::RevealValue(1)); + assert!(matches!(effect, PersonaEffect::None), "no round-trip"); + assert_eq!(personas(&state).revealed_attribute.as_deref(), Some("01B")); + + apply(&mut state, &PersonaAction::RevealValue(1)); + assert!(personas(&state).revealed_attribute.is_none()); + } + + /// A reveal on a row that is not there changes nothing — an index into a + /// list that has since shrunk must not open whatever now sits at it. + #[test] + fn a_reveal_of_a_missing_row_reveals_nothing() { + let mut state = state_with(IdentityState { + attributes: vec![attribute("01A")].into(), + show_values: true, + ..IdentityState::default() + }); + + apply(&mut state, &PersonaAction::RevealValue(7)); + assert!(personas(&state).revealed_attribute.is_none()); + } + + /// Everything that changes what is on screen puts the mask back. + /// + /// This is what keeps the reveal from becoming a global unmask reached one + /// keypress at a time: it is granted to a row on a tab in a listing, and + /// each of those three moving ends it. + #[test] + fn moving_anywhere_puts_the_mask_back() { + let revealed = || { + state_with(IdentityState { + tab: PersonaTab::Attributes, + attributes: vec![attribute("01A"), attribute("01B")].into(), + show_values: true, + loaded: true, + revealed_attribute: Some("01A".to_string()), + ..IdentityState::default() + }) + }; + + let mut moved = revealed(); + apply(&mut moved, &PersonaAction::Select(1)); + assert!(personas(&moved).revealed_attribute.is_none(), "selection"); + + let mut tabbed = revealed(); + apply(&mut tabbed, &PersonaAction::TabNext); + assert!(personas(&tabbed).revealed_attribute.is_none(), "tab"); + + let mut toggled = revealed(); + apply(&mut toggled, &PersonaAction::ToggleValues); + assert!(personas(&toggled).revealed_attribute.is_none(), "values"); + + let mut re_read = revealed(); + PersonaOutcome::Read { + attributes: Ok(vec![attribute("01B"), attribute("01A")]), + profiles: Ok(Vec::new()), + disclosures: Ok(Vec::new()), + bindings: HashMap::new(), + include_values: true, + } + .apply(&mut re_read); + assert!(personas(&re_read).revealed_attribute.is_none(), "re-read"); + } + /// Toggling values is a re-read, not a redraw — a listing fetched without /// values does not hold them. #[test] diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index 7ebaa26..dd7a4d1 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -52,6 +52,8 @@ use crate::state_handler::{ state::ConnectionState, }; use openvtc_core::display::display_identifier; +use openvtc_core::persona::pool::PoolAttribute; +use openvtc_core::persona::profile::ResolvedClaim; use ratatui::{ style::{Style, Stylize}, text::{Line, Span}, @@ -200,7 +202,8 @@ fn hints(state: &IdentityState) -> &'static str { "↑/↓ select n: new persona g: agent names d: remove unused ⇥/⇧⇥: tab" } PersonaTab::Attributes => { - "↑/↓ select n: add a fact e: edit d: delete v: values r: refresh ⇥/⇧⇥: tab" + "↑/↓ select n: add a fact e: edit d: delete v: values s: show one \ + r: refresh ⇥/⇧⇥: tab" } PersonaTab::Profiles => { "↑/↓ select ⏎: what it shows n: make a face e: edit d: delete r: refresh ⇥/⇧⇥: tab" @@ -352,6 +355,22 @@ fn render_attributes(state: &IdentityState, lines: &mut Vec>) { return; } + // Said once, above the rows, rather than on each of them: it explains the + // key, and it says what the mask is worth. Only when something on screen is + // actually masked — an explanation of a mechanism the holder is not looking + // at is noise. + if state.attributes.iter().any(PoolAttribute::is_masked) { + lines.push( + Line::from( + " Some facts are masked by what they are — `s` shows the selected one. The \ + mask is against someone reading over your shoulder; your agent has already \ + sent the value here.", + ) + .fg(COLOR_DARK_GRAY), + ); + lines.push(Line::from("")); + } + for (i, attr) in state.attributes.iter().enumerate() { let is_selected = i == state.attribute_selected; let row_style = if is_selected { @@ -381,17 +400,42 @@ fn render_attributes(state: &IdentityState, lines: &mut Vec>) { }, ), ])); - lines.push(Line::from(Span::styled( - format!( - " {}", - truncate(&attr.display_value(state.show_values), 70) - ), + // A reveal is granted to one fact, and only while it is the selected + // one. Checking the selection as well as the identifier means a list + // that re-sorted under a stale grant cannot open a row nobody chose. + let revealed = + is_selected && state.revealed_attribute.as_deref() == Some(attr.attribute_id.as_str()); + let value = if revealed { + attr.revealed_value(state.show_values) + } else { + attr.display_value(state.show_values) + }; + let mut value_spans = vec![Span::styled( + format!(" {}", truncate(&value, 70)), if attr.stale { Style::new().fg(COLOR_WARNING_ACCESSIBLE_RED) } else { Style::new().fg(COLOR_DARK_GRAY) }, - ))); + )]; + // Without this the row is a wrong answer rather than a reduced one: + // `••••••••` and "(no value)" are the same shape, and a holder reading + // the first as the second believes they hold nothing. + if attr.is_masked() { + value_spans.push(Span::styled( + if revealed { + " showing — s to mask" + } else { + " masked — s to show" + }, + if revealed { + Style::new().fg(COLOR_ORANGE) + } else { + Style::new().fg(COLOR_SOFT_PURPLE) + }, + )); + } + lines.push(Line::from(value_spans)); } } @@ -450,6 +494,19 @@ fn render_profiles(state: &IdentityState, lines: &mut Vec>) { } } lines.push(Line::from("")); + // No per-claim reveal here: a face has no cursor over its claims, so + // the only reveal this view could offer is the blanket one the mask + // exists to avoid. The one-at-a-time reveal lives with the facts. + if detail.resolved.iter().any(ResolvedClaim::is_masked) { + lines.push( + Line::from( + " Some values are masked by what they are. Read one of them among your \ + facts, where they open one at a time.", + ) + .fg(COLOR_DARK_GRAY), + ); + lines.push(Line::from("")); + } lines.push(Line::from(" ⏎/Esc: back e: edit").fg(COLOR_DARK_GRAY)); return; } @@ -1143,6 +1200,17 @@ mod tests { attr.stale = true; attr.stale_reason = Some("revoked".into()); + // A masked row, so the vocabulary guard reads the masking copy too — + // the header note, the row marker and the face-detail line only appear + // when something on screen is actually masked. + let card = PoolAttribute { + attribute_id: "01B".into(), + claim_type: "payment.card".into(), + label: Some("Everyday card".into()), + value: Some(serde_json::json!("4242424242424242")), + ..PoolAttribute::default() + }; + IdentityState { tab, show_values: true, @@ -1152,7 +1220,7 @@ mod tests { ..ManagedDid::default() }] .into(), - attributes: vec![attr].into(), + attributes: vec![attr, card].into(), profiles: vec![ProfileSummary { profile_id: "01P".into(), name: "Work".into(), @@ -1185,12 +1253,20 @@ mod tests { name: "Work".into(), ..ProfileSummary::default() }, - resolved: vec![ResolvedClaim { - claim_type: "nickname".into(), - value: Some(serde_json::json!("Ace")), - attribute_id: None, - ..ResolvedClaim::default() - }], + resolved: vec![ + ResolvedClaim { + claim_type: "nickname".into(), + value: Some(serde_json::json!("Ace")), + attribute_id: None, + ..ResolvedClaim::default() + }, + ResolvedClaim { + claim_type: "phone.mobile".into(), + value: Some(serde_json::json!("+61400123456")), + attribute_id: Some("01C".into()), + ..ResolvedClaim::default() + }, + ], ..ProfileDetail::default() }), ..IdentityState::default() @@ -1595,4 +1671,167 @@ mod tests { "exactly the inline claim is marked: {out}" ); } + + /// A fact whose claim type is sensitive is masked in the holder's own list, + /// and the row says it is masked rather than reading as empty. + /// + /// The failure this refuses is the quiet one: `••••••••` and "(no value)" + /// occupy the same space, and a holder who reads the first as the second + /// concludes they never stored the thing they are looking at. + #[test] + fn a_sensitive_fact_is_masked_and_the_row_says_so() { + let mut state = IdentityState { + tab: PersonaTab::Attributes, + show_values: true, + attributes: vec![ + card(), + PoolAttribute { + attribute_id: "01N".into(), + claim_type: "name.given".into(), + label: Some("First name".into()), + value: Some(serde_json::json!("Alice")), + ..PoolAttribute::default() + }, + ] + .into(), + ..IdentityState::default() + }; + loaded(&mut state); + + let out = text(&render(&state)); + assert!(out.contains("••••••••••••4242"), "{out}"); + assert!(!out.contains("4242424242424242"), "{out}"); + assert_eq!( + out.matches("masked — s to show").count(), + 1, + "exactly the sensitive fact is marked: {out}" + ); + assert!( + !out.contains("(no value)"), + "a masked value is held, not absent: {out}" + ); + // The name is shown as it is held: masking everything would make the + // reveal a reflex. + assert!(out.contains("Alice"), "{out}"); + } + + /// The reveal is granted to one fact — the selected one — and nothing else + /// on screen opens with it. + #[test] + fn a_reveal_opens_only_the_selected_fact() { + let second = PoolAttribute { + attribute_id: "01P".into(), + claim_type: "phone.mobile".into(), + label: Some("Mobile".into()), + value: Some(serde_json::json!("+61400123456")), + ..PoolAttribute::default() + }; + let mut state = IdentityState { + tab: PersonaTab::Attributes, + show_values: true, + attributes: vec![card(), second].into(), + attribute_selected: 0, + revealed_attribute: Some("01B".into()), + ..IdentityState::default() + }; + loaded(&mut state); + + let out = text(&render(&state)); + assert!(out.contains("4242424242424242"), "{out}"); + assert!(out.contains("showing — s to mask"), "{out}"); + assert!( + !out.contains("+61400123456"), + "the other sensitive fact stays masked: {out}" + ); + + // A grant that no longer names the selected row opens nothing: a list + // that re-sorted under it must not reveal a row nobody chose. + state.attribute_selected = 1; + let moved = text(&render(&state)); + assert!(!moved.contains("4242424242424242"), "{moved}"); + assert!(!moved.contains("+61400123456"), "{moved}"); + } + + /// The pane says what the mask is worth, where a holder is deciding whether + /// to trust it. It is protection from an onlooker, not from anything that + /// has already been given the value. + #[test] + fn the_pane_does_not_overclaim_what_masking_buys() { + let mut state = IdentityState { + tab: PersonaTab::Attributes, + show_values: true, + attributes: vec![card()].into(), + ..IdentityState::default() + }; + loaded(&mut state); + + let out = text(&render(&state)); + assert!(out.contains("reading over your shoulder"), "{out}"); + assert!(out.contains("already sent the value here"), "{out}"); + } + + /// The note appears only when something on screen is masked — an + /// explanation of a mechanism the holder is not looking at is noise. + #[test] + fn the_masking_note_stays_off_a_screen_with_nothing_masked() { + let mut state = IdentityState { + tab: PersonaTab::Attributes, + show_values: true, + attributes: vec![PoolAttribute { + attribute_id: "01N".into(), + claim_type: "name.given".into(), + value: Some(serde_json::json!("Alice")), + ..PoolAttribute::default() + }] + .into(), + ..IdentityState::default() + }; + loaded(&mut state); + + let out = text(&render(&state)); + assert!(!out.contains("reading over your shoulder"), "{out}"); + assert!(!out.contains("masked"), "{out}"); + } + + /// A face masks what it shows too, and points at where a value can be read + /// one at a time — the detail view has no cursor of its own to reveal from. + #[test] + fn a_face_masks_its_values_and_says_where_to_read_one() { + use openvtc_core::persona::profile::{ProfileDetail, ProfileSummary, ResolvedClaim}; + let mut state = IdentityState { + tab: PersonaTab::Profiles, + open_profile: Some(ProfileDetail { + summary: ProfileSummary { + profile_id: "01P".into(), + name: "Work".into(), + ..ProfileSummary::default() + }, + resolved: vec![ResolvedClaim { + claim_type: "payment.card".into(), + value: Some(serde_json::json!("4242424242424242")), + attribute_id: Some("01B".into()), + ..ResolvedClaim::default() + }], + ..ProfileDetail::default() + }), + ..IdentityState::default() + }; + loaded(&mut state); + + let out = text(&render(&state)); + assert!(out.contains("••••••••••••4242"), "{out}"); + assert!(!out.contains("4242424242424242"), "{out}"); + assert!(out.contains("among your"), "{out}"); + } + + /// The one masked fact the tests above share. + fn card() -> PoolAttribute { + PoolAttribute { + attribute_id: "01B".into(), + claim_type: "payment.card".into(), + label: Some("Everyday card".into()), + value: Some(serde_json::json!("4242424242424242")), + ..PoolAttribute::default() + } + } } diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index a2c4e14..ed94673 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -623,6 +623,11 @@ impl MainPage { KeyCode::Up if count > 0 => send(PA::Select(selected.saturating_sub(1))), KeyCode::Down if count > 0 => send(PA::Select((selected + 1).min(count - 1))), KeyCode::Char('v') => send(PA::ToggleValues), + // `s` shows one value; `v` asks the agent for all of them. + // Two different questions, and the narrower one is not a + // shortcut to the wider: `s` on a listing fetched without + // values has nothing to unmask. + KeyCode::Char('s') if selected < count => send(PA::RevealValue(selected)), KeyCode::Char('n') => send(PA::AttributeNew), KeyCode::Char('e') if selected < count => send(PA::AttributeEdit(selected)), KeyCode::Char('d') | KeyCode::Delete if selected < count => { @@ -3525,6 +3530,7 @@ mod key_handler_tests { (KeyCode::Char('e'), PersonaAction::AttributeEdit(0)), (KeyCode::Char('d'), PersonaAction::AttributeDeleteArm(0)), (KeyCode::Char('v'), PersonaAction::ToggleValues), + (KeyCode::Char('s'), PersonaAction::RevealValue(0)), (KeyCode::Char('r'), PersonaAction::Refresh), ] { let (mut page, mut rx) = open(); @@ -3540,6 +3546,17 @@ mod key_handler_tests { } } + /// `s` needs a row under the cursor. On an empty list it is not a verb, and + /// a pane that consumed it would swallow a key that meant nothing here. + #[test] + fn personas_reveal_needs_a_row() { + let (mut page, mut rx) = page_for(MainMenu::Identity, |s| { + s.main_page.content_panel.identity.tab = PersonaTab::Attributes; + }); + page.handle_key_event(press(KeyCode::Char('s'))); + assert!(rx.try_recv().is_err(), "no fact, no reveal"); + } + /// An armed confirmation owns `y`/`n` — every other pane verb is suppressed /// while a destructive question is on screen. #[test] From 0507b4681b37874731c82520eb1bb01a614b5c47 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 16:53:06 +0200 Subject: [PATCH 2/4] docs(persona): the vendored table is private, so link it as prose Signed-off-by: Glenn Gore --- openvtc-core/src/persona/claim_types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openvtc-core/src/persona/claim_types.rs b/openvtc-core/src/persona/claim_types.rs index 449ddcc..12c6fe0 100644 --- a/openvtc-core/src/persona/claim_types.rs +++ b/openvtc-core/src/persona/claim_types.rs @@ -13,7 +13,7 @@ //! `persona/claim-types/list` task — the registry's own §6 lists adding one as //! an open question — so a client that wants a default has to ship it. Re-sync //! by hand against the file named above when the registry moves; the whole of -//! the copy is [`TABLE`] plus [`UNREGISTERED`]. +//! the copy is the private `TABLE` in this file plus [`UNREGISTERED`]. //! //! # This is not a security control, and it must not be described as one //! From 10afdea7a51c37b0984a6ae77993ac7d47a9d871 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 17:01:19 +0200 Subject: [PATCH 3/4] feat(persona): follow the registry's revised masking rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 23 ++- openvtc-core/src/persona/claim_types.rs | 250 +++++++++++++++++++----- openvtc-core/src/persona/pool.rs | 41 ++-- 3 files changed, 244 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da55032..78b582a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Facts that are sensitive by what they are stay masked, and say so.** The identity pane resolves each fact's claim type against the persona claim-type - registry (`specs/persona/_shared/0.1/claim-types.json`) and paints the - `high`-sensitivity ones reduced — `••••••••••••4242` for a card, `••••••••••56` - for a mobile number, `••••••••` for a date of birth. A type the registry does - not list, and anything under `x:`, resolves to the conservative default: - masked entirely. `s` shows the selected fact and only that one; moving the - selection, changing tab or re-reading puts it back. + registry (`specs/persona/_shared/0.1/claim-types.json`) and paints anything + carrying a mask style reduced — `••••••••••••4242` for a card, `••••••••••56` + for a mobile number, `a•••@example.com` for an email address, `••••••••` for a + date of birth. A type the registry does not list, and anything under `x:`, + resolves to the conservative default: masked entirely; a new token in a + registered family (`payment.giftCard`) inherits the family rather than the + floor. `s` shows the selected fact and only that one; moving the selection, + changing tab or re-reading puts it back. A masked row says *masked — s to show*, because `••••••••` and *(no value)* are the same shape and one of them is a wrong answer about what the holder @@ -24,10 +26,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). **This is not a security control and the pane does not claim it is.** The value has already been fetched, decrypted by the agent and parked in this process; masking it defends against someone reading the terminal over a - shoulder and against a screenshot, and against nothing else. 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 yet. + shoulder and against a screenshot, and against nothing else. The half of the + registry that is not cosmetic is `sensitivity: high` — *withheld from a + listing that did not ask for sensitive values* — and that is a read-path + control which does not exist: `persona/attribute/list` takes `includeValues` + and nothing finer. The mask is what makes that missing control visible. The table is a **vendored copy**: the agent serves no claim-type registry, so `openvtc-core/src/persona/claim_types.rs` ships one and names the file to diff --git a/openvtc-core/src/persona/claim_types.rs b/openvtc-core/src/persona/claim_types.rs index 12c6fe0..485b5bc 100644 --- a/openvtc-core/src/persona/claim_types.rs +++ b/openvtc-core/src/persona/claim_types.rs @@ -23,22 +23,26 @@ //! reading the terminal over a shoulder, and a screenshot or a screen share //! carrying a card number to an audience that was never asked. //! -//! The control that would actually matter is on the read path — an -//! `includeSensitive` flag on `persona/attribute/list`, so a listing that did -//! not ask for sensitive values is never *sent* them. It does not exist: -//! `persona_attribute_list` takes `include_values` and nothing finer. The -//! registry says the same thing in §3.1 — "masking a value already fetched is -//! theatre; the control that matters is on the read path" — and the mask is -//! what makes that missing control visible rather than a substitute for it. +//! # Masking is the half of the registry this client can honour //! -//! # Only `sensitivity` decides whether to mask +//! The registry's §3.3 makes `mask` and `sensitivity` two decisions, not one, +//! and they land in two different places: //! -//! `mask` says *how* a value is reduced, not *whether* it is. A `normal` type -//! carrying a style (`email.work` is `normal` + `emailLocal`) is shown in full -//! to its own holder; the style is there for the surfaces that reduce a value -//! for someone else. Reading the style as the trigger would mask a work email -//! address in a holder's own list, which teaches the reveal key as a reflex — -//! and a reveal pressed by reflex protects nothing. +//! - **`mask`** is a rendering. Any type whose style is not `none` is shown +//! reduced, whatever its sensitivity — which is why `email.work` is masked +//! here despite being `normal`. An address is worth hiding from the person +//! behind you without being worth withholding from every listing. +//! - **`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. [`Sensitivity`] is carried here so a caller can read it, and +//! nothing in this crate can act on it. +//! +//! So the mask is what makes a missing control visible, not a substitute for +//! it. A `high` type is masked *because its style says so*, not because +//! anything here withheld it. /// How carefully a value is shown **to its own holder**. /// @@ -48,10 +52,12 @@ /// card is highly sensitive and barely linkable; a nickname can be the reverse. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Sensitivity { - /// Shown as it is held. + /// Nothing beyond whatever [`MaskStyle`] the type carries. #[default] Normal, - /// Masked until the holder asks for this one value. + /// Additionally withheld from a listing that did not ask for sensitive + /// values — a read-path control this client cannot exercise. See the module + /// header. High, } @@ -138,9 +144,15 @@ pub struct ClaimTypeDefaults { impl ClaimTypeDefaults { /// Whether a value of this type is shown masked by default. + /// + /// The style alone decides, per §3.3 — a `high` type reaches this through + /// its style like any other. Reading `sensitivity` as the trigger is the + /// tangle the registry's first draft had and its second draft names: it + /// left `email.*` carrying an `emailLocal` style that no rule could ever + /// apply, while §1 used `a•••@example.com` to motivate the registry. #[must_use] pub fn masks_by_default(self) -> bool { - self.sensitivity == Sensitivity::High && self.mask.hides_anything() + self.mask.hides_anything() } /// The value as this type shows it — masked when the type asks for it. @@ -154,13 +166,13 @@ impl ClaimTypeDefaults { } } -/// What a type this registry has never seen resolves to. +/// The floor: what a token resolves to when nothing more specific supplies an +/// axis. /// -/// The conservative answer, and the registry's §4 rule 3: a vocabulary nobody +/// The conservative answer, and the registry's §4 rule 4: a vocabulary nobody /// has reasoned about is exactly the one nothing is known about, and an unknown -/// value rendered in the clear is a decision nobody made. It applies to -/// unregistered tokens and to the open `x:` namespace alike — the registry -/// gives both the same defaults. +/// value rendered in the clear is a decision nobody made. `x:` tokens are +/// unregistered by construction and land here too. pub const UNREGISTERED: ClaimTypeDefaults = ClaimTypeDefaults { sensitivity: Sensitivity::High, mask: MaskStyle::Full, @@ -169,6 +181,13 @@ pub const UNREGISTERED: ClaimTypeDefaults = ClaimTypeDefaults { /// The vendored table, in `claim-types.json`'s order so the two diff against /// each other by eye. const TABLE: &[(&str, Sensitivity, MaskStyle)] = &[ + // Family entries, matched as prefixes. Without them `payment.somethingNew` + // resolves to the floor, and a gated family becomes leavable by inventing a + // token. `name` is also an exact token: a pool that keeps one + // undifferentiated name is using it. + ("payment", Sensitivity::High, MaskStyle::Full), + ("gov", Sensitivity::High, MaskStyle::Full), + ("name", Sensitivity::Normal, MaskStyle::None), ("name.legal", Sensitivity::Normal, MaskStyle::None), ("name.given", Sensitivity::Normal, MaskStyle::None), ("name.family", Sensitivity::Normal, MaskStyle::None), @@ -201,25 +220,116 @@ const TABLE: &[(&str, Sensitivity, MaskStyle)] = &[ ("org.role", Sensitivity::Normal, MaskStyle::None), ]; -/// Resolve a claim type to how its value is shown. +/// The namespace the registry leaves open, and never resolves through a family. +const EXTENSION_PREFIX: &str = "x:"; + +impl Sensitivity { + /// Position in `claim-types.json`'s `strictness` ordering, most protective + /// first. + fn strictness(self) -> u8 { + match self { + Self::High => 0, + Self::Normal => 1, + } + } +} + +impl MaskStyle { + /// Position in `claim-types.json`'s `strictness` ordering, most protective + /// first. `last2` outranks `last4` because it shows fewer characters. + fn strictness(self) -> u8 { + match self { + Self::Full => 0, + Self::Last2 => 1, + Self::Last4 => 2, + Self::EmailLocal => 3, + Self::None => 4, + } + } +} + +impl ClaimTypeDefaults { + /// The more protective of two answers, taken per axis. + /// + /// Per axis rather than whole-record, because the axes are independent + /// (§3) and a record chosen as a unit would carry one axis's answer on the + /// strength of another's. + fn tightest(self, other: Self) -> Self { + Self { + sensitivity: if self.sensitivity.strictness() <= other.sensitivity.strictness() { + self.sensitivity + } else { + other.sensitivity + }, + mask: if self.mask.strictness() <= other.mask.strictness() { + self.mask + } else { + other.mask + }, + } + } +} + +/// Resolve a claim type to how its value is shown — the registry's §4, minus +/// the rule this client cannot reach. +/// +/// 1. Rule 1 — a holder's explicit override — is **not implemented, because +/// there is nowhere to store one.** `persona/attribute/put` has no +/// `sensitivity` or `mask` member and the SDK's attribute carries neither, +/// so the choice the rule resolves first cannot currently be made. When it +/// can, it belongs above everything here. +/// 2. An exact entry is used **as written**, and is not compared against +/// anything: it is a decision someone made about that token. +/// 3. Otherwise the longest registered *prefix* is taken together with +/// [`UNREGISTERED`], and the more protective of the two wins on each axis. +/// A family entry can therefore only ever tighten — `name` as a prefix does +/// not make an unregistered `name.somethingNew` visible. +/// 4. Otherwise the floor. /// -/// The match is exact. A token is not resolved by walking up its family — -/// `payment.giftCard` is unregistered, not "a `payment.*`" — because the -/// families are a naming convention rather than a scope, and inheriting from a -/// prefix would let a *new* `x:payment.…` inherit a registered family's -/// answer. Unregistered is already the conservative one; there is nothing a -/// prefix walk could add but a way to be wrong. +/// Rule 3 is what stops a gated family being left by inventing a token: +/// without it `payment.giftCard` would resolve to the floor, whose `release` +/// is `consent` — weaker than every registered member of the family it plainly +/// belongs to. #[must_use] pub fn resolve(claim_type: &str) -> ClaimTypeDefaults { + if let Some(exact) = entry(claim_type) { + return exact; + } + // The open namespace is unregistered by construction, so it never inherits + // a family's answer: `x:payment.card` is a token this registry has never + // seen that happens to read like one it has. + if claim_type.starts_with(EXTENSION_PREFIX) { + return UNREGISTERED; + } + longest_registered_prefix(claim_type) + .map_or(UNREGISTERED, |family| family.tightest(UNREGISTERED)) +} + +fn entry(claim_type: &str) -> Option { TABLE .iter() .find(|(token, _, _)| *token == claim_type) - .map_or(UNREGISTERED, |(_, sensitivity, mask)| ClaimTypeDefaults { + .map(|(_, sensitivity, mask)| ClaimTypeDefaults { sensitivity: *sensitivity, mask: *mask, }) } +/// The longest registered ancestor of a token, on `.` boundaries. +/// +/// Boundaries matter: `paymentx.foo` is not in the `payment` family, and a +/// plain `starts_with` would put it there. +fn longest_registered_prefix(claim_type: &str) -> Option { + let mut cut = claim_type.len(); + while let Some(dot) = claim_type[..cut].rfind('.') { + if let Some(found) = entry(&claim_type[..dot]) { + return Some(found); + } + cut = dot; + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -230,43 +340,89 @@ mod tests { #[test] fn registered_types_resolve_to_their_entry() { assert_eq!(resolve("name.legal").sensitivity, Sensitivity::Normal); - assert_eq!(resolve("payment.card").mask, MaskStyle::Last4); assert_eq!(resolve("phone.mobile").mask, MaskStyle::Last2); assert!(resolve("gov.id.passport").masks_by_default()); assert!(!resolve("org.role").masks_by_default()); + // An exact entry is used as written and is not compared against its + // family: `payment.card` shows its last four even though the `payment` + // family entry is `full`. Someone decided that about that token. + assert_eq!(resolve("payment.card").mask, MaskStyle::Last4); } - /// An unregistered token and an `x:` extension get the conservative answer. + /// An unregistered token with no registered family gets the conservative + /// answer. /// - /// This is the case the whole default exists for: a build that has never - /// heard of a vocabulary knows nothing about what it holds, and showing it - /// in the clear would be a decision nobody made. + /// This is the case the floor exists for: a build that has never heard of a + /// vocabulary knows nothing about what it holds, and showing it in the + /// clear would be a decision nobody made. #[test] fn an_unknown_type_masks_fully() { - for token in ["x:employer.badge", "medical.condition", "", "payment"] { + for token in ["medical.condition", "", "somethingElse"] { let resolved = resolve(token); assert_eq!(resolved.sensitivity, Sensitivity::High, "{token}"); assert_eq!(resolved.mask, MaskStyle::Full, "{token}"); } } - /// A family prefix is not a scope. `payment.giftCard` is a token this build - /// has never seen, and inheriting `payment.card`'s entry would be a guess - /// dressed as a lookup. + /// A new token in a gated family inherits the family, not the floor. + /// + /// Without this a gated family is leavable by inventing a token: + /// `payment.giftCard` would resolve to the unregistered default, whose + /// `release` is weaker than every registered member of the family it + /// plainly belongs to. #[test] - fn a_family_prefix_does_not_inherit() { - assert_eq!(resolve("payment.giftCard"), UNREGISTERED); + fn a_new_token_inherits_its_registered_family() { + assert_eq!(resolve("payment.giftCard"), resolve("payment")); + assert_eq!(resolve("gov.id.somethingNew").mask, MaskStyle::Full); + } + + /// A family can only tighten. `name` is `normal`/`none`, and an unknown + /// `name.*` still lands on the floor rather than being shown in the clear + /// on the strength of its prefix. + #[test] + fn a_family_never_loosens_the_floor() { + assert_eq!(resolve("name.somethingNew"), UNREGISTERED); + // …while the family token itself, being an exact entry, is used as + // written. + assert_eq!(resolve("name").mask, MaskStyle::None); + } + + /// A prefix is a prefix on `.` boundaries. `paymentx` is not in the + /// `payment` family, and a plain `starts_with` would put it there. + #[test] + fn a_family_matches_on_segment_boundaries() { + assert_eq!(resolve("paymentx.token"), UNREGISTERED); + assert_eq!(resolve("governance.role"), UNREGISTERED); + } + + /// The open namespace never inherits a family: `x:payment.card` is a token + /// this registry has never seen that happens to read like one it has. + #[test] + fn an_extension_token_never_inherits() { + assert_eq!(resolve("x:employer.badge"), UNREGISTERED); assert_eq!(resolve("x:payment.card"), UNREGISTERED); + assert_eq!(resolve("x:name.given"), UNREGISTERED); } - /// `normal` sensitivity is shown whole even when the type names a style — - /// see the module header. `email.work` is the case in the registry today. + /// The style masks whatever the sensitivity says. `email.work` is `normal` + /// and masked, which is §3.3's whole point: an address is worth hiding from + /// the person behind you without being worth withholding from a listing. #[test] - fn a_normal_type_is_not_masked_by_its_style() { + fn a_normal_type_is_masked_by_its_style() { let email = resolve("email.work"); - assert_eq!(email.mask, MaskStyle::EmailLocal); - assert!(!email.masks_by_default()); - assert_eq!(email.render("alice@example.com"), "alice@example.com"); + assert_eq!(email.sensitivity, Sensitivity::Normal); + assert!(email.masks_by_default()); + assert_eq!(email.render("alice@example.com"), "a•••@example.com"); + } + + /// …and a `normal` type with no style is shown as it is held. Masking + /// everything would teach the reveal as a reflex, and a reveal pressed by + /// reflex protects nothing. + #[test] + fn a_type_with_no_style_is_shown_whole() { + let name = resolve("name.given"); + assert!(!name.masks_by_default()); + assert_eq!(name.render("Alice"), "Alice"); } /// Each style keeps exactly the characters it says it keeps. diff --git a/openvtc-core/src/persona/pool.rs b/openvtc-core/src/persona/pool.rs index d34f77b..f6a3971 100644 --- a/openvtc-core/src/persona/pool.rs +++ b/openvtc-core/src/persona/pool.rs @@ -178,9 +178,9 @@ impl PoolAttribute { } } - /// How this attribute's type says its value should be shown. + /// What this attribute's claim type says about showing its value. #[must_use] - pub fn sensitivity(&self) -> ClaimTypeDefaults { + pub fn claim_defaults(&self) -> ClaimTypeDefaults { claim_types::resolve(&self.claim_type) } @@ -192,7 +192,7 @@ impl PoolAttribute { /// is a wrong answer about what the holder holds. #[must_use] pub fn is_masked(&self) -> bool { - !self.stale && self.value.is_some() && self.sensitivity().masks_by_default() + !self.stale && self.value.is_some() && self.claim_defaults().masks_by_default() } /// The value as one line, or the reason there is none — masked when its @@ -232,7 +232,7 @@ impl PoolAttribute { if reveal { text } else { - self.sensitivity().render(&text) + self.claim_defaults().render(&text) } }; match &self.value { @@ -494,12 +494,14 @@ mod tests { } /// A string value renders as itself, not as a quoted JSON string — the - /// panel shows `alice@example.com`, never `"alice@example.com"`. + /// panel shows `Alice`, never `"Alice"`. An unmasked type, so the + /// assertion is about the quoting and not about the mask. #[test] fn a_string_value_renders_unquoted() { let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); - attr.value = Some(Value::String("alice@example.com".into())); - assert_eq!(attr.display_value(true), "alice@example.com"); + attr.claim_type = "name.given".into(); + attr.value = Some(Value::String("Alice".into())); + assert_eq!(attr.display_value(true), "Alice"); } /// A typed field stores the type it declares. The number case is the one @@ -540,15 +542,28 @@ mod tests { assert_eq!(attr.revealed_value(true), "4242424242424242"); } - /// A type the registry lists as `normal` is shown as it is held. Masking - /// every fact would teach the reveal key as a reflex, and a reveal pressed - /// by reflex protects nothing. + /// A type whose style is `none` is shown as it is held. Masking every fact + /// would teach the reveal key as a reflex, and a reveal pressed by reflex + /// protects nothing. #[test] - fn a_normal_value_is_not_masked() { + fn a_value_with_no_mask_style_is_shown_whole() { let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); - attr.value = Some(Value::String("alice@example.com".into())); + attr.claim_type = "name.given".into(); + attr.value = Some(Value::String("Alice".into())); assert!(!attr.is_masked()); - assert_eq!(attr.display_value(true), "alice@example.com"); + assert_eq!(attr.display_value(true), "Alice"); + } + + /// Sensitivity is not what triggers the mask — the style is. An email + /// address is `normal` and still masked: worth hiding from the person + /// behind you without being worth withholding from a listing. + #[test] + fn a_normal_type_with_a_style_is_still_masked() { + let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); + attr.value = Some(Value::String("alice@example.com".into())); + assert!(attr.is_masked()); + assert_eq!(attr.display_value(true), "a•••@example.com"); + assert_eq!(attr.revealed_value(true), "alice@example.com"); } /// A vocabulary this build has never seen is masked, because nothing here From e6053539f95476da82bfe6a004576e282d749f43 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 7 Sep 2026 17:05:51 +0200 Subject: [PATCH 4/4] test(persona): the mask is a rendering, and the words say which axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- openvtc-core/src/persona/pool.rs | 14 ++++----- openvtc-core/src/persona/profile.rs | 4 +-- openvtc/src/state_handler/actions/mod.rs | 4 +-- openvtc/src/state_handler/persona_actions.rs | 29 +++++++++++++++++++ .../pages/main/components/identity_panel.rs | 10 +++---- 5 files changed, 45 insertions(+), 16 deletions(-) diff --git a/openvtc-core/src/persona/pool.rs b/openvtc-core/src/persona/pool.rs index f6a3971..686d446 100644 --- a/openvtc-core/src/persona/pool.rs +++ b/openvtc-core/src/persona/pool.rs @@ -212,8 +212,8 @@ impl PoolAttribute { /// value. /// /// A separate method rather than a `reveal: bool` on - /// [`display_value`](Self::display_value), so that reading a sensitive - /// value in the clear is something a call site had to *name*. A boolean + /// [`display_value`](Self::display_value), so that reading a masked value + /// in the clear is something a call site had to *name*. A boolean /// gets passed through, and the caller that ends up passing `true` is /// rarely the one that meant to. #[must_use] @@ -525,14 +525,14 @@ mod tests { assert!(parse_typed_value("maybe", ValueType::Boolean).is_err()); } - /// A high-sensitivity value is masked by its type, and reads back whole + /// A value whose type carries a mask style is masked, and reads back whole /// only when a caller asks for that one value. /// /// The pairing is the point: the mask has to be liftable, or a holder /// cannot check their own card number; and lifting it has to be a /// different call, or it is not a decision anyone made. #[test] - fn a_sensitive_value_is_masked_until_it_is_asked_for() { + fn a_masked_value_is_only_whole_when_it_is_asked_for() { let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); attr.claim_type = "payment.card".into(); attr.value = Some(Value::String("4242424242424242".into())); @@ -593,10 +593,10 @@ mod tests { } /// A stale value keeps saying it is stale. The reason it cannot be shown is - /// not that it is sensitive, and a mask over it would hide the one thing - /// the holder needs to act on. + /// not that it is masked, and a mask over it would hide the one thing the + /// holder needs to act on. #[test] - fn a_stale_sensitive_value_still_says_it_is_stale() { + fn a_stale_masked_value_still_says_it_is_stale() { let mut attr = PoolAttribute::from_wire(&wire("credentialBacked")); attr.claim_type = "gov.id.passport".into(); attr.value = Some(Value::String("P1234567".into())); diff --git a/openvtc-core/src/persona/profile.rs b/openvtc-core/src/persona/profile.rs index 6d10238..138d338 100644 --- a/openvtc-core/src/persona/profile.rs +++ b/openvtc-core/src/persona/profile.rs @@ -448,13 +448,13 @@ mod tests { assert!(claim.display_value().contains("can no longer be proven")); } - /// A face shows a sensitive value masked too, and says that it did. + /// A face masks what its type says to mask, and says that it did. /// /// The face detail view is a screen a holder opens to check what a /// community sees, which is exactly the screen someone else is most likely /// to be looking at over their shoulder. #[test] - fn a_sensitive_claim_is_masked_on_a_face() { + fn a_masked_claim_is_masked_on_a_face_too() { let claim = ResolvedClaim::from_wire(&serde_json::json!({ "type": "phone.mobile", "value": "+61400123456", diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index 8fb3df5..89f1157 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -207,8 +207,8 @@ pub enum PersonaAction { ToggleValues, /// Show the selected fact's value unmasked, or stop showing it. /// - /// One fact, not a mode: a claim type the registry calls sensitive is - /// masked even in a listing that asked for values, and this lifts that for + /// One fact, not a mode: a claim type carrying a mask style is shown + /// reduced even in a listing that asked for values, and this lifts that for /// the row under the cursor only. Moving the selection puts it back. RevealValue(usize), diff --git a/openvtc/src/state_handler/persona_actions.rs b/openvtc/src/state_handler/persona_actions.rs index ee8c0a4..39a7f43 100644 --- a/openvtc/src/state_handler/persona_actions.rs +++ b/openvtc/src/state_handler/persona_actions.rs @@ -1121,6 +1121,35 @@ mod tests { assert!(personas(&state).revealed_attribute.is_none()); } + /// The editor opens on the value, never on the mask. + /// + /// A mask is a rendering and must never reach what is stored or sent — + /// which it would, silently and permanently, if a form filled from + /// `display_value` were saved: the holder's card number would become eight + /// bullets and the version check would raise nothing, because the write is + /// perfectly well-formed. + #[test] + fn the_editor_opens_on_the_value_not_on_the_mask() { + let mut attr = attribute("01A"); + attr.claim_type = "payment.card".into(); + attr.value = Some(serde_json::json!("4242424242424242")); + assert!(attr.is_masked(), "the fixture has to be a masked one"); + + let mut state = state_with(IdentityState { + attributes: vec![attr].into(), + show_values: true, + ..IdentityState::default() + }); + apply(&mut state, &PersonaAction::AttributeEdit(0)); + + match &personas(&state).mode { + PersonaMode::Attribute(form) => { + assert_eq!(form.value.value(), "4242424242424242"); + } + _ => panic!("expected the editor"), + } + } + /// A reveal on a row that is not there changes nothing — an index into a /// list that has since shrunk must not open whatever now sits at it. #[test] diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index dd7a4d1..66c4329 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -1672,14 +1672,14 @@ mod tests { ); } - /// A fact whose claim type is sensitive is masked in the holder's own list, - /// and the row says it is masked rather than reading as empty. + /// A fact whose claim type carries a mask style is masked in the holder's + /// own list, and the row says so rather than reading as empty. /// /// The failure this refuses is the quiet one: `••••••••` and "(no value)" /// occupy the same space, and a holder who reads the first as the second /// concludes they never stored the thing they are looking at. #[test] - fn a_sensitive_fact_is_masked_and_the_row_says_so() { + fn a_masked_fact_is_reduced_and_the_row_says_so() { let mut state = IdentityState { tab: PersonaTab::Attributes, show_values: true, @@ -1704,7 +1704,7 @@ mod tests { assert_eq!( out.matches("masked — s to show").count(), 1, - "exactly the sensitive fact is marked: {out}" + "exactly the masked fact is marked: {out}" ); assert!( !out.contains("(no value)"), @@ -1741,7 +1741,7 @@ mod tests { assert!(out.contains("showing — s to mask"), "{out}"); assert!( !out.contains("+61400123456"), - "the other sensitive fact stays masked: {out}" + "the other masked fact stays masked: {out}" ); // A grant that no longer names the selected row opens nothing: a list