From ed0f4db0de364fce33b1bdf6a6e17a4c61030aa6 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 8 Sep 2026 08:06:33 +0200 Subject: [PATCH] feat(health): print the DID this install authenticates to the VTA as MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VTA keys its ACL entry on a `did:key` minted during setup, so that DID is what an operator has to name in `pnm acl get` / `pnm acl update`. It was reachable from nowhere but the TUI's VTA panel, truncated to fit the pane — while the identity pane's own refusal hint told the reader that `openvtc health` prints it. It did not. A new *VTA access* section now does: the agent, the context, the transport that would be opened (the same mediator-vs-REST rule `build_runtime_vta_client` branches on, rather than a guess from the URL being non-empty), its mediator or REST endpoint, and the authenticating DID in full. Both `pnm acl` commands are printed ready to run, with a line on why `--capabilities persona-holder` grants rather than narrows — it reads as a narrowing everywhere else the flag appears. All of it comes from the loaded config, so it answers on the run where the network leg is the broken thing. `--json` carries it as `vta_access`, and as null for a BIP32 profile so a script need not branch on the backend. The identity pane's hint is fixed alongside it: it named a `--did` flag `pnm acl update` does not have (the DID is positional) and left the DID as a placeholder. It now carries the real one, from the config the pane is already rendering from — the placeholder survives only for a BIP32 account, which has no agent credential to name and will not have produced the refusal in the first place. Signed-off-by: Glenn Gore --- CHANGELOG.md | 27 +++ openvtc/src/health_cmd.rs | 197 +++++++++++++++++- .../src/state_handler/main_page/content.rs | 9 + openvtc/src/state_handler/main_page/mod.rs | 2 + .../pages/main/components/identity_panel.rs | 67 ++++-- 5 files changed, 288 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78b582a..0677963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- **`openvtc health` prints the DID this install authenticates to the VTA as.** + A new *VTA access* section names the agent, the context, the transport that + would be opened (the same mediator-vs-REST rule `build_runtime_vta_client` + branches on), its mediator or REST endpoint, and — in full, untruncated — the + `did:key` this install presents. + + That last one is the point. The VTA keys its ACL entry on it, so it is the + DID an operator has to name in `pnm acl get` / `pnm acl update`, and it was + reachable from nowhere but the TUI's VTA panel, truncated to fit — while the + identity pane's own refusal hint said "`openvtc health` prints the DID". The + section prints both commands ready to run, and says why `--capabilities + persona-holder` grants rather than narrows. + + It is read from the loaded config, so it answers on the run where the network + leg is the broken thing. `--json` carries it as `vta_access`, null for a BIP32 + profile so a script need not branch on the backend first. + +### Fixed + +- **The identity pane's grant hint is now a command you can run.** It named a + `--did` flag that `pnm acl update` does not have (the DID is positional), and + it left the DID itself as `` — a placeholder pointing at + `openvtc health`, which did not print it either. The hint now carries the real + DID from the config the pane is already rendering from. + +### 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 diff --git a/openvtc/src/health_cmd.rs b/openvtc/src/health_cmd.rs index 33b79b4..cee568b 100644 --- a/openvtc/src/health_cmd.rs +++ b/openvtc/src/health_cmd.rs @@ -34,6 +34,7 @@ pub async fn run( recoverable: bool, ) -> Result<()> { let local = local_report(profile); + let access = config.and_then(vta_access); let mut subjects: Vec = Vec::new(); @@ -91,9 +92,19 @@ pub async fn run( // report anything because the half that failed is the half we can't check. if subjects.is_empty() { if as_json { - println!("{}", serde_json::to_string_pretty(&local.as_json())?); + let mut value = local.as_json(); + if let Some(obj) = value.as_object_mut() { + obj.insert( + "vta_access".to_string(), + VtaAccess::as_json(access.as_ref()), + ); + } + println!("{}", serde_json::to_string_pretty(&value)?); } else { local.render(); + if let Some(access) = &access { + access.render(); + } eprintln!( "{}", style( @@ -122,10 +133,17 @@ pub async fn run( let mut value = serde_json::to_value(&report)?; if let Some(obj) = value.as_object_mut() { obj.insert("local".to_string(), local.as_json()); + obj.insert( + "vta_access".to_string(), + VtaAccess::as_json(access.as_ref()), + ); } println!("{}", serde_json::to_string_pretty(&value)?); } else { local.render(); + if let Some(access) = &access { + access.render(); + } render(&report, config.is_none()); } @@ -271,6 +289,128 @@ impl LocalReport { } } +/// How this install reaches the VTA, and — the part that was missing — the DID +/// it authenticates *as*. +/// +/// The VTA keys its ACL on that DID, so it is the one an operator has to name +/// in `pnm acl get` / `pnm acl update` to see or change what this install may +/// do. It is a `did:key` minted during setup and it never appears in the config +/// file (it lives in the credential bundle in the secure store), so short of +/// reading it out of the TUI's VTA panel there was no way to get at it — while +/// the identity pane's own refusal hint says "`openvtc health` prints the DID". +/// Now it does. +/// +/// Everything here is read from the loaded config: no network, so it is +/// answerable on exactly the run where the network leg is the thing that is +/// broken. +struct VtaAccess { + /// The `did:key` this install authenticates to the VTA as. + credential_did: String, + vta_did: String, + /// Empty for a DIDComm-only VTA. + vta_url: String, + /// `Some` when setup reached the VTA over DIDComm; the transport + /// `build_runtime_vta_client` will pick again at runtime. + mediator_did: Option, + context_id: String, +} + +/// `None` for a BIP32 profile: there is no VTA and so no ACL to edit. +fn vta_access(config: &Config) -> Option { + let KeyBackend::Vta { + credential_did, + vta_did, + vta_url, + mediator_did, + .. + } = &config.key_backend + else { + return None; + }; + Some(VtaAccess { + credential_did: credential_did.clone(), + vta_did: vta_did.clone(), + vta_url: vta_url.clone(), + mediator_did: mediator_did.clone(), + context_id: config.account.top_context_id.clone(), + }) +} + +impl VtaAccess { + /// The transport this profile would open, named the same way + /// `build_runtime_vta_client` chooses it: a mediator means DIDComm, and a + /// REST URL alongside it is a fallback rather than the primary. + fn transport(&self) -> String { + match (&self.mediator_did, self.vta_url.is_empty()) { + (Some(_), true) => "DIDComm".to_string(), + (Some(_), false) => "DIDComm (REST fallback configured)".to_string(), + (None, false) => "REST".to_string(), + (None, true) => "none configured".to_string(), + } + } + + /// Serialised even when absent, so a script can read `.vta_access` without + /// having to know whether this profile uses a VTA. + fn as_json(access: Option<&Self>) -> serde_json::Value { + let Some(access) = access else { + return serde_json::Value::Null; + }; + serde_json::json!({ + // Named for what it is used for rather than for where it is stored: + // a consumer of this key wants the ACL subject. + "authenticates_as": access.credential_did, + "vta_did": access.vta_did, + "vta_url": (!access.vta_url.is_empty()).then(|| access.vta_url.clone()), + "mediator_did": access.mediator_did, + "context_id": access.context_id, + "transport": access.transport(), + }) + } + + fn render(&self) { + println!("{}", style("VTA access").color256(CLI_BLUE).bold()); + println!(" VTA {}", self.vta_did); + println!(" context {}", self.context_id); + println!(" transport {}", self.transport()); + if let Some(mediator) = &self.mediator_did { + println!(" mediator {mediator}"); + } + if !self.vta_url.is_empty() { + println!(" REST endpoint {}", self.vta_url); + } + // Printed in full and last, on its own, because it is the one line on + // this screen that gets copied into another terminal. Truncating it — + // as the TUI panel must, for width — would make it useless here. + println!( + " authenticates as {}", + style(&self.credential_did).color256(CLI_PURPLE) + ); + println!(); + println!(" The VTA's ACL is keyed on that last DID: it is what to name when reading"); + println!(" or changing what this install may do. From your PNM session:"); + println!(); + println!( + " {}", + style(format!("pnm acl get {}", self.credential_did)).color256(CLI_ORANGE) + ); + println!( + " {}", + style(format!( + "pnm acl update {} --capabilities persona-holder", + self.credential_did + )) + .color256(CLI_ORANGE) + ); + println!(); + // `--capabilities` narrows everywhere else it appears, and someone + // pasting that second line deserves to know why this one does not. + println!(" `persona-holder` is the exception that grants rather than narrows: it adds"); + println!(" authority over your own identity — the facts and faces that sit above every"); + println!(" context — without widening this install's reach into any other context."); + println!(); + } +} + /// The last path segment of a `did:webvh`, which is the part humans recognise /// (`…:dids.example.dev:legend-swear` → `legend-swear`). Falls back to the whole /// string for DID methods with no path. @@ -763,6 +903,61 @@ mod tests { assert_eq!(short_tail("notadid"), "notadid"); } + fn vta_access_fixture() -> VtaAccess { + VtaAccess { + credential_did: "did:key:z6MkAuthKeyForThisInstall".to_string(), + vta_did: "did:webvh:QmScid:vta.example.dev:agent".to_string(), + vta_url: String::new(), + mediator_did: Some("did:peer:2.Vz6Mkmediator".to_string()), + context_id: "openvtc".to_string(), + } + } + + /// The point of the section: the DID an operator pastes into `pnm acl` + /// arrives whole. It is a `did:key`, so a truncation would not merely be + /// ugly — the remainder is the key, and a shortened one names nobody. + #[test] + fn the_authenticating_did_is_serialised_in_full() { + let access = vta_access_fixture(); + let json = VtaAccess::as_json(Some(&access)); + assert_eq!( + json["authenticates_as"], "did:key:z6MkAuthKeyForThisInstall", + "the ACL subject is the key a script reads this report for" + ); + assert_eq!(json["context_id"], "openvtc"); + assert_eq!(json["transport"], "DIDComm"); + assert!( + json["vta_url"].is_null(), + "a DIDComm-only VTA has no REST endpoint to report" + ); + } + + /// A BIP32 profile has no VTA and no ACL, but the key is still emitted so a + /// script can read `.vta_access` without branching on the backend first. + #[test] + fn a_profile_without_a_vta_serialises_as_null() { + assert!(VtaAccess::as_json(None).is_null()); + } + + /// Which transport this profile would open is a `build_runtime_vta_client` + /// rule, not a guess: a mediator means DIDComm, and a REST URL alongside one + /// is the fallback rather than the primary. Reporting it the other way round + /// would send an operator to debug the leg that is not being used. + #[test] + fn the_transport_is_named_the_way_the_client_picks_it() { + let mut access = vta_access_fixture(); + assert_eq!(access.transport(), "DIDComm"); + + access.vta_url = "https://vta.example.dev".to_string(); + assert_eq!(access.transport(), "DIDComm (REST fallback configured)"); + + access.mediator_did = None; + assert_eq!(access.transport(), "REST"); + + access.vta_url = String::new(); + assert_eq!(access.transport(), "none configured"); + } + /// The fragment is what distinguishes two service entries; the DID prefix is /// printed once above and repeating it per row hides the difference. #[test] diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 8a511a5..899532e 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -775,6 +775,15 @@ pub struct IdentityState { pub disclosure_selected: usize, // ── Shared ─────────────────────────────────────────────────────────── + /// The `did:key` this install authenticates to the agent as, when the + /// account is agent-managed. + /// + /// Display-only, and here for one line: the grant hint shown when the agent + /// refuses a read names a `pnm acl update` command, and that command needs + /// this DID. A placeholder there is a command the reader cannot run — they + /// would have to go and find the DID on another pane first, which is the + /// step the hint exists to remove. + pub agent_credential_did: Option, /// A read is in flight. pub loading: bool, /// Why the last read failed, kept until one succeeds. diff --git a/openvtc/src/state_handler/main_page/mod.rs b/openvtc/src/state_handler/main_page/mod.rs index 2f0d1c6..294f078 100644 --- a/openvtc/src/state_handler/main_page/mod.rs +++ b/openvtc/src/state_handler/main_page/mod.rs @@ -414,6 +414,7 @@ impl MainPageState { self.content_panel.vta.vta_agent_name = config.agent_name_for(vta_did).map(str::to_owned); self.content_panel.vta.credential_did = credential_did.clone(); + self.content_panel.identity.agent_credential_did = Some(credential_did.clone()); self.content_panel.vta.is_vta_managed = true; // Same condition `build_runtime_vta_client` branches on, so the // panel names the transport this process actually connects over @@ -428,6 +429,7 @@ impl MainPageState { } _ => { self.content_panel.vta.is_vta_managed = false; + self.content_panel.identity.agent_credential_did = None; } } self.content_panel.vta.key_count = config.key_info.len(); diff --git a/openvtc/src/ui/pages/main/components/identity_panel.rs b/openvtc/src/ui/pages/main/components/identity_panel.rs index 66c4329..c9cda8d 100644 --- a/openvtc/src/ui/pages/main/components/identity_panel.rs +++ b/openvtc/src/ui/pages/main/components/identity_panel.rs @@ -741,8 +741,8 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: // name a host, a port, a contract mismatch, and translating what we do // not recognise would be inventing a cause (VTI R6.4). if needs_holder_grant(error) { - for line in HOLDER_GRANT_HINT { - lines.push(Line::from(*line).fg(COLOR_ORANGE)); + for line in holder_grant_hint(state.agent_credential_did.as_deref()) { + lines.push(Line::from(line).fg(COLOR_ORANGE)); } } else { super::status::push_status(lines, error, " "); @@ -770,16 +770,28 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec>, noun: /// What to do about the one refusal that has a specific answer. /// /// Kept as lines rather than a paragraph because the middle one is a command an -/// operator has to read character by character. -const HOLDER_GRANT_HINT: &[&str] = &[ - " Your agent credential administers this context. Your facts, and the faces", - " over them, sit above every context — reaching them is a separate grant:", - "", - " pnm acl update --did --capabilities persona-holder", - "", - " It adds authority over your own identity without giving this install any", - " authority over other contexts. `openvtc health` prints the DID.", -]; +/// operator has to read character by character — and, when we know it, retype +/// or copy into another terminal. +/// +/// We *do* know it: the DID this install authenticates as is in the config the +/// pane is already rendering from, so the command is emitted complete rather +/// than with a `` placeholder the reader has to go and +/// resolve on another pane. The placeholder survives only for the case where +/// there is genuinely nothing to substitute — a BIP32 account, which has no +/// agent credential at all (and, having no agent, will not have produced this +/// refusal in the first place). +fn holder_grant_hint(credential_did: Option<&str>) -> Vec { + let subject = credential_did.unwrap_or(""); + vec![ + " Your agent credential administers this context. Your facts, and the faces".to_string(), + " over them, sit above every context — reaching them is a separate grant:".to_string(), + String::new(), + format!(" pnm acl update {subject} --capabilities persona-holder"), + String::new(), + " It adds authority over your own identity without giving this install any".to_string(), + " authority over other contexts.".to_string(), + ] +} /// Whether a read failed because the caller lacks holder authority, as opposed /// to the agent being unreachable or the request being malformed. @@ -1314,13 +1326,42 @@ mod tests { holder credential" .to_string(), ), + agent_credential_did: Some("did:key:z6MkThisInstall".to_string()), ..IdentityState::default() }; loaded(&mut state); let out = text(&render(&state)); assert!(out.contains("persona-holder"), "{out}"); - assert!(out.contains("pnm acl update"), "{out}"); + // The whole command, ready to run. A hint that names a placeholder is a + // hint that sends the reader somewhere else to finish reading it. + assert!( + out.contains("pnm acl update did:key:z6MkThisInstall --capabilities persona-holder"), + "{out}" + ); + assert!( + !out.contains(""), + "{hint}" + ); } /// And an unrelated failure does not: telling someone to run a grant when