archy/docs/SEED-VERIFICATION.md
Dorian 1e283daf13 fix: overhaul container lifecycle — recovery, health, uninstall, UI state
Container recovery:
- Health monitor: MAX_RESTART_ATTEMPTS 3→10, interval 60s→120s
- Dependency-aware restarts: won't restart services before their deps
- Reset dependent counters when a dependency recovers
- Handle "created" state containers (were invisible to health monitor)
- Added IndeedHub, mempool-api, mysql to tier system
- Crash recovery: podman start timeout 30s→120s with retry
- Podman client: socket timeout 5s→30s, added restart policy

UI state representation:
- Exit code 0 shows "stopped" (gray), not "crashed" (red)
- Exit code 137 shows "killed (OOM)"
- Non-zero exit shows "crashed" (red)
- Added exit_code field to PackageDataEntry

Install/uninstall fixes:
- Install returns error when container doesn't start (was silent success)
- Post-install hooks awaited instead of fire-and-forget tokio::spawn
- Uninstall: graceful rm before force, volume prune, network cleanup
- Uninstall returns error on partial failure (was 200 OK)

Config consistency:
- DB passwords read from /var/lib/archipelago/secrets/ (was hardcoded)
- Bitcoin: added ZMQ ports 28332/28333 for LND block notifications
- IndeedHub port 7777→8190 (was conflicting with strfry)
- Marketplace versions: LND 0.17.4→0.18.4, Mempool 2.5.0→3.0.0

Performance:
- Metrics collector interval 60s→300s (was duplicating health monitor)
- Podman client: proper error propagation instead of unwrap_or_default

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 07:03:57 +01:00

15 KiB

Archipelago Seed Verification

Independently verify that your 24-word BIP-39 mnemonic produces the correct Nostr keys and DID identifiers — using only standard cryptographic primitives, no Archipelago code.

24-word mnemonic
      |
      v
PBKDF2-HMAC-SHA512 (2048 rounds, salt = "mnemonic")
      |
      v
64-byte master seed
      |
      +-- HKDF-SHA256 (info="archipelago/node/ed25519/v1")
      |       --> Node Ed25519 keypair --> did:key:z...
      |
      +-- HKDF-SHA256 (info="archipelago/nostr-node/secp256k1/v1")
      |       --> Node Nostr key --> npub1...
      |
      +-- HKDF-SHA256 (info="archipelago/identity/{i}/ed25519/v1")
      |       --> Identity[i] Ed25519 --> did:key:z...
      |
      +-- BIP-32 m/44'/1237'/0'/0/{i}  (NIP-06)
      |       --> Identity[i] Nostr key --> npub1...
      |
      +-- BIP-32 m/84'/0'/0'
      |       --> Bitcoin HD wallet
      |
      +-- HKDF-SHA256 (info="archipelago/lnd/entropy/v1")
              --> 16 bytes LND aezeed entropy

Source: core/archipelago/src/seed.rs and core/archipelago/src/identity.rs


Setup

pip3 install cryptography ecdsa

Two packages, both pure crypto, no network calls. Python 3.9+.


The Verification Script

Save as verify-seed.py and run with your mnemonic:

MNEMONIC="word1 word2 ... word24" python3 verify-seed.py
#!/usr/bin/env python3
"""
Archipelago seed derivation verifier.

Re-derives every key from a BIP-39 mnemonic using the exact same algorithms
as the Rust backend (seed.rs), so you can compare outputs independently.

Dependencies: cryptography, ecdsa  (pip3 install cryptography ecdsa)
No network calls. No file writes. Safe to run air-gapped.
"""

import hashlib, hmac, os, sys

# ── BIP-39: mnemonic --> 64-byte master seed ─────────────────────────────

def mnemonic_to_seed(mnemonic: str) -> bytes:
    """PBKDF2-HMAC-SHA512, 2048 rounds, salt = 'mnemonic', no passphrase."""
    return hashlib.pbkdf2_hmac(
        "sha512",
        mnemonic.encode("utf-8"),
        b"mnemonic",  # BIP-39 salt prefix + empty passphrase
        2048,
    )

# ── HKDF-SHA256 (RFC 5869) ──────────────────────────────────────────────

def hkdf_sha256(ikm: bytes, info: bytes, length: int = 32) -> bytes:
    """
    HKDF-Extract(salt=None, ikm) then HKDF-Expand(PRK, info, L).
    Salt=None means 32 zero bytes per RFC 5869 section 2.2.
    Matches: hkdf::Hkdf::<Sha256>::new(None, ikm).expand(info, &mut okm)
    """
    # Extract
    prk = hmac.new(b"\x00" * 32, ikm, hashlib.sha256).digest()
    # Expand (32 bytes = 1 block, only T(1) needed)
    t1 = hmac.new(prk, info + b"\x01", hashlib.sha256).digest()
    return t1[:length]

# ── Ed25519 ──────────────────────────────────────────────────────────────

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat

def ed25519_keypair(secret_32: bytes) -> tuple[bytes, bytes]:
    """Returns (private_32, public_32) from a 32-byte seed."""
    sk = Ed25519PrivateKey.from_private_bytes(secret_32)
    pk = sk.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
    return secret_32, pk

# ── secp256k1 ────────────────────────────────────────────────────────────

from ecdsa import SECP256k1, SigningKey as ECDSASigningKey

def secp256k1_xonly(secret_32: bytes) -> bytes:
    """32-byte x-only pubkey (Schnorr/Nostr format) from private key bytes."""
    sk = ECDSASigningKey.from_string(secret_32, curve=SECP256k1)
    point = sk.get_verifying_key().pubkey.point
    return point.x().to_bytes(32, "big")

# ── BIP-32 HD derivation (secp256k1) ────────────────────────────────────

import struct

SECP256K1_N = SECP256k1.order

def _bip32_master(seed: bytes) -> tuple[bytes, bytes]:
    """BIP-32 master key: HMAC-SHA512(key='Bitcoin seed', data=seed)."""
    I = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()
    return I[:32], I[32:]  # (secret, chain_code)

def _bip32_ckd(key: bytes, chain: bytes, index: int) -> tuple[bytes, bytes]:
    """Child key derivation (private -> private)."""
    if index >= 0x80000000:
        data = b"\x00" + key + struct.pack(">I", index)
    else:
        # Compressed pubkey for non-hardened
        sk = ECDSASigningKey.from_string(key, curve=SECP256k1)
        pt = sk.get_verifying_key().pubkey.point
        prefix = b"\x02" if pt.y() % 2 == 0 else b"\x03"
        data = prefix + pt.x().to_bytes(32, "big") + struct.pack(">I", index)

    I = hmac.new(chain, data, hashlib.sha512).digest()
    child = (int.from_bytes(I[:32], "big") + int.from_bytes(key, "big")) % SECP256K1_N
    return child.to_bytes(32, "big"), I[32:]

def bip32_derive(seed: bytes, path: str) -> bytes:
    """
    Derive private key for a BIP-32 path like 'm/44h/1237h/0h/0/0'.
    Matches: bitcoin::bip32::Xpriv::new_master + derive_priv
    """
    key, chain = _bip32_master(seed)
    for part in path.lstrip("m/").split("/"):
        hardened = part.endswith("'") or part.endswith("h")
        idx = int(part.rstrip("'h"))
        if hardened:
            idx += 0x80000000
        key, chain = _bip32_ckd(key, chain, idx)
    return key

# ── Bech32 encoding (NIP-19: npub / nsec) ───────────────────────────────

_BECH32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"

def _bech32_polymod(values):
    GEN = [0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3]
    chk = 1
    for v in values:
        b = chk >> 25
        chk = ((chk & 0x1FFFFFF) << 5) ^ v
        for i in range(5):
            chk ^= GEN[i] if ((b >> i) & 1) else 0
    return chk

def bech32_encode(hrp: str, data: bytes) -> str:
    """Bech32 encode (NIP-19 for npub1.../nsec1...)."""
    # Convert 8-bit to 5-bit
    acc, bits, vals = 0, 0, []
    for byte in data:
        acc = (acc << 8) | byte
        bits += 8
        while bits >= 5:
            bits -= 5
            vals.append((acc >> bits) & 31)
    if bits:
        vals.append((acc << (5 - bits)) & 31)
    # Checksum
    hrp_exp = [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
    polymod = _bech32_polymod(hrp_exp + vals + [0]*6) ^ 1
    checksum = [(polymod >> 5*(5-i)) & 31 for i in range(6)]
    return hrp + "1" + "".join(_BECH32[d] for d in vals + checksum)

# ── did:key encoding ────────────────────────────────────────────────────

_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"

def base58_encode(data: bytes) -> str:
    n = int.from_bytes(data, "big")
    result = ""
    while n > 0:
        n, r = divmod(n, 58)
        result = _B58[r] + result
    for b in data:
        if b == 0:
            result = "1" + result
        else:
            break
    return result

def to_did_key(ed25519_pub_32: bytes) -> str:
    """did:key:z<base58btc(0xed01 + pubkey)>  — W3C did:key method, Ed25519."""
    return "did:key:z" + base58_encode(b"\xed\x01" + ed25519_pub_32)

# ── Main ─────────────────────────────────────────────────────────────────

def main():
    mnemonic = os.environ.get("MNEMONIC", "").strip()
    if not mnemonic:
        print("Enter your 24-word mnemonic (space-separated):")
        mnemonic = input("> ").strip()

    words = mnemonic.split()
    if len(words) != 24:
        print(f"Error: expected 24 words, got {len(words)}", file=sys.stderr)
        sys.exit(1)

    seed = mnemonic_to_seed(mnemonic)

    W = 72
    print()
    print("=" * W)
    print("  ARCHIPELAGO SEED DERIVATION VERIFICATION")
    print("=" * W)
    print()
    print(f"  Seed fingerprint (SHA-256):  {hashlib.sha256(seed).hexdigest()[:16]}...")
    print(f"  Seed length:                 {len(seed)} bytes")

    # ── 1. Node Ed25519 + DID ────────────────────────────────────────────

    print()
    print("-" * W)
    print("  1. NODE ED25519 KEY")
    print(f"     HKDF-SHA256(seed, info='archipelago/node/ed25519/v1')")
    print("-" * W)

    node_ed_priv, node_ed_pub = ed25519_keypair(
        hkdf_sha256(seed, b"archipelago/node/ed25519/v1")
    )
    node_did = to_did_key(node_ed_pub)

    print(f"  Private:  {node_ed_priv.hex()}")
    print(f"  Public:   {node_ed_pub.hex()}")
    print(f"  did:key:  {node_did}")

    # ── 2. Node Nostr key ────────────────────────────────────────────────

    print()
    print("-" * W)
    print("  2. NODE NOSTR KEY")
    print(f"     HKDF-SHA256(seed, info='archipelago/nostr-node/secp256k1/v1')")
    print("-" * W)

    node_nostr_priv = hkdf_sha256(seed, b"archipelago/nostr-node/secp256k1/v1")
    node_nostr_pub = secp256k1_xonly(node_nostr_priv)

    print(f"  Private:  {node_nostr_priv.hex()}")
    print(f"  X-only:   {node_nostr_pub.hex()}")
    print(f"  nsec:     {bech32_encode('nsec', node_nostr_priv)}")
    print(f"  npub:     {bech32_encode('npub', node_nostr_pub)}")

    # ── 3. Identity[0..2] Ed25519 + DID ─────────────────────────────────

    print()
    print("-" * W)
    print("  3. IDENTITY ED25519 KEYS + DID")
    print(f"     HKDF-SHA256(seed, info='archipelago/identity/{{i}}/ed25519/v1')")
    print("-" * W)

    for i in range(3):
        info = f"archipelago/identity/{i}/ed25519/v1".encode()
        priv, pub = ed25519_keypair(hkdf_sha256(seed, info))
        did = to_did_key(pub)
        print(f"  [{i}] Public:   {pub.hex()}")
        print(f"      did:key:  {did}")

    # ── 4. Identity[0..2] Nostr (NIP-06 BIP-32) ────────────────────────

    print()
    print("-" * W)
    print("  4. IDENTITY NOSTR KEYS (NIP-06)")
    print(f"     BIP-32  m/44'/1237'/0'/0/{{i}}")
    print("-" * W)

    for i in range(3):
        priv = bip32_derive(seed, f"m/44'/1237'/0'/0/{i}")
        pub = secp256k1_xonly(priv)
        print(f"  [{i}] X-only:   {pub.hex()}")
        print(f"      nsec:     {bech32_encode('nsec', priv)}")
        print(f"      npub:     {bech32_encode('npub', pub)}")

    # ── 5. Bitcoin BIP-84 ───────────────────────────────────────────────

    print()
    print("-" * W)
    print("  5. BITCOIN WALLET (BIP-84)")
    print(f"     BIP-32  m/84'/0'/0'")
    print("-" * W)

    btc_acct = bip32_derive(seed, "m/84'/0'/0'")
    btc_pub = secp256k1_xonly(btc_acct)
    print(f"  Account key:  {btc_acct.hex()}")
    print(f"  Account pub:  {btc_pub.hex()}")

    # ── 6. LND Entropy ──────────────────────────────────────────────────

    print()
    print("-" * W)
    print("  6. LND AEZEED ENTROPY")
    print(f"     HKDF-SHA256(seed, info='archipelago/lnd/entropy/v1')  [16 bytes]")
    print("-" * W)

    lnd = hkdf_sha256(seed, b"archipelago/lnd/entropy/v1", 16)
    print(f"  Entropy:  {lnd.hex()}")

    # ── Done ─────────────────────────────────────────────────────────────

    print()
    print("=" * W)
    print("  Compare these values with your Archipelago node:")
    print("    UI:  Settings > Identity")
    print("    SSH: xxd -p /var/lib/archipelago/identity/node_key.pub")
    print("    RPC: curl -s http://<ip>/api/rpc \\")
    print("           -d '{\"method\":\"identity.get-node\"}' | jq .")
    print("=" * W)
    print()

if __name__ == "__main__":
    main()

How to Run

# Install (two packages, pure crypto, no telemetry)
pip3 install cryptography ecdsa

# Option A: environment variable (doesn't persist in shell history)
read -rs MNEMONIC && export MNEMONIC
# (type or paste your 24 words, press Enter)
python3 verify-seed.py
unset MNEMONIC

# Option B: interactive prompt
python3 verify-seed.py
# Enter your 24-word mnemonic (space-separated):
# > abandon abandon ... art

What to Compare

Output field Where to find on your node
Node Ed25519 public xxd -p /var/lib/archipelago/identity/node_key.pub
Node did:key Settings > Identity > Node DID
Node npub Settings > Identity > Nostr Public Key
Identity[0] did:key Settings > Identity > first identity DID
Identity[0] npub Settings > Identity > first identity Nostr key

RPC alternative (from any machine on the LAN):

# Node identity
curl -s http://192.168.1.228/api/rpc \
  -H 'Content-Type: application/json' \
  -d '{"method":"identity.get-node"}' | jq .

# All identities
curl -s http://192.168.1.228/api/rpc \
  -H 'Content-Type: application/json' \
  -d '{"method":"identity.list"}' | jq .

Cryptographic Reference

HKDF-SHA256 (RFC 5869)

Used for Ed25519 and node-level Nostr keys. Domain separation via unique info strings prevents key reuse across contexts.

Extract:  PRK = HMAC-SHA256(salt=0x00*32, ikm=64_byte_seed)
Expand:   OKM = HMAC-SHA256(PRK, info || 0x01)  [first 32 bytes]

The Rust backend uses hkdf::Hkdf::<Sha256>::new(None, ikm) where None salt = 32 zero bytes.

BIP-32 (secp256k1 HD derivation)

Used for per-identity Nostr keys (NIP-06) and Bitcoin wallet.

Master:   HMAC-SHA512(key="Bitcoin seed", data=64_byte_seed)
Child:    HMAC-SHA512(key=chain_code, data=0x00||key||index)  [hardened]
          HMAC-SHA512(key=chain_code, data=pubkey||index)     [normal]

The Rust backend uses the bitcoin crate: Xpriv::new_master() + derive_priv().

did:key (W3C)

did:key:z  +  base58btc( 0xED 0x01 || 32_byte_ed25519_pubkey )

Multicodec prefix 0xED 0x01 identifies Ed25519 public keys. The Rust backend uses bs58::encode() over a 34-byte buffer.

NIP-19 Bech32 (npub/nsec)

npub1...  =  bech32(hrp="npub", data=32_byte_x_only_pubkey)
nsec1...  =  bech32(hrp="nsec", data=32_byte_private_key)

X-only pubkey = just the x-coordinate of the secp256k1 point (Schnorr format).


Security

  • Run on an air-gapped machine or at minimum a private terminal session
  • The script makes zero network calls and writes zero files
  • After verification, clean up:
    rm verify-seed.py
    unset MNEMONIC
    history -c  # bash
    # or: fc -W /dev/null  # zsh
    
  • Never paste your mnemonic into a web tool, online REPL, or shared terminal