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());
|
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
|
- [ ] Settings UI survives a page reload; RPC has a vitest like
|
||||||
`TransportPrefsCard.test.ts`.
|
`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
|
## Working rules
|
||||||
|
|
||||||
Same as the queue handoffs: small commits, tracker issue for this feature
|
Same as the queue handoffs: small commits, tracker issue for this feature
|
||||||
|
|||||||
Reference in New Issue
Block a user