diff --git a/CHANGELOG.md b/CHANGELOG.md index 418008f..78b582a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,36 @@ 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 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 + 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 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 + 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..485b5bc --- /dev/null +++ b/openvtc-core/src/persona/claim_types.rs @@ -0,0 +1,474 @@ +//! 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 the private `TABLE` in this file 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. +//! +//! # 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: +//! +//! - **`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**. +/// +/// 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 { + /// Nothing beyond whatever [`MaskStyle`] the type carries. + #[default] + Normal, + /// 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, +} + +/// 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. + /// + /// 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.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() + } + } +} + +/// The floor: what a token resolves to when nothing more specific supplies an +/// axis. +/// +/// 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. `x:` tokens are +/// unregistered by construction and land here too. +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)] = &[ + // 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), + ("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), +]; + +/// 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. +/// +/// 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(|(_, 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::*; + + /// 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("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 with no registered family gets the conservative + /// answer. + /// + /// 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 ["medical.condition", "", "somethingElse"] { + let resolved = resolve(token); + assert_eq!(resolved.sensitivity, Sensitivity::High, "{token}"); + assert_eq!(resolved.mask, MaskStyle::Full, "{token}"); + } + } + + /// 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_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); + } + + /// 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_masked_by_its_style() { + let email = resolve("email.work"); + 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. + #[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..686d446 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. + /// What this attribute's claim type says about showing its value. + #[must_use] + pub fn claim_defaults(&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.claim_defaults().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 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] + 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.claim_defaults().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(), } @@ -449,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 @@ -478,6 +525,91 @@ mod tests { assert!(parse_typed_value("maybe", ValueType::Boolean).is_err()); } + /// 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_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())); + + assert!(attr.is_masked()); + assert_eq!(attr.display_value(true), "••••••••••••4242"); + assert_eq!(attr.revealed_value(true), "4242424242424242"); + } + + /// 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_value_with_no_mask_style_is_shown_whole() { + let mut attr = PoolAttribute::from_wire(&wire("selfAsserted")); + attr.claim_type = "name.given".into(); + attr.value = Some(Value::String("Alice".into())); + assert!(!attr.is_masked()); + 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 + /// 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 masked, and a mask over it would hide the one thing the + /// holder needs to act on. + #[test] + 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())); + 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..138d338 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 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_masked_claim_is_masked_on_a_face_too() { + 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..89f1157 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 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), // ── 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..39a7f43 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,114 @@ 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()); + } + + /// 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] + 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..66c4329 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 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_masked_fact_is_reduced_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 masked 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 masked 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]