Files
archy/docs/companion-backup-restore.md
T
Dorian 22f8129b52 feat(companion): backup & restore + NIP-46 remote signer — 0.5.28 (#128, #139)
Companion 0.5.28 (versionCode 48), the companion-agent queue items:

#128 Backup & Restore — the phone side of losing your phone or wiping it
to cross a border. Hub card → SAF export/import of an encrypted .json:
everything the app holds (servers+passwords, FIPS identity/peers, signer
key) sealed in the node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305,
native backup.rs — same blob layout as the node's, node-shaped envelopes
decrypt too). Restore is merge-only: servers upsert npub-first, identity
and signer key adopt only when absent, peers union by npub. No cloud, no
telemetry — the file goes wherever the user saves it.

#139 Remote Signer — the phone IS the NIP-46 bunker. Generate/import a
nostr key, scan a nostrconnect:// QR (in-app scanner or deep link), and
approve/deny each sign_event request from a legible card (kind label,
content, tags, time) — nothing signs without a human. Wire-faithful to
rust-nostr's reference bunker (connect-carrying-secret handshake, NIP-44
v2 transport with NIP-04 receive fallback, kind-24133 responses);
get_public_key/describe/ping handled, everything else 'not authorized'.
Session state in BunkerManager, UI in SignerScreen, hub card wired.

Plus NativeCore (JNI object for the new native surface), FipsPreferences
peers-merge for restore, nostrconnect:// intent filter, and the release
docs (companion-backup-restore.md, companion-nip46-remote-signer.md).

Also Android/tools/nip46-test-client.py: a pure-Python NIP-46 client that
plays the node's login role (QR, handshake, get_public_key, sign_event)
and verifies the phone's signature with an independent BIP-340 — the
end-to-end test for the feature until node-side lands. Its crypto matches
the official NIP-44 + BIP-340 vectors byte-for-byte, the same vectors the
Rust core passes, so the two interop by construction.

Built + smoke: assembleDebug v0.5.28/vc48, same signing cert as the
served 0.5.27 (d622e07e…644d) so it updates in place.
2026-08-31 13:53:52 +01:00

4.0 KiB

Companion backup & restore — the phone side of a border crossing (#128)

Status: shipped in companion 0.5.28 (vc48). Issue: #128 ("Graphene phone backup/restore — part of the companion app or passport prime combo").

The problem

The companion holds real secrets: node addresses and login passwords, the phone's FIPS mesh identity (which nodes peer with), and — since 0.5.28 — the remote-signer key. Losing the phone, or wiping it to cross a border, loses all of it. On GrapheneOS there is no cloud backup and there should be none here either: the export is a plain file the user saves wherever they choose (USB drive, computer, a folder synced their way), sealed with a passphrase.

The envelope — the node's, not a second format

Backups use the node's ADR-005 encrypted-backup envelope (core/archipelago/src/backup/identity.rs), byte-for-byte:

  • Argon2id key derivation (RustCrypto argon2, default params — same as the node's Argon2::default()), passphrase in, 16-byte random salt.
  • ChaCha20-Poly1305 AEAD with a 12-byte random nonce.
  • Envelope JSON: {"version": 1, "kind": "companion", "encrypted": true, "blob": "<base64(salt‖nonce‖ct)>", "timestamp": "<rfc3339>"}
  • The native code (Android/rust/archy-fips-core/src/backup.rs) is the same crate family as the node's backup code; decrypt ignores unknown envelope fields, so a node identity backup (which carries did/pubkey/kid) also decrypts here — one envelope, two producers.

The encrypted payload is the companion's own JSON:

{
  "app": "archipelago-companion",
  "payloadVersion": 1,
  "appVersion": "0.5.28",
  "createdAt": 1725100000,
  "servers": ["<serialized ServerEntry>", …],
  "active": "<serialized ServerEntry or null>",
  "fips": {"secret","npub","address","peers","partyPeers","partyName","partyListen"},
  "signer": {"secret": "<hex>"},
  "flags": {"introSeen": true}
}

Where the code lives

  • Crypto: Android/rust/archy-fips-core/src/backup.rs (+ JNI NativeCore.backupEncrypt/Decrypt). Host cargo test covers round-trip, wrong-passphrase, tampered-blob, node-shape envelopes, and salt/nonce freshness.
  • Payload/merge: BackupManager (Android/app/src/main/java/com/archipelago/app/data/BackupManager.kt).
  • UI: hub menu (three-finger) → Backup & Restore → SAF file picker (CreateDocument for export, OpenDocument for import), passphrase fields, verified-backup preview, result summary. The suggested export name is archy-companion-backup-YYYYMMDD-HHmmss.json.

Restore semantics — never silently destructive

What On restore
Servers Upsert (ServerPreferences.upsertServer): same npub merges (even when every address changed), new ones append
Active server Set only when this phone has none (the fresh-wipe case)
FIPS identity Restored only when this phone has none; node peers UNION by npub (FipsPreferences.mergePeersJson); party peers merge by npub
Signer key Restored only when this phone has none
introSeen flag Restored (no re-onboarding after a restore)

The identity rules exist because a phone that already paired has a live mesh identity nodes peer with; swapping it in from a backup would strand the current pairing.

Test checklist (on-device)

  • Export → file saved, version: 1, kind: companion, base64 blob ≥ 44 chars.
  • Wrong passphrase on import → "wrong passphrase" error, no state change.
  • Correct passphrase → preview shows the right server count; restore on a second install (or after clearing app data) reconnects to the node without re-pairing, mesh included.
  • Re-scan the node's QR after restore → no duplicate entry.
  • The old phone's password for a node restores (login works on the new phone).

Roadmap notes (node-side, tracked separately)

Node-side storage/quota/scheduling for companion backups ("passport prime combo") is roadmap territory — this issue's scope was the phone side. The envelope is ready to be a drop-in for the node's existing backup RPCs when that lands.