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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<this install's DID>` — 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
Expand Down
197 changes: 196 additions & 1 deletion openvtc/src/health_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Subject> = Vec::new();

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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());
}

Expand Down Expand Up @@ -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<String>,
context_id: String,
}

/// `None` for a BIP32 profile: there is no VTA and so no ACL to edit.
fn vta_access(config: &Config) -> Option<VtaAccess> {
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.
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions openvtc/src/state_handler/main_page/content.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// A read is in flight.
pub loading: bool,
/// Why the last read failed, kept until one succeeds.
Expand Down
2 changes: 2 additions & 0 deletions openvtc/src/state_handler/main_page/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down
67 changes: 54 additions & 13 deletions openvtc/src/ui/pages/main/components/identity_panel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -741,8 +741,8 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec<Line<'static>>, 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, " ");
Expand Down Expand Up @@ -770,16 +770,28 @@ fn push_agent_state(state: &IdentityState, lines: &mut Vec<Line<'static>>, 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 <this install's 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 `<this install's DID>` 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<String> {
let subject = credential_did.unwrap_or("<this install's DID>");
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.
Expand Down Expand Up @@ -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("<this install"),
"the placeholder must not survive when we hold the DID: {out}"
);
}

/// `did` is positional on `pnm acl update`; there is no `--did` flag, and a
/// hint that grew one would be a command that fails on paste.
#[test]
fn the_grant_command_passes_the_did_positionally() {
let hint = holder_grant_hint(Some("did:key:z6MkThisInstall")).join("\n");
assert!(!hint.contains("--did"), "{hint}");
}

/// Nothing to substitute is the one case the placeholder is still right for
/// — better an obvious blank than a command naming the wrong subject.
#[test]
fn the_grant_command_keeps_a_placeholder_when_there_is_no_credential() {
let hint = holder_grant_hint(None).join("\n");
assert!(
hint.contains("pnm acl update <this install\'s DID>"),
"{hint}"
);
}

/// And an unrelated failure does not: telling someone to run a grant when
Expand Down
Loading