From f1d0092e57bd5ee33ccccb9e7f6d63538c1cc5ba Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 5 Oct 2026 09:31:46 -0400 Subject: [PATCH] Validate app owner keys and match identity picker whitespace rules --- core/archipelago/src/identity_manager.rs | 47 +++++++++++++++++++++--- docs/app-manifest-spec.md | 11 ++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 23d6b104..26f81aa4 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -126,10 +126,17 @@ pub fn is_node_identity(record: &IdentityRecord, node_pubkey_hex: &str) -> bool /// `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. + // Match ECMAScript trim exactly: Rust includes U+0085 and excludes U+FEFF. + let trim_js = |value: &str| value.trim_matches(is_js_whitespace).to_lowercase(); is_node_identity(record, node_pubkey_hex) - || record.id.trim().to_lowercase().starts_with("node-") - || record.name.trim().to_lowercase() == "node" + || trim_js(&record.id).starts_with("node-") + || trim_js(&record.name) == "node" +} + +fn is_js_whitespace(c: char) -> bool { + matches!(c, '\u{0009}'..='\u{000D}' | '\u{0020}' | '\u{00A0}' | '\u{1680}' + | '\u{2000}'..='\u{200A}' | '\u{2028}' | '\u{2029}' | '\u{202F}' + | '\u{205F}' | '\u{3000}' | '\u{FEFF}') } impl IdentityManager { @@ -179,8 +186,13 @@ impl IdentityManager { .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(); + .map(|key| { + anyhow::ensure!(key.len() == 64, "invalid app owner Nostr public key"); + nostr_sdk::PublicKey::from_hex(key) + .map(|key| key.to_hex()) + .context("invalid app owner Nostr public key") + }) + .collect::>>()?; pubkeys.sort(); pubkeys.dedup(); Ok(pubkeys.join(",")) @@ -1051,6 +1063,11 @@ mod tests { // The name "Node", any case, surrounding whitespace ignored. assert!(hidden("uuid-1", "Node", &other)); assert!(hidden("uuid-1", " nODe\t", &other)); + assert!(hidden("\u{FEFF}NODE-x\u{FEFF}", "Laptop", &other)); + assert!(hidden("uuid-1", "\u{FEFF}Node\u{FEFF}", &other)); + // ECMAScript keeps U+0085; do not add owners that the picker hides, + // or hide identities that it offers. + assert!(!hidden("uuid-1", "\u{0085}Node\u{0085}", &other)); // Near misses stay visible. assert!(!hidden("uuid-1", "Laptop", &other)); assert!(!hidden("my-node-1", "Node 2", &other)); @@ -1121,6 +1138,26 @@ mod tests { ); } + #[tokio::test] + async fn app_owner_pubkeys_reject_malformed_key_material() { + let dir = tempdir().unwrap(); + let mgr = IdentityManager::new(dir.path()).await.unwrap(); + let identity = mgr + .create("Owner".into(), IdentityPurpose::Personal) + .await + .unwrap(); + let path = mgr.identities_dir.join(format!("{}.json", identity.id)); + let original = fs::read(&path).await.unwrap(); + for invalid in ["", "not-a-key", "owner,another-owner", "\nINJECTED=true"] { + let mut data: serde_json::Value = serde_json::from_slice(&original).unwrap(); + data["nostr_pubkey_hex"] = serde_json::json!(invalid); + fs::write(&path, serde_json::to_vec(&data).unwrap()) + .await + .unwrap(); + assert!(mgr.app_signable_nostr_pubkeys("").await.is_err()); + } + } + #[tokio::test] async fn app_signable_nostr_pubkeys_is_empty_with_only_the_node_identity() { let dir = tempdir().unwrap(); diff --git a/docs/app-manifest-spec.md b/docs/app-manifest-spec.md index 2c45edc6..ce3b0d2d 100644 --- a/docs/app-manifest-spec.md +++ b/docs/app-manifest-spec.md @@ -322,3 +322,14 @@ automatically install dependencies, alter Bitcoin pruning, or require a synced backend merely to recognize an already-installed service. Declare ongoing relationships separately in `dependencies`; use the app health check for actual API readiness. Self-dependencies and malformed ids are invalid. + +### App owner identity placeholder + +`{{NODE_IDENTITY_PUBKEYS}}` is opt-in per manifest. It gives the application the +comma-separated public keys of all user identities offered by the app signer, +excluding the appliance identity. It grants no signing capability or private +keys. Describe any resulting owner/upload privileges in the app documentation: a +shared list also links those identities to the same installation. An absent node +key, malformed owner key or empty owner set fails setup rather than rendering an +empty allow-list. Do not use this as an anonymous or per-profile authorization +mechanism. Existing manifests that omit it keep their existing configuration.