Validate app owner keys and match identity picker whitespace rules

This commit is contained in:
archipelago
2026-10-05 09:31:46 -04:00
parent 7dfb0e0013
commit f1d0092e57
2 changed files with 53 additions and 5 deletions
+42 -5
View File
@@ -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::<Result<Vec<_>>>()?;
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();
+11
View File
@@ -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.