Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
474 changes: 474 additions & 0 deletions openvtc-core/src/persona/claim_types.rs

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions openvtc-core/src/persona/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
146 changes: 139 additions & 7 deletions openvtc-core/src/persona/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand Down
67 changes: 63 additions & 4 deletions openvtc-core/src/persona/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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" },
Expand All @@ -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]
Expand Down
6 changes: 6 additions & 0 deletions openvtc/src/state_handler/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions openvtc/src/state_handler/main_page/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,

// ── Profiles (from the agent) ────────────────────────────────────────
pub profiles: Arc<[openvtc_core::persona::profile::ProfileSummary]>,
Expand Down
Loading
Loading