diff --git a/Android/rust/archy-fips-core/Cargo.lock b/Android/rust/archy-fips-core/Cargo.lock index b99f5ba2..a92341e6 100644 --- a/Android/rust/archy-fips-core/Cargo.lock +++ b/Android/rust/archy-fips-core/Cargo.lock @@ -12,6 +12,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -81,17 +92,41 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" name = "archy-fips-core" version = "0.1.0" dependencies = [ + "aes", "anyhow", + "argon2", + "base64", + "bech32", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", "fips", "getrandom 0.2.17", "hex", + "hkdf", + "hmac", "jni", "libc", "paranoid-android", + "secp256k1 0.29.1", "serde_json", + "sha2", "tokio", "tracing", "tracing-subscriber", + "url", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", ] [[package]] @@ -124,6 +159,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bech32" version = "0.11.1" @@ -172,6 +219,15 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -181,6 +237,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "blocking" version = "1.6.2" @@ -200,6 +265,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.3.0" @@ -406,6 +480,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "either" version = "1.16.0" @@ -476,7 +561,7 @@ dependencies = [ "libc", "rand 0.10.2", "rtnetlink", - "secp256k1", + "secp256k1 0.30.0", "serde", "serde_json", "serde_yaml", @@ -491,6 +576,15 @@ dependencies = [ "tun", ] +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "futures" version = "0.3.33" @@ -676,6 +770,110 @@ dependencies = [ "digest", ] +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -692,6 +890,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -788,6 +987,12 @@ dependencies = [ "libc", ] +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + [[package]] name = "log" version = "0.4.33" @@ -954,12 +1159,29 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -988,6 +1210,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1129,6 +1360,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "secp256k1-sys", +] + [[package]] name = "secp256k1" version = "0.30.0" @@ -1272,6 +1512,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1306,6 +1552,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1355,6 +1612,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.53.1" @@ -1519,6 +1786,24 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1657,6 +1942,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.55" @@ -1677,12 +1991,66 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + [[package]] name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/Android/rust/archy-fips-core/Cargo.toml b/Android/rust/archy-fips-core/Cargo.toml index e6cc8cf4..4126e4e5 100644 --- a/Android/rust/archy-fips-core/Cargo.toml +++ b/Android/rust/archy-fips-core/Cargo.toml @@ -37,6 +37,35 @@ tracing = "0.1" # fcntl: force the VpnService TUN fd into blocking mode (see mesh::start). libc = "0.2" +# ── Companion backup (#128) ─────────────────────────────────────────────── +# ADR-005 envelope: the SAME crates and blob layout as the node's backup code +# (core/archipelago/src/backup/identity.rs) — Argon2id KDF + ChaCha20-Poly1305 +# AEAD — applied to the companion's own JSON payload. Do not diverge from +# those parameters: a companion backup and a node backup must decrypt with +# the same code path on either side. +argon2 = "0.5" +chacha20poly1305 = "0.10" +base64 = "0.22" + +# ── NIP-46 remote signer (#139) ─────────────────────────────────────────── +# BIP340 schnorr signing + secp256k1 ECDH (NIP-44/NIP-04 conversation keys). +# Audited libsecp256k1 via cc; cargo-ndk provides the NDK clang on Android. +secp256k1 = "0.29" +# NIP-44 v2: HKDF-SHA256 (conversation/message keys) + HMAC-SHA256 (MAC). +sha2 = "0.10" +hmac = "0.12" +hkdf = "0.12" +# NIP-44 v2 stream cipher (raw ChaCha20, RFC 8439 — NOT the AEAD). +chacha20 = "0.9" +# NIP-04 fallback (deprecated in the spec but still sent by real clients): +# AES-256-CBC, key = raw ECDH x-coordinate. +aes = "0.8" +cbc = { version = "0.1", features = ["alloc"] } +# npub/nsec (bech32, BIP173 variant — NOT Bech32m). +bech32 = "0.11" +# nostrconnect:// URI parsing (repeated relay params + percent-decoding). +url = "2.5" + # The JNI surface only exists on Android; host builds skip it and drive the # mesh module directly (tests). [target.'cfg(target_os = "android")'.dependencies] diff --git a/Android/rust/archy-fips-core/src/backup.rs b/Android/rust/archy-fips-core/src/backup.rs new file mode 100644 index 00000000..1a9aee92 --- /dev/null +++ b/Android/rust/archy-fips-core/src/backup.rs @@ -0,0 +1,246 @@ +//! Companion app backup — the ADR-005 encrypted-backup envelope. +//! +//! Reuses the node's backup format exactly (ADR-005: +//! `core/archipelago/src/backup/identity.rs`): Argon2id key derivation with +//! default params, ChaCha20-Poly1305 AEAD, and the same blob layout +//! `base64(salt[16] || nonce[12] || ciphertext)`. A companion backup and a +//! node backup share one crypto story — the payload differs (the companion +//! serializes its servers, FIPS identity and signer key instead of a node +//! key), the envelope does not. +//! +//! The envelope is JSON with `version`, `kind`, `encrypted`, `blob` and +//! `timestamp`; [`decrypt`] ignores any extra fields, so node envelopes +//! (which carry `did`/`pubkey`/`kid`) decrypt here too. + +use anyhow::{bail, Context, Result}; +use argon2::Argon2; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use serde_json::json; + +/// Envelope version. Bump only when the blob layout itself changes — and +/// then only with a reader for the old layout (same policy as the node). +const BACKUP_VERSION: u32 = 1; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const KEY_LEN: usize = 32; + +/// Encrypt a JSON payload into an ADR-005 envelope. +/// +/// The passphrase never leaves this call; the envelope carries only the +/// salt (Argon2id parameter), the AEAD nonce, and the ciphertext. +pub fn encrypt(payload: &str, passphrase: &str) -> Result { + if payload.is_empty() { + bail!("backup payload is empty"); + } + if passphrase.is_empty() { + bail!("backup passphrase must not be empty"); + } + + let mut salt = [0u8; SALT_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + // Same CSPRNG discipline as identity generation (getrandom, see mesh.rs): + // OS RNG, never thread-local or derived-from-content randomness for key + // material or nonces. + getrandom::getrandom(&mut salt).context("OS RNG")?; + getrandom::getrandom(&mut nonce).context("OS RNG")?; + + let key = derive_key(passphrase, &salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), payload.as_bytes()) + .map_err(|_| anyhow::anyhow!("encryption failed"))?; + + let mut blob = Vec::with_capacity(SALT_LEN + NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&salt); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ciphertext); + + Ok(json!({ + "version": BACKUP_VERSION, + "kind": "companion", + "encrypted": true, + "blob": BASE64.encode(&blob), + "timestamp": chrono_like_now(), + }) + .to_string()) +} + +/// Decrypt an ADR-005 envelope back into its JSON payload. +/// +/// Accepts `version: 1` envelopes regardless of `kind` or extra fields — +/// the node's identity backups use the same blob, and being able to decrypt +/// one here is free interop (the caller decides what to do with it). +pub fn decrypt(envelope: &str, passphrase: &str) -> Result { + let obj: serde_json::Value = + serde_json::from_str(envelope).context("not a JSON backup envelope")?; + + if obj.get("version").and_then(|v| v.as_u64()) != Some(BACKUP_VERSION as u64) { + bail!("unsupported backup version (expected {BACKUP_VERSION})"); + } + + let blob_b64 = obj + .get("blob") + .and_then(|v| v.as_str()) + .context("missing 'blob' in backup envelope")?; + let blob = BASE64 + .decode(blob_b64) + .context("invalid base64 in backup blob")?; + if blob.len() < SALT_LEN + NONCE_LEN { + bail!("backup blob too short"); + } + + let salt = &blob[..SALT_LEN]; + let nonce = &blob[SALT_LEN..SALT_LEN + NONCE_LEN]; + let ciphertext = &blob[SALT_LEN + NONCE_LEN..]; + + let key = derive_key(passphrase, salt)?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let plaintext = cipher + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|_| anyhow::anyhow!("decryption failed — wrong passphrase or corrupted backup"))?; + + String::from_utf8(plaintext).context("decrypted payload is not valid UTF-8") +} + +fn derive_key(passphrase: &str, salt: &[u8]) -> Result<[u8; KEY_LEN]> { + let mut key = [0u8; KEY_LEN]; + Argon2::default() + .hash_password_into(passphrase.as_bytes(), salt, &mut key) + .map_err(|e| anyhow::anyhow!("Argon2 key derivation failed: {e}"))?; + Ok(key) +} + +/// RFC 3339 UTC timestamp without pulling chrono into the .so — the node's +/// envelope field is informational (display), not part of the authenticated +/// or derived material. +fn chrono_like_now() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let days = secs / 86_400; + let rem = secs % 86_400; + let (h, m, s) = (rem / 3600, (rem % 3600) / 60, rem % 60); + // Civil-from-days (Howard Hinnant's algorithm), valid for 1970-2100+. + let z = days as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let mo = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if mo <= 2 { y + 1 } else { y }; + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +#[cfg(test)] +mod tests { + use super::*; + + const PAYLOAD: &str = r#"{"app":"archipelago-companion","servers":["192.168.1.10|false|1301||Lab Node|fd00::1|npub1abc"]}"#; + + #[test] + fn round_trip() { + let envelope = encrypt(PAYLOAD, "correct horse battery staple").unwrap(); + let decrypted = decrypt(&envelope, "correct horse battery staple").unwrap(); + assert_eq!(decrypted, PAYLOAD); + } + + #[test] + fn wrong_passphrase_fails() { + let envelope = encrypt(PAYLOAD, "right").unwrap(); + let err = decrypt(&envelope, "wrong").unwrap_err(); + assert!( + err.to_string().contains("wrong passphrase"), + "error should name the likely cause: {err}" + ); + } + + #[test] + fn envelope_shape_matches_node_format() { + let envelope = encrypt(PAYLOAD, "pw").unwrap(); + let obj: serde_json::Value = serde_json::from_str(&envelope).unwrap(); + + assert_eq!(obj["version"], 1); + assert_eq!(obj["encrypted"], true); + assert!(obj["kind"].as_str().is_some()); + assert!(obj["timestamp"].as_str().is_some()); + + // Blob layout is exactly the node's: base64(salt||nonce||ct) with the + // AEAD tag inside the ciphertext — at least 16+12+16+1 bytes. + let blob = BASE64 + .decode(obj["blob"].as_str().unwrap()) + .expect("blob is base64"); + assert!(blob.len() >= SALT_LEN + NONCE_LEN + 16 + PAYLOAD.len()); + } + + #[test] + fn fresh_salt_and_nonce_every_time() { + let a = encrypt(PAYLOAD, "pw").unwrap(); + let b = encrypt(PAYLOAD, "pw").unwrap(); + let (oa, ob): (serde_json::Value, serde_json::Value) = ( + serde_json::from_str(&a).unwrap(), + serde_json::from_str(&b).unwrap(), + ); + assert_ne!(oa["blob"], ob["blob"], "salt/nonce must never repeat"); + } + + #[test] + fn tampered_blob_fails_to_decrypt() { + let envelope = encrypt(PAYLOAD, "pw").unwrap(); + let mut obj: serde_json::Value = serde_json::from_str(&envelope).unwrap(); + let blob = BASE64.decode(obj["blob"].as_str().unwrap()).unwrap(); + let mut tampered = blob.clone(); + // Flip a bit inside the ciphertext (past salt+nonce). + tampered[SALT_LEN + NONCE_LEN] ^= 0x01; + obj["blob"] = serde_json::Value::String(BASE64.encode(&tampered)); + assert!(decrypt(&obj.to_string(), "pw").is_err()); + } + + /// Node identity backups use the same blob layout but carry their own + /// envelope fields (did/pubkey/kid). Decrypt must ignore those extras — + /// one envelope reader, two producers. + #[test] + fn node_style_envelope_with_extra_fields_decrypts() { + let envelope = encrypt(PAYLOAD, "pw").unwrap(); + let mut obj: serde_json::Value = serde_json::from_str(&envelope).unwrap(); + obj["kind"] = serde_json::Value::String("node-identity".into()); + obj["did"] = serde_json::Value::String("did:key:z6Mktest".into()); + obj["pubkey"] = serde_json::Value::String("aabbcc".into()); + obj["kid"] = serde_json::Value::String("did:key:z6Mktest#key-1".into()); + let decrypted = decrypt(&obj.to_string(), "pw").unwrap(); + assert_eq!(decrypted, PAYLOAD); + } + + #[test] + fn rejects_unknown_version_and_garbage() { + let err = decrypt("{\"version\":99,\"blob\":\"AAAA\"}", "pw").unwrap_err(); + assert!(err.to_string().contains("version")); + assert!(decrypt("not json", "pw").is_err()); + assert!(decrypt("{\"version\":1}", "pw").is_err()); + } + + #[test] + fn rejects_empty_passphrase_and_payload() { + assert!(encrypt(PAYLOAD, "").is_err()); + assert!(encrypt("", "pw").is_err()); + } + + #[test] + fn timestamp_is_rfc3339_utc() { + let envelope = encrypt(PAYLOAD, "pw").unwrap(); + let obj: serde_json::Value = serde_json::from_str(&envelope).unwrap(); + let ts = obj["timestamp"].as_str().unwrap(); + // 2026-08-31T12:34:56Z — 20 chars, RFC 3339 UTC. + assert_eq!(ts.len(), 20); + assert!(ts.ends_with('Z')); + assert_eq!(&ts[4..5], "-"); + assert_eq!(&ts[10..11], "T"); + assert!(ts.starts_with("20")); + } +} diff --git a/Android/rust/archy-fips-core/src/jni_glue.rs b/Android/rust/archy-fips-core/src/jni_glue.rs index 5f891135..5b124f83 100644 --- a/Android/rust/archy-fips-core/src/jni_glue.rs +++ b/Android/rust/archy-fips-core/src/jni_glue.rs @@ -1,5 +1,6 @@ -//! JNI surface for `com.archipelago.app.fips.FipsNative` — JSON over strings, -//! no codegen (the myco / nostr-vpn embedding pattern). Errors come back as +//! JNI surface for `com.archipelago.app.fips.FipsNative` and +//! `com.archipelago.app.NativeCore` — JSON over strings, no codegen (the +//! myco / nostr-vpn embedding pattern). Errors come back as //! `{"error": "…"}` so Kotlin never sees a raw exception from native code. use std::sync::Once; @@ -127,3 +128,177 @@ pub extern "system" fn Java_com_archipelago_app_fips_FipsNative_statusJson( ) -> jstring { out(&env, mesh::status_json()) } + +// ───────────────────────────────────────────────────────────────────────────── +// com.archipelago.app.NativeCore — companion backup (#128) and NIP-46 remote +// signer crypto (#139). Same library, JSON-over-strings contract. +// ───────────────────────────────────────────────────────────────────────────── + +/// Kotlin: `external fun backupEncrypt(payload: String, passphrase: String): String` +/// Returns the ADR-005 envelope JSON or `{"error": …}`. +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_NativeCore_backupEncrypt( + mut env: JNIEnv, + _class: JClass, + payload: JString, + passphrase: JString, +) -> jstring { + init_logging(); + let payload = jstr(&mut env, &payload); + let passphrase = jstr(&mut env, &passphrase); + let json = match crate::backup::encrypt(&payload, &passphrase) { + Ok(envelope) => envelope, + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun backupDecrypt(envelope: String, passphrase: String): String` +/// Returns the decrypted payload JSON or `{"error": …}`. +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_NativeCore_backupDecrypt( + mut env: JNIEnv, + _class: JClass, + envelope: JString, + passphrase: JString, +) -> jstring { + init_logging(); + let envelope = jstr(&mut env, &envelope); + let passphrase = jstr(&mut env, &passphrase); + let json = match crate::backup::decrypt(&envelope, &passphrase) { + Ok(payload) => payload, + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun nostrGenerateSecret(): String` +/// Returns `{"secret": hex, "pubkey": hex, "npub": …, "nsec": …}` or `{"error": …}`. +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrGenerateSecret( + env: JNIEnv, + _class: JClass, +) -> jstring { + init_logging(); + let json = match crate::nostr::generate_secret() { + Ok(secret) => nostr_key_info_json(&secret), + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun nostrSecretFromAny(secret: String): String` +/// Accepts hex or `nsec…`; returns key-info JSON or `{"error": …}`. +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrSecretFromAny( + mut env: JNIEnv, + _class: JClass, + secret: JString, +) -> jstring { + init_logging(); + let secret = jstr(&mut env, &secret); + let json = match crate::nostr::secret_from_any(&secret) { + Ok(hex) => nostr_key_info_json(&hex), + Err(e) => err_json(e), + }; + out(&env, json) +} + +fn nostr_key_info_json(secret_hex: &str) -> String { + match ( + crate::nostr::pubkey_hex(secret_hex), + crate::nostr::npub_from_pubkey(&crate::nostr::pubkey_hex(secret_hex).unwrap_or_default()), + crate::nostr::nsec_from_secret(secret_hex), + ) { + (Ok(pubkey), Ok(npub), Ok(nsec)) => serde_json::json!({ + "secret": secret_hex, + "pubkey": pubkey, + "npub": npub, + "nsec": nsec, + }) + .to_string(), + (e, _, _) => err_json(e.unwrap_err()), + } +} + +/// Kotlin: `external fun nostrParseConnectUri(uri: String): String` +/// Returns the parsed URI fields or `{"error": …}`. +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrParseConnectUri( + mut env: JNIEnv, + _class: JClass, + uri: JString, +) -> jstring { + init_logging(); + let uri = jstr(&mut env, &uri); + let json = match crate::nostr::parse_connect_uri(&uri) { + Ok(info) => info.to_json().to_string(), + Err(e) => err_json(e), + }; + out(&env, json) +} + +/// Kotlin: `external fun nostrSignEvent(secretHex: String, eventJson: String): String` +/// Returns the signed event JSON or `{"error": …}`. The approve/deny decision +/// is made in Kotlin BEFORE this is called — native code never signs unasked. +#[no_mangle] +pub extern "system" fn Java_com_archipelago_app_NativeCore_nostrSignEvent( + mut env: JNIEnv, + _class: JClass, + secret_hex: JString, + event_json: JString, +) -> jstring { + init_logging(); + let secret = jstr(&mut env, &secret_hex); + let event = jstr(&mut env, &event_json); + let json = match crate::nostr::sign_event(&secret, &event) { + Ok(signed) => signed, + Err(e) => err_json(e), + }; + out(&env, json) +} + +macro_rules! nostr_cipher { + ($name:ident, $doc:literal, $fn:path) => { + #[doc = $doc] + #[no_mangle] + pub extern "system" fn $name( + mut env: JNIEnv, + _class: JClass, + secret_hex: JString, + peer_pub: JString, + text: JString, + ) -> jstring { + init_logging(); + let secret = jstr(&mut env, &secret_hex); + let peer = jstr(&mut env, &peer_pub); + let text = jstr(&mut env, &text); + let json = match $fn(&secret, &peer, &text) { + Ok(out) => serde_json::json!({ "result": out }).to_string(), + Err(e) => err_json(e), + }; + out(&env, json) + } + }; +} + +nostr_cipher!( + Java_com_archipelago_app_NativeCore_nostrNip44Encrypt, + "Kotlin: `external fun nostrNip44Encrypt(secretHex: String, peerPub: String, plaintext: String): String` — returns `{\"result\": payload}` or `{\"error\": …}`.", + crate::nostr::nip44_encrypt +); +nostr_cipher!( + Java_com_archipelago_app_NativeCore_nostrNip44Decrypt, + "Kotlin: `external fun nostrNip44Decrypt(secretHex: String, peerPub: String, payload: String): String`", + crate::nostr::nip44_decrypt +); +nostr_cipher!( + Java_com_archipelago_app_NativeCore_nostrNip04Encrypt, + "Kotlin: `external fun nostrNip04Encrypt(secretHex: String, peerPub: String, plaintext: String): String`", + crate::nostr::nip04_encrypt +); +nostr_cipher!( + Java_com_archipelago_app_NativeCore_nostrNip04Decrypt, + "Kotlin: `external fun nostrNip04Decrypt(secretHex: String, peerPub: String, payload: String): String`", + crate::nostr::nip04_decrypt +); diff --git a/Android/rust/archy-fips-core/src/lib.rs b/Android/rust/archy-fips-core/src/lib.rs index 98a22d7e..b653b991 100644 --- a/Android/rust/archy-fips-core/src/lib.rs +++ b/Android/rust/archy-fips-core/src/lib.rs @@ -11,7 +11,9 @@ //! JSON-over-strings, mirroring the myco / nostr-vpn embedding pattern: //! `generateIdentity`, `deriveIdentity`, `start`, `stop`, `isRunning`. +pub mod backup; pub mod mesh; +pub mod nostr; #[cfg(target_os = "android")] mod jni_glue; diff --git a/Android/rust/archy-fips-core/src/nostr.rs b/Android/rust/archy-fips-core/src/nostr.rs new file mode 100644 index 00000000..daf87cd7 --- /dev/null +++ b/Android/rust/archy-fips-core/src/nostr.rs @@ -0,0 +1,798 @@ +//! NIP-46 phone-side remote signer ("bunker") crypto core. +//! +//! Everything that must be constant-time correct for the companion to act as +//! a nostr remote signer: key handling (nsec/npub bech32), BIP340 schnorr +//! event signing, NIP-44 v2 payload encryption (the mandated NIP-46 +//! transport), NIP-04 fallback decryption (deprecated, but real clients +//! still speak it), and `nostrconnect://` URI parsing. The protocol session +//! — relay WebSocket, JSON-RPC dispatch, approve/deny UX — lives in Kotlin; +//! this module is the crypto and nothing but. +//! +//! Verified against the official NIP-44 vectors and BIP-340 reference +//! vectors (see tests below). + +use anyhow::{bail, Context, Result}; +use base64::engine::general_purpose::{STANDARD as BASE64, URL_SAFE as BASE64_URL}; +use base64::Engine; +use bech32::{Bech32, Hrp}; +use chacha20::cipher::{KeyIvInit, StreamCipher}; +use chacha20::ChaCha20; +use hmac::{Hmac, Mac}; +use hkdf::Hkdf; +use secp256k1::ecdh; +use secp256k1::schnorr::Signature; +use secp256k1::{ + Keypair, Message, PublicKey, Secp256k1, SecretKey, XOnlyPublicKey, +}; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +const NIP44_VERSION: u8 = 2; +const NIP44_SALT: &[u8] = b"nip44-v2"; +const NIP44_MIN_PAYLOAD_LEN: usize = 99; // 1 ver + 32 nonce + 32 ct + 32 mac +const NIP44_MIN_B64_LEN: usize = 132; + +// ── keys ────────────────────────────────────────────────────────────────── + +/// Generate a fresh nostr secret key (hex) from the OS CSPRNG. +pub fn generate_secret() -> Result { + loop { + let mut bytes = [0u8; 32]; + getrandom::getrandom(&mut bytes).context("OS RNG")?; + // Reject zero and >= curve order — the valid scalar range (mirrors + // the mesh identity loop; rejection is astronomically unlikely). + if bytes.iter().all(|&b| b == 0) { + continue; + } + if SecretKey::from_slice(&bytes).is_ok() { + return Ok(hex::encode(bytes)); + } + } +} + +/// Parse a secret key from hex or bech32 `nsec…` form into hex. +pub fn secret_from_any(s: &str) -> Result { + let s = s.trim(); + if s.starts_with("nsec") { + return secret_from_nsec(s); + } + let bytes = hex::decode(s.trim()).context("secret key must be hex or nsec")?; + let sk = SecretKey::from_slice(&bytes).context("invalid nostr secret key")?; + Ok(hex::encode(sk.secret_bytes())) +} + +pub fn secret_from_nsec(nsec: &str) -> Result { + let (hrp, data) = bech32::decode(nsec).context("bad nsec encoding")?; + if hrp.as_str() != "nsec" { + bail!("not an nsec"); + } + let sk = SecretKey::from_slice(&data).context("invalid nostr secret key")?; + Ok(hex::encode(sk.secret_bytes())) +} + +pub fn nsec_from_secret(secret_hex: &str) -> Result { + let bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?; + let hrp = Hrp::parse("nsec").context("nsec hrp")?; + bech32::encode::(hrp, &bytes).context("nsec encoding") +} + +/// x-only public key (hex) for a secret key. +/// NOTE: `Keypair::public_key()` in secp256k1 0.29 is the full compressed +/// (33-byte) key — nostr uses x-only pubkeys, so serialize `.x_only_public_key().0`. +pub fn pubkey_hex(secret_hex: &str) -> Result { + let kp = keypair(secret_hex)?; + Ok(hex::encode(kp.public_key().x_only_public_key().0.serialize())) +} + +pub fn npub_from_pubkey(pub_hex: &str) -> Result { + let bytes = hex::decode(pub_hex.trim()).context("bad pubkey hex")?; + let hrp = Hrp::parse("npub").context("npub hrp")?; + bech32::encode::(hrp, &bytes).context("npub encoding") +} + +/// Parse an x-only pubkey from hex or bech32 `npub…` form into hex. +pub fn pubkey_from_any(s: &str) -> Result { + let s = s.trim(); + let bytes = if s.starts_with("npub") { + let (hrp, data) = bech32::decode(s).context("bad npub encoding")?; + if hrp.as_str() != "npub" { + bail!("not an npub"); + } + data + } else { + hex::decode(s).context("pubkey must be hex or npub")? + }; + XOnlyPublicKey::from_slice(&bytes).context("invalid x-only pubkey")?; + Ok(hex::encode(bytes)) +} + +fn keypair(secret_hex: &str) -> Result { + let bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?; + let sk = SecretKey::from_slice(&bytes).context("invalid nostr secret key")?; + Ok(Keypair::from_secret_key(&Secp256k1::new(), &sk)) +} + +// ── nostrconnect:// URI ─────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct ConnectUri { + /// The client's pubkey, hex. + pub client_pubkey: String, + /// Relays the client is listening on (≥1 by spec; kept in URI order). + pub relays: Vec, + /// One-time pairing secret the client expects to see echoed back. + pub secret: String, + /// Comma-separated permission grants the client requests (display hint + /// only — approval always stays with the human). + pub perms: Vec, + pub name: String, + pub url: String, + pub image: String, +} + +impl ConnectUri { + /// JSON shape for the JNI boundary (flat strings/arrays — easy to parse + /// with org.json on the Kotlin side). + pub fn to_json(&self) -> serde_json::Value { + serde_json::json!({ + "clientPubkey": self.client_pubkey, + "relays": self.relays, + "secret": self.secret, + "perms": self.perms, + "name": self.name, + "url": self.url, + "image": self.image, + }) + } +} + +/// Parse `nostrconnect://?relay=…&secret=…&perms=…&name=…`. +/// +/// Query values are percent-decoded; `relay` may repeat. The pubkey in the +/// host position may be hex or (non-spec but harmless) `npub…`. +pub fn parse_connect_uri(uri: &str) -> Result { + let uri = uri.trim(); + let rest = uri + .strip_prefix("nostrconnect://") + .ok_or_else(|| anyhow::anyhow!("not a nostrconnect:// URI"))?; + + let (host, query) = match rest.split_once('?') { + Some((h, q)) => (h, q), + None => bail!("nostrconnect URI has no query parameters"), + }; + let client_pubkey = pubkey_from_any(host).context("nostrconnect URI: bad client pubkey")?; + + let mut relays = Vec::new(); + let mut secret = String::new(); + let mut perms: Vec = Vec::new(); + let mut name = String::new(); + let mut url = String::new(); + let mut image = String::new(); + + for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { + let v = v.into_owned(); + match k.as_ref() { + "relay" => { + if v.starts_with("ws://") || v.starts_with("wss://") { + relays.push(v); + } + } + "secret" => secret = v, + "perms" => perms = v.split(',').filter(|s| !s.is_empty()).map(String::from).collect(), + "name" => name = v, + "url" => url = v, + "image" => image = v, + _ => {} // forward-compat: ignore unknown params + } + } + + if relays.is_empty() { + bail!("nostrconnect URI carries no relay"); + } + if secret.is_empty() { + bail!("nostrconnect URI carries no secret"); + } + + Ok(ConnectUri { + client_pubkey, + relays, + secret, + perms, + name, + url, + image, + }) +} + +// ── events (NIP-01 id + BIP340 signature) ───────────────────────────────── + +/// Compute the NIP-01 event id: sha256 over the compact serialization +/// `[0, pubkey, created_at, kind, tags, content]`. +fn event_id(pubkey: &str, created_at: u64, kind: u64, tags: &serde_json::Value, content: &str) -> [u8; 32] { + let serialized = serde_json::json!([ + 0, + pubkey, + created_at, + kind, + tags, + content, + ]); + let mut hasher = Sha256::new(); + hasher.update(serialized.to_string().as_bytes()); + hasher.finalize().into() +} + +/// Sign an unsigned event `{kind, content, tags, created_at}` (pubkey filled +/// from the secret key; `pubkey` in the input ignored) and return the signed +/// event JSON. This is the `sign_event` NIP-46 method's core — the approve +/// happens before this call, never inside it. +pub fn sign_event(secret_hex: &str, event_json: &str) -> Result { + let ev: serde_json::Value = serde_json::from_str(event_json).context("event is not JSON")?; + let kind = ev + .get("kind") + .and_then(|v| v.as_u64()) + .context("event has no kind")?; + let created_at = ev + .get("created_at") + .and_then(|v| v.as_u64()) + .context("event has no created_at")?; + let tags = ev + .get("tags") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + let content = ev + .get("content") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let kp = keypair(secret_hex)?; + let pubkey = hex::encode(kp.public_key().x_only_public_key().0.serialize()); + let id = event_id(&pubkey, created_at, kind, &tags, &content); + + let mut aux = [0u8; 32]; + getrandom::getrandom(&mut aux).context("OS RNG")?; + let sig = Secp256k1::new().sign_schnorr_with_aux_rand( + &Message::from_digest(id), + &kp, + &aux, + ); + + Ok(serde_json::json!({ + "id": hex::encode(id), + "pubkey": pubkey, + "created_at": created_at, + "kind": kind, + "tags": tags, + "content": content, + "sig": hex::encode(sig.serialize()), + }) + .to_string()) +} + +/// Verify a signed event's id and schnorr signature (tests + defensive use). +pub fn verify_event(event_json: &str) -> Result<()> { + let ev: serde_json::Value = serde_json::from_str(event_json).context("event is not JSON")?; + let pubkey = ev.get("pubkey").and_then(|v| v.as_str()).context("no pubkey")?; + let id_hex = ev.get("id").and_then(|v| v.as_str()).context("no id")?; + let sig_hex = ev.get("sig").and_then(|v| v.as_str()).context("no sig")?; + let kind = ev.get("kind").and_then(|v| v.as_u64()).context("no kind")?; + let created_at = ev.get("created_at").and_then(|v| v.as_u64()).context("no created_at")?; + let tags = ev.get("tags").cloned().unwrap_or_else(|| serde_json::json!([])); + let content = ev.get("content").and_then(|v| v.as_str()).unwrap_or(""); + + let expected = event_id(pubkey, created_at, kind, &tags, content); + if hex::encode(expected) != id_hex { + bail!("event id mismatch"); + } + + let pk = XOnlyPublicKey::from_slice(&hex::decode(pubkey)?) + .context("bad pubkey")?; + let sig = Signature::from_slice(&hex::decode(sig_hex)?) + .context("bad signature")?; + Secp256k1::new() + .verify_schnorr(&sig, &Message::from_digest(expected), &pk) + .context("signature verification failed")?; + Ok(()) +} + +// ── NIP-44 v2 ────────────────────────────────────────────────────────────── + +/// ECDH shared x-coordinate (unhashed, 32 bytes) between our secret key and +/// the peer's x-only public key. Lifting the x-only key with even-y parity +/// is safe here: negating a point flips only y, so the shared x — the only +/// thing NIP-44/NIP-04 consume — is unchanged. +fn shared_x(secret_hex: &str, peer_pubkey_hex: &str) -> Result<[u8; 32]> { + let sk_bytes = hex::decode(secret_hex.trim()).context("bad secret hex")?; + let sk = SecretKey::from_slice(&sk_bytes).context("invalid secret key")?; + let peer_hex = pubkey_from_any(peer_pubkey_hex)?; + let peer = XOnlyPublicKey::from_slice(&hex::decode(&peer_hex)?) + .context("invalid peer pubkey")?; + // Lift x-only key to a full public key (even-y representative). + let full = PublicKey::from_x_only_public_key(peer, secp256k1::Parity::Even); + let point = ecdh::shared_secret_point(&full, &sk); // 64 bytes: x || y + let mut x = [0u8; 32]; + x.copy_from_slice(&point[..32]); + Ok(x) +} + +/// NIP-44 v2 conversation key: HKDF-extract(IKM = ECDH x, salt = 'nip44-v2'). +fn conversation_key(secret_hex: &str, peer_pubkey_hex: &str) -> Result<[u8; 32]> { + let x = shared_x(secret_hex, peer_pubkey_hex)?; + let mut hk = HkdfExtractSha256::new(Some(NIP44_SALT)); + hk.input_ikm(&x); + let (prk, _) = hk.finalize(); + let mut ck = [0u8; 32]; + ck.copy_from_slice(prk.as_slice()); + Ok(ck) +} + +/// HKDF-SHA256 extract step, exposing the raw PRK (Hkdf::expand hashes with +/// an info suffix even when info is empty, which is NOT the extract output; +/// finalize returns (PRK, ready-to-expand Hkdf)). +type HkdfExtractSha256 = hkdf::HkdfExtract; + +/// Per-message keys: HKDF-expand(PRK = conversation key, info = nonce, L = 76) +/// sliced into chacha_key[32] chacha_nonce[12] hmac_key[32]. +fn message_keys(ck: &[u8; 32], nonce: &[u8; 32]) -> ([u8; 32], [u8; 12], [u8; 32]) { + let hk = Hkdf::::from_prk(ck).expect("conversation key is 32 bytes"); + let mut okm = [0u8; 76]; + hk.expand(nonce, &mut okm).expect("76 <= 255 * hash len"); + let mut chacha_key = [0u8; 32]; + let mut chacha_nonce = [0u8; 12]; + let mut hmac_key = [0u8; 32]; + chacha_key.copy_from_slice(&okm[..32]); + chacha_nonce.copy_from_slice(&okm[32..44]); + hmac_key.copy_from_slice(&okm[44..76]); + (chacha_key, chacha_nonce, hmac_key) +} + +/// NIP-44 padding: 2-byte big-endian plaintext length (6 bytes, `0x0000` + +/// u32, when ≥ 65536), zero-padded to the next power-of-two-ish chunk. +fn calc_padded_len(unpadded: usize) -> usize { + let unpadded: u64 = unpadded as u64; + if unpadded <= 32 { + return 32; + } + let next_power = 1u64 << ((63 - (unpadded - 1).leading_zeros()) + 1); + let chunk = if next_power <= 256 { 32 } else { next_power / 8 }; + (chunk * ((unpadded - 1) / chunk + 1)) as usize +} + +fn pad(plaintext: &[u8]) -> Result> { + if plaintext.is_empty() || plaintext.len() > u32::MAX as usize { + bail!("invalid plaintext length"); + } + let prefix: Vec = if plaintext.len() >= 65536 { + let mut p = vec![0u8, 0u8]; + p.extend_from_slice(&(plaintext.len() as u32).to_be_bytes()); + p + } else { + (plaintext.len() as u16).to_be_bytes().to_vec() + }; + let padded_len = calc_padded_len(plaintext.len()); + let mut out = Vec::with_capacity(prefix.len() + padded_len); + out.extend_from_slice(&prefix); + out.extend_from_slice(plaintext); + out.resize(prefix.len() + padded_len, 0); + Ok(out) +} + +fn unpad(padded: &[u8]) -> Result> { + if padded.len() < 2 { + bail!("invalid padding"); + } + let first_two = u16::from_be_bytes([padded[0], padded[1]]); + let (unpadded_len, prefix_len) = if first_two == 0 { + if padded.len() < 6 { + bail!("invalid padding"); + } + (u32::from_be_bytes([padded[2], padded[3], padded[4], padded[5]]) as usize, 6) + } else { + (first_two as usize, 2) + }; + if unpadded_len == 0 + || padded.len() < prefix_len + unpadded_len + || padded.len() != prefix_len + calc_padded_len(unpadded_len) + { + bail!("invalid padding"); + } + Ok(padded[prefix_len..prefix_len + unpadded_len].to_vec()) +} + +/// Constant-time equality (length differs → false; content comparison never +/// short-circuits on a byte). +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +/// NIP-44 v2 encrypt: returns `base64(0x02 || nonce || ciphertext || mac)`. +pub fn nip44_encrypt(secret_hex: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result { + let ck = conversation_key(secret_hex, peer_pubkey_hex)?; + let mut nonce = [0u8; 32]; + getrandom::getrandom(&mut nonce).context("OS RNG")?; + let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce); + + let mut padded = pad(plaintext.as_bytes())?; + ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut padded); + + let mut mac = ::new_from_slice(&hmac_key).expect("hmac accepts any key len"); + mac.update(&nonce); + mac.update(&padded); + let tag = mac.finalize().into_bytes(); + + let mut out = Vec::with_capacity(1 + 32 + padded.len() + 32); + out.push(NIP44_VERSION); + out.extend_from_slice(&nonce); + out.extend_from_slice(&padded); + out.extend_from_slice(&tag); + Ok(BASE64.encode(&out)) +} + +/// NIP-44 v2 decrypt of a `base64(0x02 || …)` payload. +pub fn nip44_decrypt(secret_hex: &str, peer_pubkey_hex: &str, payload: &str) -> Result { + if payload.starts_with('#') { + bail!("unknown NIP-44 version (non-base64 payload)"); + } + let data = BASE64 + .decode(payload.trim()) + .context("payload is not base64")?; + if payload.len() < NIP44_MIN_B64_LEN || data.len() < NIP44_MIN_PAYLOAD_LEN { + bail!("invalid NIP-44 payload size"); + } + if data[0] != NIP44_VERSION { + bail!("unknown NIP-44 version {}", data[0]); + } + let nonce: [u8; 32] = data[1..33].try_into().expect("slice is 32"); + let ciphertext = &data[33..data.len() - 32]; + let mac_bytes = &data[data.len() - 32..]; + + let ck = conversation_key(secret_hex, peer_pubkey_hex)?; + let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce); + + let mut mac = ::new_from_slice(&hmac_key).expect("hmac accepts any key len"); + mac.update(&nonce); + mac.update(ciphertext); + let expected = mac.finalize().into_bytes(); + if !ct_eq(&expected, mac_bytes) { + bail!("invalid NIP-44 MAC"); + } + + let mut buf = ciphertext.to_vec(); + ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut buf); + let plaintext = unpad(&buf)?; + String::from_utf8(plaintext).context("decrypted payload is not UTF-8") +} + +// ── NIP-04 (deprecated transport, still spoken by real clients) ──────────── + +/// NIP-04 encrypt: AES-256-CBC, key = raw ECDH x-coordinate (unhashed — the +/// spec's quirk), output `?iv=`. +pub fn nip04_encrypt(secret_hex: &str, peer_pubkey_hex: &str, plaintext: &str) -> Result { + use aes::cipher::{BlockEncryptMut, KeyIvInit}; + type Enc = cbc::Encryptor; + + let key = shared_x(secret_hex, peer_pubkey_hex)?; + let mut iv = [0u8; 16]; + getrandom::getrandom(&mut iv).context("OS RNG")?; + let ct = Enc::new(&key.into(), &iv.into()).encrypt_padded_vec_mut::(plaintext.as_bytes()); + Ok(format!("{}?iv={}", BASE64.encode(&ct), BASE64.encode(iv))) +} + +/// NIP-04 decrypt of `?iv=`. +pub fn nip04_decrypt(secret_hex: &str, peer_pubkey_hex: &str, payload: &str) -> Result { + use aes::cipher::{BlockDecryptMut, KeyIvInit}; + type Dec = cbc::Decryptor; + + let (ct_b64, iv_b64) = payload + .trim() + .split_once("?iv=") + .ok_or_else(|| anyhow::anyhow!("not a NIP-04 payload (no iv)"))?; + let ct = BASE64.decode(ct_b64).context("bad NIP-04 ciphertext base64")?; + let iv: [u8; 16] = BASE64 + .decode(iv_b64) + .context("bad NIP-04 iv base64")? + .try_into() + .map_err(|_| anyhow::anyhow!("NIP-04 iv must be 16 bytes"))?; + let key = shared_x(secret_hex, peer_pubkey_hex)?; + let pt = Dec::new(&key.into(), &iv.into()) + .decrypt_padded_vec_mut::(&ct) + .map_err(|_| anyhow::anyhow!("NIP-04 decryption failed"))?; + String::from_utf8(pt).context("decrypted payload is not UTF-8") +} + +/// URL-safe base64 for keys that cross the JNI boundary — unused by the +/// protocol but handy for the Kotlin side; keep the engine in one place. +pub fn b64_url(data: &[u8]) -> String { + BASE64_URL.encode(data) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── official NIP-44 vectors (paulmillr/nip44 nip44.vectors.json) ────── + + #[test] + fn nip44_official_conversation_keys() { + let vectors: &[(&str, &str, &str)] = &[ + ("315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268", "c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133", "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1"), + ("98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311", "aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1", "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7"), + ("86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f", "59f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585", "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba"), + // sec1 == pub2 (ECDH with self) + ("0000000000000000000000000000000000000000000000000000000000000001", "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e"), + ]; + for (sec1, pub2, expected) in vectors { + let ck = conversation_key(sec1, pub2).unwrap(); + assert_eq!(hex::encode(ck), *expected); + } + } + + #[test] + fn nip44_official_message_keys() { + let ck_bytes: [u8; 32] = hex::decode("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54") + .unwrap() + .try_into() + .unwrap(); + let vectors: &[(&str, &str, &str, &str)] = &[ + ("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72", "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76", "c4ad129bb01180c0933a160c", "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"), + ("ea6eb84cac23c5c1607c334e8bdf66f7977a7e374052327ec28c6906cbe25967", "ff68db24b34fa62c78ac5ffeeaf19533afaedf651fb6a08384e46787f6ce94be", "50bb859aa2dde938cc49ec7a", "06ff32e1f7b29753a727d7927b25c2dd175aca47751462d37a2039023ec6b5a6"), + ]; + for (nonce_h, ck_exp, cn_exp, hk_exp) in vectors { + let nonce: [u8; 32] = hex::decode(nonce_h).unwrap().try_into().unwrap(); + let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck_bytes, &nonce); + assert_eq!(hex::encode(chacha_key), *ck_exp); + assert_eq!(hex::encode(chacha_nonce), *cn_exp); + assert_eq!(hex::encode(hmac_key), *hk_exp); + } + } + + #[test] + fn nip44_offical_padded_len() { + let vectors: &[(usize, usize)] = &[ + (16, 32), (32, 32), (33, 64), (37, 64), (45, 64), (49, 64), (64, 64), + (65, 96), (100, 128), (111, 128), (200, 224), (250, 256), (320, 320), + (383, 384), (384, 384), (400, 448), (500, 512), (512, 512), (515, 640), + (700, 768), (800, 896), (900, 1024), (1020, 1024), (65536, 65536), + ]; + for (unpadded, padded) in vectors { + assert_eq!(calc_padded_len(*unpadded), *padded, "unpadded {unpadded}"); + } + } + + #[test] + fn nip44_official_encrypt_vectors() { + // (sec1, sec2, nonce, plaintext, payload) — decrypt with the peer's + // view (sec2, pub(sec1)) so this also proves key symmetry. + let vectors: &[(&str, &str, &str, &str, &str)] = &[ + ("0000000000000000000000000000000000000000000000000000000000000001", + "0000000000000000000000000000000000000000000000000000000000000002", + "0000000000000000000000000000000000000000000000000000000000000001", + "a", + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb"), + ("0000000000000000000000000000000000000000000000000000000000000002", + "0000000000000000000000000000000000000000000000000000000000000001", + "f00000000000000000000000000000f00000000000000000000000000000000f", + "🍕🫃", + "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj"), + ("5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a", + "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d", + "b635236c42db20f021bb8d1cdff5ca75dd1a0cc72ea742ad750f33010b24f73b", + "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀", + "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs="), + ("eba1687cab6a3101bfc68fd70f214aa4cc059e9ec1b79fdb9ad0a0a4e259829f", + "dff20d262bef9dfd94666548f556393085e6ea421c8af86e9d333fa8747e94b3", + "2180b52ae645fcf9f5080d81b1f0b5d6f2cd77ff3c986882bb549158462f3407", + "( ͡° ͜ʖ ͡°)", + "AiGAtSrmRfz59QgNgbHwtdbyzXf/PJhogrtUkVhGLzQHv4qhKQwnFQ54OjVMgqCea/Vj0YqBSdhqNR777TJ4zIUk7R0fnizp6l1zwgzWv7+ee6u+0/89KIjY5q1wu6inyuiv"), + ("d5633530f5bcfebceb5584cfbbf718a30df0751b729dd9a789b9f30c0587d74e", + "b74e6a341fb134127272b795a08b59250e5fa45a82a2eb4095e4ce9ed5f5e214", + "a3e219242d85465e70adcd640b564b3feff57d2ef8745d5e7a0663b2dccceb54", + "🙈 🙉 🙊 0️⃣ 1️⃣ 2️⃣ 3️⃣ 4️⃣ 5️⃣ 6️⃣ 7️⃣ 8️⃣ 9️⃣ 🔟 Powerلُلُصّبُلُلصّبُررً ॣ ॣh ॣ ॣ冗", + "AqPiGSQthUZecK3NZAtWSz/v9X0u+HRdXnoGY7LczOtUf05aMF89q1FLwJvaFJYICZoMYgRJHFLwPiOHce7fuAc40kX0wXJvipyBJ9HzCOj7CgtnC1/cmPCHR3s5AIORmroBWglm1LiFMohv1FSPEbaBD51VXxJa4JyWpYhreSOEjn1wd0lMKC9b+osV2N2tpbs+rbpQem2tRen3sWflmCqjkG5VOVwRErCuXuPb5+hYwd8BoZbfCrsiAVLd7YT44dRtKNBx6rkabWfddKSLtreHLDysOhQUVOp/XkE7OzSkWl6sky0Hva6qJJ/V726hMlomvcLHjE41iKmW2CpcZfOedg=="), + ]; + for (sec1, sec2, nonce_hex, plaintext, payload) in vectors { + // Encrypt from A to B with the fixed nonce must reproduce the + // official payload byte-for-byte. + let pub1 = pubkey_hex(sec1).unwrap(); + let made = { + let ck = conversation_key(sec1, &pubkey_hex(sec2).unwrap()).unwrap(); + let nonce: [u8; 32] = hex::decode(nonce_hex).unwrap().try_into().unwrap(); + let (chacha_key, chacha_nonce, hmac_key) = message_keys(&ck, &nonce); + let mut padded = pad(plaintext.as_bytes()).unwrap(); + ChaCha20::new(&chacha_key.into(), &chacha_nonce.into()).apply_keystream(&mut padded); + let mut mac = ::new_from_slice(&hmac_key).unwrap(); + mac.update(&nonce); + mac.update(&padded); + let tag = mac.finalize().into_bytes(); + let mut out = vec![NIP44_VERSION]; + out.extend_from_slice(&nonce); + out.extend_from_slice(&padded); + out.extend_from_slice(&tag); + BASE64.encode(&out) + }; + assert_eq!(&made, payload, "encrypt vector for {plaintext:?}"); + + // Decrypt from B's view of A (key-role symmetry). + let got = nip44_decrypt(sec2, &pub1, payload).unwrap(); + assert_eq!(got, *plaintext); + } + } + + #[test] + fn nip44_round_trip_and_failures() { + let sk_a = generate_secret().unwrap(); + let sk_b = generate_secret().unwrap(); + let pub_b = pubkey_hex(&sk_b).unwrap(); + let pub_a = pubkey_hex(&sk_a).unwrap(); + + let msg = "hello, remote signer"; + let payload = nip44_encrypt(&sk_a, &pub_b, msg).unwrap(); + assert_eq!(nip44_decrypt(&sk_b, &pub_a, &payload).unwrap(), msg); + + // Round-trip long content across the 65536 prefix boundary. + let long = "x".repeat(70_000); + let payload = nip44_encrypt(&sk_a, &pub_b, &long).unwrap(); + assert_eq!(nip44_decrypt(&sk_b, &pub_a, &payload).unwrap(), long); + + // Wrong peer key must fail the MAC, not return garbage. + let stranger = generate_secret().unwrap(); + assert!(nip44_decrypt(&sk_b, &pub_b, &payload).is_err()); + let _ = stranger; + + // Tampered payload fails. + let payload = nip44_encrypt(&sk_a, &pub_b, msg).unwrap(); + let mut tampered = BASE64.decode(&payload).unwrap(); + let n = tampered.len(); + tampered[n - 1] ^= 0x01; + assert!(nip44_decrypt(&sk_b, &pub_a, &BASE64.encode(&tampered)).is_err()); + + // Truncated payload fails. + assert!(nip44_decrypt(&sk_b, &pub_a, "AAAA").is_err()); + } + + // ── BIP-340 official vectors (github.com/bitcoin/bips test vectors) ──── + + #[test] + fn bip340_reference_sign_vectors() { + // (seckey, pubkey, aux, msg, expected sig) — indices 0/1/2 of the + // official BIP-340 `bip-0340/test-vectors.csv` "should sign" set, + // transcribed from the file itself (x(3G) additionally verified + // by independent scalar-math in the review notes for this commit). + let vectors: &[(&str, &str, &str, &str, &str)] = &[ + ("0000000000000000000000000000000000000000000000000000000000000003", + "F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000000", + "E907831F80848D1069A5371B402410364BDF1C5F8307B0084C55F1CE2DCA821525F66A4A85EA8B71E482A74F382D2CE5EBEEE8FDB2172F477DF4900D310536C0"), + ("B7E151628AED2A6ABF7158809CF4F3C762E7160F38B4DA56A784D9045190CFEF", + "DFF1D77F2A671C5F36183726DB2341BE58FEAE1DA2DECED843240F7B502BA659", + "0000000000000000000000000000000000000000000000000000000000000001", + "243F6A8885A308D313198A2E03707344A4093822299F31D0082EFA98EC4E6C89", + "6896BD60EEAE296DB48A229FF71DFE071BDE413E6D43F917DC8DCF8C78DE33418906D11AC976ABCCB20B091292BFF4EA897EFCB639EA871CFA95F6DE339E4B0A"), + ("C90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B14E5C9", + "DD308AFEC5777E13121FA72B9CC1B7CC0139715309B086C960E18FD969774EB8", + "C87AA53824B4D7AE2EB035A2B5BBBCCC080E76CDC6D1692C4B0B62D798E6D906", + "7E2D58D8B3BCDF1ABADEC7829054F90DDA9805AAB56C77333024B9D0A508B75C", + "5831AAEED7B44BB74E5EAB94BA9D4294C49BCF2A60728D8B4C200F50DD313C1BAB745879A5AD954A72C45A91C3A51D3C7ADEA98D82F8481E0E1E03674A6F3FB7"), + ]; + for (sk_hex, pk_hex, aux_hex, msg_hex, sig_hex) in vectors { + let sk_bytes = hex::decode(sk_hex).unwrap(); + let sk = SecretKey::from_slice(&sk_bytes).unwrap(); + let kp = Keypair::from_secret_key(&Secp256k1::new(), &sk); + assert_eq!(hex::encode(kp.public_key().x_only_public_key().0.serialize()).to_uppercase(), *pk_hex); + + let msg: [u8; 32] = hex::decode(msg_hex).unwrap().try_into().unwrap(); + let aux: [u8; 32] = hex::decode(aux_hex).unwrap().try_into().unwrap(); + let sig = Secp256k1::new().sign_schnorr_with_aux_rand( + &Message::from_digest(msg), + &kp, + &aux, + ); + assert_eq!(hex::encode(sig.serialize()).to_uppercase(), *sig_hex); + } + } + + #[test] + fn event_signing_round_trip() { + let sk = generate_secret().unwrap(); + let unsigned = r#"{"kind":22242,"content":"{\"challenge\":\"abc123\"}","tags":[["relay","ws://127.0.0.1:7777"]],"created_at":1725100000}"#; + let signed = sign_event(&sk, unsigned).unwrap(); + verify_event(&signed).unwrap(); + + let ev: serde_json::Value = serde_json::from_str(&signed).unwrap(); + assert_eq!(ev["kind"], 22242); + assert_eq!(ev["pubkey"], pubkey_hex(&sk).unwrap()); + // Tampering with content breaks the id, which breaks verification. + let mut tampered = ev.clone(); + tampered["content"] = serde_json::Value::String("nope".into()); + assert!(verify_event(&tampered.to_string()).is_err()); + } + + #[test] + fn connect_uri_parsing() { + let uri = "nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?relay=wss%3A%2F%2Frelay1.example.com&perms=nip44_encrypt%2Csign_event%3A22242&name=My+Client&secret=0s8j2djs&relay=ws%3A%2F%2F192.168.1.20%3A7777"; + let info = parse_connect_uri(uri).unwrap(); + assert_eq!(info.client_pubkey, "83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5"); + assert_eq!( + info.relays, + vec!["wss://relay1.example.com", "ws://192.168.1.20:7777"] + ); + assert_eq!(info.secret, "0s8j2djs"); + assert_eq!(info.perms, vec!["nip44_encrypt", "sign_event:22242"]); + assert_eq!(info.name, "My Client"); + + // npub client keys and unknown params tolerated — the npub is + // generated through our own encoder so the test carries no + // hand-transcribed bech32 string. + let sk1 = "0000000000000000000000000000000000000000000000000000000000000001"; + let npub = npub_from_pubkey(&pubkey_hex(sk1).unwrap()).unwrap(); + let pubkey = pubkey_from_any(&npub).unwrap(); + let uri = format!("nostrconnect://{npub}?relay=wss://r&secret=s&future=1"); + let info = parse_connect_uri(&uri).unwrap(); + assert_eq!(info.client_pubkey, pubkey); + assert_eq!(info.relays, vec!["wss://r"]); + + assert!(parse_connect_uri("bunker://abc?relay=wss://r&secret=s").is_err()); + assert!(parse_connect_uri("nostrconnect://zz?relay=wss://r&secret=s").is_err()); + assert!(parse_connect_uri("nostrconnect://83f3b2ae6aa368e8275397b9c26cf550101d63ebaab900d19dd4a4429f5ad8f5?name=x").is_err()); + } + + #[test] + fn nip04_round_trip_and_cross_check() { + let sk_a = generate_secret().unwrap(); + let sk_b = generate_secret().unwrap(); + let pub_b = pubkey_hex(&sk_b).unwrap(); + let pub_a = pubkey_hex(&sk_a).unwrap(); + + let payload = nip04_encrypt(&sk_a, &pub_b, "old client hello").unwrap(); + assert!(payload.contains("?iv=")); + assert_eq!(nip04_decrypt(&sk_b, &pub_a, &payload).unwrap(), "old client hello"); + + // Wrong key must fail (PKCS#7 padding check) rather than return garbage. + assert!(nip04_decrypt(&sk_a, &pub_a, &payload).is_err()); + assert!(nip04_decrypt(&sk_b, &pub_b, &payload).is_err()); + assert!(nip04_decrypt(&sk_b, &pub_a, "not-a-payload").is_err()); + } + + #[test] + fn key_encoding_round_trip() { + let sk = generate_secret().unwrap(); + let nsec = nsec_from_secret(&sk).unwrap(); + assert!(nsec.starts_with("nsec1")); + assert_eq!(secret_from_nsec(&nsec).unwrap(), sk); + assert_eq!(secret_from_any(&nsec).unwrap(), sk); + assert_eq!(secret_from_any(&sk).unwrap(), sk); + + let pk = pubkey_hex(&sk).unwrap(); + let npub = npub_from_pubkey(&pk).unwrap(); + assert!(npub.starts_with("npub1")); + assert_eq!(pubkey_from_any(&npub).unwrap(), pk); + assert_eq!(pubkey_from_any(&pk).unwrap(), pk); + + // The famous even-y lift edge case: pubkey of sk=1 is x(G) (y is odd); + // shared_x with oneself is exactly x(G) — pins the unhashed-x ECDH and + // the even-parity lift in one assertion (x is invariant under y-negation, + // so the lift is safe for NIP-44/NIP-04 keys). + let g_x = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + assert_eq!( + pubkey_hex("0000000000000000000000000000000000000000000000000000000000000001").unwrap(), + g_x + ); + assert_eq!( + hex::encode( + shared_x("0000000000000000000000000000000000000000000000000000000000000001", g_x).unwrap() + ), + g_x + ); + assert!(secret_from_nsec("npub1").is_err()); + } +}