#!/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}" "$@"