146 lines
4.5 KiB
Bash
146 lines
4.5 KiB
Bash
#!/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}" "$@"
|