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.
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+145
@@ -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 <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}" "$@"
|
||||
@@ -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 <user>@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 <user>@<npub>` 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
|
||||
|
||||
Reference in New Issue
Block a user