archy/reticulum-daemon/archy_rns_identity.py

81 lines
3.5 KiB
Python
Raw Normal View History

"""Derive a stable Reticulum (RNS) Identity from the Archipelago node identity.
Archy's root identity is a single 32-byte Ed25519 seed (``identity_dir/node_key``,
0600 see core/archipelago/src/identity.rs). A Reticulum ``Identity`` is a *pair*
of keypairs: an X25519 (encryption) key and an Ed25519 (signing) key, whose
concatenated 64-byte private blob is ``x25519_priv(32) || ed25519_priv(32)``.
We derive both halves deterministically from the Archy seed with domain-separated
HKDF-SHA256. Properties this gives us:
* **Reproducible** the same Archy node always produces the same RNS destination
hash, so a contact's Reticulum address is stable across reboots / reinstalls and
peers can bind it to the existing Archy contact (no manual re-pairing).
* **Domain-separated** we never reuse the raw Archy signing key inside RNS; each
derived key has its own HKDF ``info`` label. Reusing one private key across two
cryptographic schemes is a footgun we deliberately avoid.
Binding to the Archy DID/Npub is NOT done by key reuse it is carried in the signed
announce app-data (see ``build_announce_app_data``), which peers verify against the
Archy ed25519 identity and then bind onto the contact's stable ``arch_pubkey_hex``.
"""
from __future__ import annotations
import hashlib
import hmac
# HKDF info labels — changing these changes every node's RNS address, so they are
# part of the wire contract. Do not edit without a migration.
_INFO_X25519 = b"archipelago/reticulum/x25519/v1"
_INFO_ED25519 = b"archipelago/reticulum/ed25519/v1"
_HKDF_SALT = b"archipelago-reticulum-identity-v1"
def _hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
"""RFC 5869 HKDF-SHA256 (extract + expand) for one output block (length <= 32)."""
if length > 32:
raise ValueError("this helper only emits up to one SHA-256 block")
prk = hmac.new(_HKDF_SALT, ikm, hashlib.sha256).digest() # extract
okm = hmac.new(prk, info + b"\x01", hashlib.sha256).digest() # expand (T(1))
return okm[:length]
def rns_private_blob(archy_ed25519_seed: bytes) -> bytes:
"""Return the 64-byte RNS private blob (x25519_priv || ed25519_priv).
``archy_ed25519_seed`` is the raw 32 bytes of ``identity_dir/node_key``.
"""
if len(archy_ed25519_seed) != 32:
raise ValueError(f"expected a 32-byte Archy ed25519 seed, got {len(archy_ed25519_seed)}")
x25519_priv = _hkdf_sha256(archy_ed25519_seed, _INFO_X25519, 32)
ed25519_priv = _hkdf_sha256(archy_ed25519_seed, _INFO_ED25519, 32)
return x25519_priv + ed25519_priv
def load_identity(archy_ed25519_seed: bytes):
"""Build an ``RNS.Identity`` deterministically from the Archy seed.
Imported lazily so this module (and the determinism unit test) can be reasoned
about without RNS installed; the daemon imports it after the venv is present.
"""
import RNS # noqa: PLC0415 — lazy by design
blob = rns_private_blob(archy_ed25519_seed)
identity = RNS.Identity(create_keys=False)
identity.load_private_key(blob)
return identity
def lxmf_destination_hash(archy_ed25519_seed: bytes) -> bytes:
"""The 16-byte LXMF *delivery* destination hash for this node's identity.
Uses the static ``Destination.hash`` so we can derive the address without a
running Reticulum/Transport instance (the daemon computes this at startup,
before bringing interfaces up).
"""
import RNS # noqa: PLC0415
identity = load_identity(archy_ed25519_seed)
return RNS.Destination.hash(identity, "lxmf", "delivery")