Files
archy/Android/tools/fipssh
T
Dorian b927461f8e feat(companion): fipssh — ssh to a mesh node by npub (Termux helper)
Verified against the fips crate source: the mesh ULA is a pure function
of the PUBLIC key — fd || sha256(x-only pubkey)[0..15] — so the npub is
the durable address and needs no resolver. Android/tools/fipssh wraps
ssh for Termux: 'fipssh user@npub1…' derives the ULA (pure-python
bech32 + sha256, checksum-validated, typo protection) and execs ssh
over the companion's split tunnel; --resolve prints the ULA alone.

The derivation is pinned by a new Rust test
(npub_derives_the_same_mesh_ula_as_the_fips_identity, 3 seeds against
fips::Identity) and the helper's output was verified byte-identical
against a live fips identity pair. SSH-over-mesh handover updated with
an addendum: node docs/UI can advertise npub-based addressing, no
node-side DNS needed for this case.
2026-08-31 15:00:42 +01:00

146 lines
4.5 KiB
Bash
Executable File

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