diff --git a/Android/rust/archy-fips-core/src/nostr.rs b/Android/rust/archy-fips-core/src/nostr.rs index daf87cd7..337250a9 100644 --- a/Android/rust/archy-fips-core/src/nostr.rs +++ b/Android/rust/archy-fips-core/src/nostr.rs @@ -795,4 +795,30 @@ mod tests { ); assert!(secret_from_nsec("npub1").is_err()); } + + /// The mesh ULA is a PURE function of the node's public key: + /// `fd ‖ sha256(x-only pubkey)[0..15]` (fips identity/node_addr.rs → + /// identity/address.rs). That is what makes "address by npub" work — + /// Termux's fipssh helper, and any future DNS-style resolver, just + /// computes what the fips daemon's DNS answers. + #[test] + fn npub_derives_the_same_mesh_ula_as_the_fips_identity() { + for seed in [0x42u8, 0x07, 0x31] { + // 0xff… would exceed the curve order — secret keys must be valid scalars. + let secret = [seed; 32]; + let id = fips::Identity::from_secret_bytes(&secret).unwrap(); + let npub = id.npub(); + let expected = id.address().to_ipv6().to_string(); + + let pubkey_hex = pubkey_from_any(&npub).unwrap(); + let pk = hex::decode(&pubkey_hex).unwrap(); + let mut hasher = Sha256::new(); + hasher.update(&pk); + let hash = hasher.finalize(); + let mut ula = [0u8; 16]; + ula[0] = 0xfd; + ula[1..].copy_from_slice(&hash[..15]); + assert_eq!(std::net::Ipv6Addr::from(ula).to_string(), expected, "npub {npub}"); + } + } } diff --git a/Android/tools/fipssh b/Android/tools/fipssh new file mode 100755 index 00000000..0adf8d24 --- /dev/null +++ b/Android/tools/fipssh @@ -0,0 +1,145 @@ +#!/data/data/com.termux/files/usr/bin/sh +# fipssh — SSH to an Archipelago FIPS mesh node BY NPUB. +# +# The mesh ULA is a pure function of the node's public key (verified against +# the fips crate itself — archy-fips-core's npub_derives_the_same_mesh_ula +# test, and the Android tools commit that shipped this script): +# +# ula = fd || sha256(x-only pubkey)[0..15] +# +# so the npub IS the address: no DNS server, no mesh query, works offline. +# The node's fips daemon answers the same question through its DNS resolver +# (core/archipelago/src/fips/dial.rs) — this is the phone-side equivalent. +# +# Setup (Termux): pkg install python openssh +# Usage: +# fipssh @npub1… [ssh args…] connect +# fipssh npub1… connect as $FIPSSH_USER +# fipssh --resolve npub1… print the ULA and exit +# +# The companion's split tunnel carries the connection (fd00::/8 routes the +# whole device while the mesh is up) — at home on LAN, away via the anchors. +# The node still has to allow port 22 through its fips0 firewall: see +# docs/HANDOFF-2026-08-31-ssh-over-mesh.md (the interim 90-ssh.nft drop-in, +# restricted to your phone's ULA, until the node-side toggle ships). +set -eu + +usage() { + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' + exit 1 +} + +RESOLVE_ONLY=0 +if [ "${1:-}" = "--resolve" ]; then + RESOLVE_ONLY=1 + shift +fi +[ $# -ge 1 ] || usage + +TARGET="$1" +shift 2>/dev/null || true + +case "$TARGET" in + *npub1*) + case "$TARGET" in + *@npub1*) USER_PART="${TARGET%%@*}"; N_PUB="${TARGET#*@}" ;; + npub1*) + USER_PART="${FIPSSH_USER:-}" + N_PUB="$TARGET" + if [ -z "$USER_PART" ] && [ "$RESOLVE_ONLY" = 0 ]; then + echo "fipssh: no user given (use user@npub… or set FIPSSH_USER)" >&2 + exit 1 + fi + ;; + *) echo "fipssh: expected [user@]npub1…, got '$TARGET'" >&2; exit 1 ;; + esac + ;; + *) echo "fipssh: '$TARGET' is not an npub (expected [user@]npub1…)" >&2; exit 1 ;; +esac + +command -v python3 >/dev/null 2>&1 || { + echo "fipssh: python3 not found — run: pkg install python" >&2 + exit 1 +} + +ULA=$(python3 - "$N_PUB" <<'PYEOF' +import hashlib, ipaddress, sys + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def bech32_polymod(values): + gen = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3] + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1FFFFFF) << 5 ^ value + for i in range(5): + chk ^= gen[i] if ((top >> i) & 1) else 0 + return chk + + +def bech32_hrp_expand(hrp): + return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + + +def bech32_verify_checksum(hrp, data): + return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1 + + +def bech32_decode(s): + if any(ord(c) < 33 or ord(c) > 126 for c in s): + raise ValueError("bad character") + if s.lower() != s and s.upper() != s: + raise ValueError("mixed case") + s = s.lower() + pos = s.rfind("1") + if pos < 1 or pos + 7 > len(s) or len(s) > 90: + raise ValueError("bad separator") + hrp = s[:pos] + data = [CHARSET.find(c) for c in s[pos + 1:]] + if -1 in data: + raise ValueError("bad data character") + if not bech32_verify_checksum(hrp, data): + raise ValueError("bad checksum — typo in the npub?") + return hrp, data[:-6] + + +def convertbits(data, frombits, tobits): + acc = 0 + bits = 0 + ret = bytearray() + maxv = (1 << tobits) - 1 + for value in data: + if value < 0 or (value >> frombits): + raise ValueError("bad value") + acc = (acc << frombits) | value + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if bits >= frombits or ((acc << (tobits - bits)) & maxv): + raise ValueError("bad padding") + return bytes(ret) + + +npub = sys.argv[1] +hrp, data = bech32_decode(npub) +if hrp != "npub": + raise ValueError(f"expected hrp 'npub', got '{hrp}'") +pubkey = convertbits(data, 5, 8) +if len(pubkey) != 32: + raise ValueError(f"npub data must be 32 bytes, got {len(pubkey)}") +# ula = fd || sha256(pubkey)[0..15] — mirrors fips identity/node_addr.rs + +# identity/address.rs (FIPS_ADDRESS_PREFIX = 0xfd). +ula = bytes([0xFD]) + hashlib.sha256(pubkey).digest()[:15] +print(ipaddress.IPv6Address(ula).compressed) +PYEOF +) || exit 1 + +if [ "$RESOLVE_ONLY" = 1 ]; then + echo "$ULA" + exit 0 +fi + +exec ssh "${USER_PART}@${ULA}" "$@" diff --git a/docs/HANDOFF-2026-08-31-ssh-over-mesh.md b/docs/HANDOFF-2026-08-31-ssh-over-mesh.md index 9d32482a..627866b8 100644 --- a/docs/HANDOFF-2026-08-31-ssh-over-mesh.md +++ b/docs/HANDOFF-2026-08-31-ssh-over-mesh.md @@ -87,6 +87,25 @@ the node UI**, default **off**. Sketch: - [ ] Settings UI survives a page reload; RPC has a vitest like `TransportPrefsCard.test.ts`. +## Addendum (2026-08-31, same day): the npub IS the address + +While wiring this up we confirmed the mesh ULA is a **pure function of the +public key** — `fd ‖ sha256(x-only pubkey)[0..15]` (`fips/src/identity/node_addr.rs` +`from_pubkey` → `identity/address.rs` `from_node_addr`, +`FIPS_ADDRESS_PREFIX = 0xfd`). The daemon's DNS resolver (`fips/dial.rs`) just +answers what anyone can compute. Consequences for the node side: + +- Docs/UI can advertise `ssh @npub1…`-style addressing: Termux's + `Android/tools/fipssh` (shipped with the companion work) derives the ULA + from the npub with zero infrastructure, verified byte-identical against + the fips crate (`archy-fips-core` test + `npub_derives_the_same_mesh_ula_as_the_fips_identity`). +- If the settings toggle from this handover ever grows a "copy command" + affordance, `fipssh @` is the natural shape (npub, not ULA — + it is the durable identity; the ULA follows from it). +- No node-side DNS surface is required for the SSH case; the resolver stays + what it is today (the node's own peer dials). + ## Working rules Same as the queue handoffs: small commits, tracker issue for this feature