From 176a60e9a7309bebddd2dcd99a092dc4317eccba Mon Sep 17 00:00:00 2001 From: Herklos Date: Tue, 28 Jul 2026 21:49:51 +0200 Subject: [PATCH] [Node] Migrate pairing from passphrase to password Signed-off-by: Herklos --- .../src/lib/__tests__/pairing.test.ts | 26 +++++++++++++++++ .../node_web_interface/src/lib/pairing.ts | 28 +++++++++++++++++-- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/pairing.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/pairing.test.ts index c4f95d8e8..14dbfd934 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/pairing.test.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/pairing.test.ts @@ -2,10 +2,36 @@ import { describe, expect, it } from "vitest" import { buildOriginWithHostname, + pairingSecretFromWallet, resolveExternalHostUrl, resolvePairingNodeHostname, } from "@/lib/pairing" +describe("pairingSecretFromWallet", () => { + const KEY = "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318" + const SEED = + "legal winner thank year wave sausage worth useful legal winner thank yellow" + + it("prefers the mnemonic when the wallet has one", () => { + expect(pairingSecretFromWallet({ private_key: KEY, seed: SEED })).toBe(SEED) + }) + + it("0x-prefixes a raw private key, which storage saves bare", () => { + // Without the prefix a scanner reads the key as a seed phrase and rejects + // it, so every mnemonic-less node would fail to pair. + expect(pairingSecretFromWallet({ private_key: KEY })).toBe(`0x${KEY}`) + }) + + it("leaves an already-prefixed key alone", () => { + expect(pairingSecretFromWallet({ private_key: `0x${KEY}` })).toBe(`0x${KEY}`) + }) + + it("falls back to the key when seed is null or empty", () => { + expect(pairingSecretFromWallet({ private_key: KEY, seed: null })).toBe(`0x${KEY}`) + expect(pairingSecretFromWallet({ private_key: KEY, seed: "" })).toBe(`0x${KEY}`) + }) +}) + describe("buildOriginWithHostname", () => { it("replaces hostname while preserving protocol and port", () => { expect( diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/pairing.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/pairing.ts index ba9869793..2e3e5ca7e 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/pairing.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/pairing.ts @@ -1,6 +1,7 @@ import { SetupService } from "@/client" import { loadPassword } from "@/lib/device-key" import { fetchNodeConfig } from "@/lib/node-config" +import { fetchOwnWalletExport, type WalletExportData } from "@/lib/wallet-export" export function buildOriginWithHostname(origin: string, hostname: string): string { const url = new URL(origin) @@ -60,18 +61,39 @@ export async function resolvePairingNodeUrl(): Promise { return resolvePairingNodeHostname(vpnIp, localIp, window.location.origin) } +/** The secret a scanner should import. + * + * `seed` wins when there is one: a mnemonic restores anywhere, and a node set + * up from a raw key has none to give. + * + * The `0x` prefix is not cosmetic. Wallet storage strips it before saving + * (`WalletEntry.private_key = private_key.removeprefix("0x")`), so the export + * hands back bare hex, and the scanner has nothing but the value's shape to + * tell a key from a phrase by. Unprefixed, a private key reads as a seed + * phrase and fails to import. */ +export function pairingSecretFromWallet(wallet: WalletExportData): string { + if (wallet.seed) return wallet.seed + return wallet.private_key.startsWith("0x") + ? wallet.private_key + : `0x${wallet.private_key}` +} + +/** The QR carries the wallet itself, not a way to ask this node for it: the + * export happens here, while the browser still holds an authenticated session. + * A phone that scans it can restore the account with the node switched off. */ export async function buildPairingQrValue() { const address = localStorage.getItem("auth_username") || "" - const passphrase = (await loadPassword()) ?? "" - if (!address || !passphrase) { + const password = (await loadPassword()) ?? "" + if (!address || !password) { throw new Error( "No active wallet session — log out and back in to refresh device key.", ) } const url = await resolvePairingNodeUrl() + const wallet = await fetchOwnWalletExport() return JSON.stringify({ url, address, - passphrase, + password: pairingSecretFromWallet(wallet), }) }