Merge opt-in app owner identity placeholder

nevent1qqs9d76qm6f5xj2vrtjfnkqz5exrc8r0s9zev4f672kqyd0wjh7wwvqpz3mhxue69uhhyetvv9ujumn8d96zuer9wcx2tvaw
This commit is contained in:
archipelago
2026-10-05 09:29:18 -04:00
6 changed files with 435 additions and 7 deletions
@@ -1,6 +1,8 @@
use super::*;
use crate::api::rpc::RpcHandler;
use crate::identity_manager::{IdentityManager, IdentityProfile, IdentityPurpose};
use crate::identity_manager::{
is_node_identity, IdentityManager, IdentityProfile, IdentityPurpose,
};
use crate::network::did_dht;
use anyhow::{Context, Result};
use nostr_sdk::ToBech32;
@@ -38,7 +40,7 @@ impl RpcHandler {
.into_iter()
.map(|id| {
let is_default = default_id.as_deref() == Some(&id.id);
let is_node = !node_pubkey_hex.is_empty() && id.pubkey_hex == node_pubkey_hex;
let is_node = is_node_identity(&id, &node_pubkey_hex);
let (nostr_pubkey, nostr_npub) = if is_node {
(
node_nostr_hex.clone().or(id.nostr_pubkey),
@@ -3534,6 +3534,7 @@ impl ProdContainerOrchestrator {
host_mdns: "test.local".to_string(),
disk_gb: self.test_disk_gb.unwrap_or(1000),
bitcoin_host: "bitcoin-knots".to_string(),
node_identity_pubkeys: String::new(),
};
}
#[allow(unreachable_code)]
@@ -3551,10 +3552,53 @@ impl ProdContainerOrchestrator {
// demand (it costs a podman call) only for manifests that use
// {{BITCOIN_HOST}}, rather than every app on every reconcile.
bitcoin_host: "bitcoin-knots".to_string(),
// Likewise filled on demand, only for manifests that use
// {{NODE_IDENTITY_PUBKEYS}}.
node_identity_pubkeys: String::new(),
}
}
}
/// Nostr public keys of the identities the app identity picker offers, for
/// the `{{NODE_IDENTITY_PUBKEYS}}` derived-env placeholder. The node
/// identity is recognised the way `identity.list` marks `is_node`: by the
/// node's ed25519 public key, read here from `identity/node_key.pub`
/// (the file `server_info.pubkey` is derived from at startup). The record
/// mirrored from the node key has a `node-` id, so the picker's prefix rule
/// hides it either way. The key is only read, never created: a missing or
/// malformed file is an error. An empty set is an error too, so an app is
/// never handed an empty owner list.
async fn node_identity_pubkeys(&self) -> Result<String> {
let node_pubkey_hex = self.node_pubkey_hex().await?;
let pubkeys = crate::identity_manager::IdentityManager::new(&self.data_dir)
.await?
.app_signable_nostr_pubkeys(&node_pubkey_hex)
.await?;
anyhow::ensure!(
!pubkeys.is_empty(),
"no user identity with a Nostr key is available for apps to sign with; \
create one under Web5 \u{2192} Identities"
);
Ok(pubkeys)
}
/// The node's ed25519 public key as lowercase hex, read from
/// `identity/node_key.pub` (raw 32 bytes, as `NodeIdentity` writes it)
/// without the logging or key creation of `NodeIdentity::load_or_create`.
async fn node_pubkey_hex(&self) -> Result<String> {
let path = self.data_dir.join("identity").join("node_key.pub");
let bytes = tokio::fs::read(&path)
.await
.with_context(|| format!("reading the node public key {}", path.display()))?;
anyhow::ensure!(
bytes.len() == 32,
"node public key {} is {} bytes, expected 32",
path.display(),
bytes.len()
);
Ok(hex::encode(bytes))
}
/// Container name of the running Bitcoin node (`bitcoin-knots` or
/// `bitcoin-core`) for the `{{BITCOIN_HOST}}` derived-env placeholder.
/// Defaults to `bitcoin-knots` when none is running (B12).
@@ -3822,6 +3866,22 @@ impl ProdContainerOrchestrator {
{
facts.bitcoin_host = self.bitcoin_host().await;
}
// The identities' keys are read only for manifests that template them.
if manifest
.app
.container
.derived_env
.iter()
.any(|e| e.template.contains("{{NODE_IDENTITY_PUBKEYS}}"))
{
facts.node_identity_pubkeys =
self.node_identity_pubkeys().await.with_context(|| {
format!(
"resolving {{{{NODE_IDENTITY_PUBKEYS}}}} for {}",
manifest.app.id
)
})?;
}
let mut env = manifest.app.environment.clone();
env.extend(manifest.app.container.resolve_derived_env(&facts));
if matches!(manifest.app.id.as_str(), "bitcoin-core" | "bitcoin-knots") {
@@ -6151,6 +6211,143 @@ app:
}
}
const NODE_IDENTITY_PUBKEYS_YAML: &str = "app:\n id: wildbloom-node\n name: wildbloom-node\n version: 1.0.0\n container:\n image: x:1\n derived_env:\n - key: WILDBLOOM_ALLOW_PUBKEYS\n template: \"{{NODE_IDENTITY_PUBKEYS}}\"\n";
/// Writes `pubkey_hex` as the node public key, in `NodeIdentity`'s format.
async fn write_node_pubkey(orch: &ProdContainerOrchestrator, pubkey_hex: &str) {
let dir = orch.data_dir().join("identity");
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(dir.join("node_key.pub"), hex::decode(pubkey_hex).unwrap())
.await
.unwrap();
}
#[tokio::test]
async fn node_identity_pubkeys_placeholder_renders_the_signable_identities() {
// The owners must be exactly the identities the app signer offers:
// the user identities, never the node's own identity.
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt).await;
let mgr = crate::identity_manager::IdentityManager::new(orch.data_dir())
.await
.unwrap();
let mut expected = Vec::new();
for name in ["Personal", "Business"] {
let r = mgr
.create(
name.to_string(),
crate::identity_manager::IdentityPurpose::Personal,
)
.await
.unwrap();
expected.push(r.nostr_pubkey.unwrap().to_ascii_lowercase());
}
expected.sort();
// The node key is held by an identity with a uuid id and an ordinary
// name, so only the `is_node` match, through the key read from
// node_key.pub, can keep it out.
let laptop = mgr
.create(
"Laptop".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
)
.await
.unwrap();
assert!(!laptop.id.starts_with("node-"));
let laptop_nostr = laptop.nostr_pubkey.clone().unwrap();
write_node_pubkey(&orch, &laptop.pubkey_hex).await;
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
orch.resolve_dynamic_env(&mut manifest).await.unwrap();
let env = &manifest.app.environment;
let want = format!("WILDBLOOM_ALLOW_PUBKEYS={}", expected.join(","));
assert!(env.iter().any(|e| e == &want), "env was {env:?}");
assert!(
!env.iter().any(|e| e.contains(&laptop_nostr)),
"node identity leaked into {env:?}"
);
}
#[tokio::test]
async fn node_identity_pubkeys_placeholder_refuses_an_empty_set() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt).await;
// A node key with only the node's own identity: nothing is signable.
let node = crate::identity::NodeIdentity::load_or_create(&orch.data_dir().join("identity"))
.await
.unwrap();
crate::identity_manager::IdentityManager::new(orch.data_dir())
.await
.unwrap()
.create_from_signing_key(
"Node".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
node.signing_key().clone(),
)
.await
.unwrap();
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("NODE_IDENTITY_PUBKEYS"),
"unexpected error: {msg}"
);
assert!(msg.contains("no user identity"), "unexpected error: {msg}");
assert!(
!manifest
.app
.environment
.iter()
.any(|e| e.starts_with("WILDBLOOM_ALLOW_PUBKEYS=")),
"an empty owner list must never render"
);
}
#[tokio::test]
async fn node_identity_pubkeys_placeholder_needs_the_node_key_and_never_creates_it() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt).await;
crate::identity_manager::IdentityManager::new(orch.data_dir())
.await
.unwrap()
.create(
"Personal".to_string(),
crate::identity_manager::IdentityPurpose::Personal,
)
.await
.unwrap();
let identity_dir = orch.data_dir().join("identity");
let mut manifest = AppManifest::parse(NODE_IDENTITY_PUBKEYS_YAML).unwrap();
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
assert!(
format!("{err:#}").contains("node public key"),
"unexpected error: {err:#}"
);
assert!(
!identity_dir.join("node_key").exists(),
"a node key was created"
);
assert!(!identity_dir.join("node_key.pub").exists());
// A malformed key file is refused, not reinterpreted.
tokio::fs::create_dir_all(&identity_dir).await.unwrap();
tokio::fs::write(identity_dir.join("node_key.pub"), "ab".repeat(32))
.await
.unwrap();
let err = orch.resolve_dynamic_env(&mut manifest).await.unwrap_err();
assert!(
format!("{err:#}").contains("expected 32"),
"unexpected error: {err:#}"
);
assert!(!manifest
.app
.environment
.iter()
.any(|e| e.starts_with("WILDBLOOM_ALLOW_PUBKEYS=")));
}
/// A fedimint-gateway manifest shaped like the real one: a bcrypt
/// generated secret plus a secret_env that reads it, which is what makes
/// the credential participate in secret_env_hash.
+172
View File
@@ -115,6 +115,23 @@ fn relay_url_matches(a: &str, b: &str) -> bool {
norm(a) == norm(b)
}
/// True when `record` is the node's own identity: the one whose ed25519 key
/// is the node key (`server_info.pubkey`). `identity.list` reports this as
/// `is_node`, and clients must never offer it as an app signer.
pub fn is_node_identity(record: &IdentityRecord, node_pubkey_hex: &str) -> bool {
!node_pubkey_hex.is_empty() && record.pubkey_hex == node_pubkey_hex
}
/// True when the app identity picker hides `record`, mirroring
/// `NostrIdentityPicker.vue`'s filter exactly: the node identity
/// (`is_node`), any `node-*` id and any identity named "Node".
pub(crate) fn is_hidden_from_app_signer(record: &IdentityRecord, node_pubkey_hex: &str) -> bool {
// Rust's `str::trim` keeps U+FEFF, which JS `trim()` strips.
is_node_identity(record, node_pubkey_hex)
|| record.id.trim().to_lowercase().starts_with("node-")
|| record.name.trim().to_lowercase() == "node"
}
impl IdentityManager {
pub async fn new(data_dir: &Path) -> Result<Self> {
let identities_dir = data_dir.join(IDENTITIES_DIR);
@@ -150,6 +167,25 @@ impl IdentityManager {
Ok((identities, default_id))
}
/// Nostr public keys of the identities an app may sign with through the
/// NIP-07 bridge, as sorted, de-duplicated, comma-joined lowercase hex.
///
/// Leaves out what the identity picker hides (`is_hidden_from_app_signer`)
/// and identities without a Nostr key (they cannot sign). Empty when no
/// identity qualifies.
pub async fn app_signable_nostr_pubkeys(&self, node_pubkey_hex: &str) -> Result<String> {
let (identities, _) = self.list().await?;
let mut pubkeys: Vec<String> = identities
.iter()
.filter(|r| !is_hidden_from_app_signer(r, node_pubkey_hex))
.filter_map(|r| r.nostr_pubkey.as_deref())
.map(str::to_ascii_lowercase)
.collect();
pubkeys.sort();
pubkeys.dedup();
Ok(pubkeys.join(","))
}
/// Create a new identity.
pub async fn create(&self, name: String, purpose: IdentityPurpose) -> Result<IdentityRecord> {
let signing_key = SigningKey::generate(&mut OsRng);
@@ -966,6 +1002,142 @@ mod tests {
assert_ne!(default_id, Some(r1.id));
}
fn record(id: &str, name: &str, pubkey_hex: &str) -> IdentityRecord {
IdentityRecord {
id: id.to_string(),
name: name.to_string(),
purpose: IdentityPurpose::Personal,
pubkey_hex: pubkey_hex.to_string(),
did: String::new(),
dht_did: None,
created_at: String::new(),
nostr_pubkey: None,
nostr_npub: None,
profile: None,
}
}
#[test]
fn is_node_identity_matches_only_the_node_pubkey() {
let node = "ab".repeat(32);
let other = "cd".repeat(32);
assert!(is_node_identity(&record("uuid-1", "Laptop", &node), &node));
assert!(!is_node_identity(
&record("uuid-1", "Laptop", &other),
&node
));
// An unknown node key matches nothing, not the records without a key.
assert!(!is_node_identity(&record("uuid-1", "Laptop", ""), ""));
// The id and name rules belong to the picker filter, not to `is_node`.
assert!(!is_node_identity(
&record("node-abc", "Node", &other),
&node
));
}
#[test]
fn is_hidden_from_app_signer_mirrors_the_picker_rules() {
let node = "ab".repeat(32);
let other = "cd".repeat(32);
let hidden = |id: &str, name: &str, pk: &str| {
is_hidden_from_app_signer(&record(id, name, pk), &node)
};
// is_node: matched by key alone, whatever the id and name.
assert!(hidden("uuid-1", "Laptop", &node));
// node-* id, any case, surrounding whitespace ignored.
assert!(hidden("node-0123456789abcdef", "Laptop", &other));
assert!(hidden(" NODE-x ", "Laptop", &other));
assert!(hidden("Node-x", "Laptop", &other));
// The name "Node", any case, surrounding whitespace ignored.
assert!(hidden("uuid-1", "Node", &other));
assert!(hidden("uuid-1", " nODe\t", &other));
// Near misses stay visible.
assert!(!hidden("uuid-1", "Laptop", &other));
assert!(!hidden("my-node-1", "Node 2", &other));
assert!(!hidden("nodes", "Nodes", &other));
}
#[tokio::test]
async fn app_signable_nostr_pubkeys_mirror_the_identity_picker() {
let dir = tempdir().unwrap();
let mgr = IdentityManager::new(dir.path()).await.unwrap();
let personal = mgr
.create("Personal".to_string(), IdentityPurpose::Personal)
.await
.unwrap();
let business = mgr
.create("Business".to_string(), IdentityPurpose::Business)
.await
.unwrap();
// The node identity as mirrored at startup: a `node-` id named
// "Node", given a Nostr key so only the id and name rules hide it.
let mirrored_key = SigningKey::generate(&mut OsRng);
let mirrored = mgr
.create_from_signing_key("Node".to_string(), IdentityPurpose::Personal, mirrored_key)
.await
.unwrap();
assert!(mirrored.id.starts_with("node-"));
mgr.create_nostr_key(&mirrored.id).await.unwrap();
// A user-created identity named "Node" is hidden by the picker too.
let named_node = mgr
.create(" node ".to_string(), IdentityPurpose::Anonymous)
.await
.unwrap();
// The node key belongs to an identity with a uuid id and an ordinary
// name, so only the `is_node` match can hide it.
let laptop = mgr
.create("Laptop".to_string(), IdentityPurpose::Personal)
.await
.unwrap();
assert!(!laptop.id.starts_with("node-"));
let (all, _) = mgr.list().await.unwrap();
assert!(all
.iter()
.any(|r| r.id == mirrored.id && r.nostr_pubkey.is_some()));
assert!(all.iter().any(|r| r.id == named_node.id));
assert!(all
.iter()
.any(|r| r.id == laptop.id && r.nostr_pubkey.is_some()));
let mut expected = vec![
personal.nostr_pubkey.unwrap().to_ascii_lowercase(),
business.nostr_pubkey.unwrap().to_ascii_lowercase(),
];
expected.sort();
assert_eq!(
mgr.app_signable_nostr_pubkeys(&laptop.pubkey_hex)
.await
.unwrap(),
expected.join(",")
);
// Without the node key, the same identity is offered like any other.
let mut with_laptop = expected.clone();
with_laptop.push(laptop.nostr_pubkey.unwrap().to_ascii_lowercase());
with_laptop.sort();
assert_eq!(
mgr.app_signable_nostr_pubkeys("").await.unwrap(),
with_laptop.join(",")
);
}
#[tokio::test]
async fn app_signable_nostr_pubkeys_is_empty_with_only_the_node_identity() {
let dir = tempdir().unwrap();
let mgr = IdentityManager::new(dir.path()).await.unwrap();
let node_key = SigningKey::generate(&mut OsRng);
let node_pubkey_hex = hex::encode(node_key.verifying_key().as_bytes());
mgr.create_from_signing_key("Node".to_string(), IdentityPurpose::Personal, node_key)
.await
.unwrap();
assert_eq!(
mgr.app_signable_nostr_pubkeys(&node_pubkey_hex)
.await
.unwrap(),
""
);
}
#[tokio::test]
async fn test_delete_default_shifts() {
let dir = tempdir().unwrap();