diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dc1fa1f7..bee04173a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Fixes +* [FIX][all] **Activating a device key on a migrated Guardian account no longer permanently breaks its sync.** After a structural rotation (`update_signers` for replace-hot-key, or the follow-up `update_procedure_threshold`), the wallet now pushes the new on-chain state back to the guardian. The OpenZeppelin multisig client only re-registers post-execution state on the guardian for `switch_guardian`; for the other structural ops it submitted the transaction on-chain but left the guardian serving the pre-rotation blob, so every subsequent ~3s guardian sync saw guardian-commitment ≠ on-chain and threw on `ensureSafeToOverwriteLocalState` — permanently, until a full reinstall. The re-registration runs before the hot-key pointer swap arms the sync, and the guardian sync now self-heals a lagging guardian by re-registering the current state once per run (which also recovers already-broken accounts without a reinstall). The legacy-Guardian migration additionally verifies the re-derived cold key matches the on-chain signer before flagging an account for activation. (#227) +* [FIX][all] **Guardian sync no longer spams "missing hotPublicKey" errors for un-activated accounts.** `syncGuardianAccounts` now only syncs Guardian accounts that actually carry a hot key, instead of every account not flagged `requiresHotKeyRotation`. A legacy single-signer Guardian record that hasn't been migrated yet — e.g. in the brief window after a wallet upgrade (new code, old storage) and before the forced re-unlock runs the migration — has no hot key, so `getOrCreateMultisigService` threw on it every ~3s AutoSync tick. There's genuinely no hot-bound service to build for such accounts, so they're skipped; recovery still happens via the migration → Activate Device Key banner path on the next unlock. (#227) +* [FIX][all] **Guardian operator endpoint is now tracked per account, and structural Guardian ops recover from a submit-then-local-apply failure.** Each Guardian account persists its own `guardianEndpoint` (set at create/recovery, updated on switch-guardian) instead of a single global setting, so two Guardian accounts on different operators no longer collide — older records without the field fall back to the legacy global value. Separately, when a `replace-hot-key` or `switch-guardian` transaction lands on chain but the local apply step throws, the wallet now runs the same finalization the happy path would (swapping the hot-key pointer, or re-registering on the new guardian and persisting its endpoint) instead of cancelling — which previously stranded the account signing with a rotated-out key or talking to the old guardian. (#227) * [FIX][all] **Guardian co-sign transactions are now serialized per account.** The Guardian co-signs one delta per account at a time, so concurrent same-account transactions (e.g. auto-consume racing a user claim, or rapid successive claims) made the Guardian's expected commitment diverge from on-chain — stalling its canonicalization for minutes and returning `409 ConflictPendingDelta` while a prior delta was still finalizing, which surfaced as a Guardian claim/send that never completes. Guardian transactions now take a per-account lock so at most one is ever in flight, and proposal creation waits out a transient `409` (a prior delta mid-canonicalization) instead of failing the transaction. (#297) ## 1.15.2 (2026-06-22) @@ -33,6 +36,8 @@ ### Features +* [FEATURE][all] 3-key Guardian: cold co-signs `switch_guardian` (on-chain threshold-2 satisfied via `procedureThresholds`) and a new Settings → Rotate Device Key flow lets users replace the hot signer in-place via a single cold-signed `update_signers` proposal, persisting the new ciphertext before submission and finalizing `WalletAccount.hotPublicKey` (plus releasing the old SE/StrongBox wrapper) only after on-chain inclusion. Settings → Switch Guardian now requires an explicit confirmation step before initiating, mirroring the Rotate Device Key flow so the user acknowledges that the switch is cold-signed + guardian-co-signed. Guardian import-by-seed does lookup + adopt only (`MultisigClient.recoverByKey` per HD index until first miss); each adopted account is flagged `requiresHotKeyRotation` and the home view surfaces an Activate Device Key banner with a one-click CTA that fires the same cold-signed `update_signers` rotation — the banner self-hides once `Vault.swapHotKey` lands. The `swapHotKey` message now carries only `newHotPubKey`; the vault resolves the previous hot from the persisted `WalletAccount`, so the initial post-recovery activation and subsequent rotations share one code path. +* [FEATURE][all] 3-key Guardian accounts. Guardian creation now provisions a hot ECDSA key (random, lives outside the WASM keystore behind a new `secure-hot-key` facade — extension/desktop use a JS fallback; iOS wraps the secret under a per-account Secure Enclave P-256 key via `HotKeyPlugin.swift`; Android wraps it under a per-account StrongBox-backed RSA-OAEP key via `HotKeyPlugin.kt` with biometric-gated unwrap, identical wire format on both platforms) plus an HD-derived cold ECDSA key (kept in the SDK keystore for cold-routed flows) alongside the existing guardian co-signer. Threshold stays at 1 (hot OR cold + guardian); cold-only routing for `update_signers` / `update_guardian` / `update_procedure_threshold` is enforced client-side per the Phase 0 SDK reading. Storage adds `accColdSecretKeyStrgKey` and `WalletAccount.{hotPublicKey,coldPublicKey}` so role-aware `signWord` (Phase 3) can dispatch hot vs cold by storage entity. Hard cutover from the 1-of-1 Falcon scheme — pre-cutover Guardian accounts are unreachable from this build. * [FEATURE][all] Guardian integration. Adds Guardian-backed accounts (1-of-1 multisig with on-chain Guardian signature verification), onboarding create/import flows with a dedicated recovery-method screen that accepts a custom guardian URL, Settings → Guardian Settings with an on-chain switch-guardian proposal that re-registers post-switch state with the new endpoint, service-worker routing for Guardian transaction signing via `MultisigService`, frontend Guardian sync outside the WASM lock, and per-stage progress labels (`creating-proposal`, `signing-proposal`, `submitting`, `registering-guardian`) in the transaction modal. * [FEATURE][all] **Default auth scheme for new accounts switched from Falcon to ECDSA.** Existing accounts are unaffected — Miden seals the auth component at on-chain creation and can never rotate it, so any account previously created stays Falcon and continues signing with its existing keystore secret. Restore paths handle both schemes: mnemonic-only restore (`Vault.spawn`) probes the chain under each scheme to find the user's actual hdIndex=0 account; encrypted-file restore reads the new optional `authScheme` field on `WalletAccount` (legacy entries with no field default to Falcon, matching the historical default 1:1). Private-key import detects the scheme from the deserialized `AuthSecretKey` via the SDK's per-scheme accessor. New accounts created post-upgrade get ECDSA stamped into their `WalletAccount` record. Encrypted-file format change is purely additive — old files round-trip through restore as Falcon. (#229) diff --git a/CLAUDE.md b/CLAUDE.md index 9786ab0e6..b6c360065 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,12 @@ xcrun simctl spawn booted notifyutil -p com.apple.BiometricKit_Sim.fingerTouch.m - Grey bar at bottom → `100dvh` doesn't account for safe areas. Use `100%` + `env(safe-area-inset-*)` padding on `mobile.html` body. - Debug UI text should be `select-text` so errors are copyable. +### Adding Swift files to the App target +The App target in `ios/App/App.xcodeproj/project.pbxproj` does NOT auto-discover Swift files in the `App/` source directory. New files must be registered in four sections: `PBXBuildFile`, `PBXFileReference`, the App `PBXGroup` children, and the `PBXSourcesBuildPhase` files list. Pattern: see `LocalBiometricPlugin.swift` or `HotKeyPlugin.swift` entries. + +### Adding a custom Capacitor plugin (iOS) +Capacitor on this app uses **manual** registration — not the `CAPBridgedPlugin` auto-discovery you'd get on a stock Capacitor app. After creating `MyPlugin.swift` and wiring it into the four pbxproj sections above, you also have to call `bridge?.registerPluginInstance(MyPlugin())` inside `capacitorDidLoad()` in `ios/App/App/AppViewController.swift`. Skip this step and JS calls land as `{"code":"UNIMPLEMENTED"}` even though the class compiled fine. + ### Native navbar overlay Mobile hides React footer and renders bottom nav as native pill (iOS: `MidenNavbarOverlayWindow` `UIWindow`; Android: two-instance `NavbarOverlayManager` with Activity-scoped + Dialog-scoped `NavbarView`). Plugin methods: `showNativeNavbar`, `setNavbarSecondaryRow`, `setNavbarAction`, `morphNavbar{Out,In}`. Events: `nativeNavbarTap`, `nativeNavbarSecondaryTap`, `nativeNavbarActionTap`. Wiring: `src/app/providers/DappBrowserProvider.tsx`. Android gotchas: don't use `MATCH_PARENT` children in `WRAP_CONTENT` parents (1878px buttons); `Dialog.setLayout` must follow `setContentView`; shadow must be on the view owning the background drawable. diff --git a/__mocks__/@openzeppelin/miden-multisig-client.ts b/__mocks__/@openzeppelin/miden-multisig-client.ts index 5fccf4855..f13d9f0f5 100644 --- a/__mocks__/@openzeppelin/miden-multisig-client.ts +++ b/__mocks__/@openzeppelin/miden-multisig-client.ts @@ -59,6 +59,16 @@ export const buildMultisigStorageSlots = jest.fn(); export const buildGuardianStorageSlots = jest.fn(); export const storageLayoutBuilder = jest.fn(); +// Update-signers + summary builders used by createReplaceHotKeyProposal. The +// real implementations touch WASM; tests mock-or-spy as needed. +export const buildUpdateSignersTransactionRequest = jest.fn(async () => ({ + request: { kind: 'update-signers-request' }, + salt: { toHex: () => 'salt-hex' } +})); +export const executeForSummary = jest.fn(async () => ({ + serialize: () => new Uint8Array([0xab]) +})); + export class StorageLayoutBuilder { constructor(..._args: unknown[]) {} } diff --git a/__mocks__/lib/miden/back/vault.ts b/__mocks__/lib/miden/back/vault.ts index f00fe9888..ee4cc118d 100644 --- a/__mocks__/lib/miden/back/vault.ts +++ b/__mocks__/lib/miden/back/vault.ts @@ -43,6 +43,10 @@ export class Vault { return state.accounts; } + // Best-effort on-unlock migration of legacy single-key Guardian accounts to + // the 3-key model — a no-op in the mock (no real accounts/keys to migrate). + async migrateLegacyGuardianAccounts() {} + async fetchSettings() { return state.settings; } diff --git a/android/.idea/deploymentTargetSelector.xml b/android/.idea/deploymentTargetSelector.xml index b268ef36c..ca16a995c 100644 --- a/android/.idea/deploymentTargetSelector.xml +++ b/android/.idea/deploymentTargetSelector.xml @@ -4,6 +4,7 @@ diff --git a/android/app/build.gradle b/android/app/build.gradle index 13bfcd46c..ca63c97b4 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -65,6 +65,10 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" // Biometric authentication for hardware security implementation 'androidx.biometric:biometric:1.1.0' + // secp256k1 ECDSA + Keccak-256 for the Guardian hot-key signing path + // (HotKeyPlugin). Keystore wraps the 32-byte k256 secret with RSA-OAEP; + // BouncyCastle handles everything outside the Keystore boundary. + implementation 'org.bouncycastle:bcprov-jdk18on:1.78.1' } apply from: 'capacitor.build.gradle' diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 4c549e886..b2835525e 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -70,6 +70,12 @@ -keep class com.niceforyou.capacitor.barcodescanner.** { *; } -keep class com.google.mlkit.vision.barcode.** { *; } +# BouncyCastle (secp256k1 + Keccak-256) — used by HotKeyPlugin for the +# Guardian hot-key signing path. Keep the low-level crypto + asn1 classes +# we touch reflectively via JCE provider lookup paths. +-keep class org.bouncycastle.** { *; } +-keep class com.miden.wallet.HotKeyPlugin { *; } + # Don't warn about missing classes in optional dependencies -dontwarn org.conscrypt.** -dontwarn org.bouncycastle.** diff --git a/android/app/src/main/java/com/miden/wallet/HotKeyPlugin.kt b/android/app/src/main/java/com/miden/wallet/HotKeyPlugin.kt new file mode 100644 index 000000000..627c6f1c4 --- /dev/null +++ b/android/app/src/main/java/com/miden/wallet/HotKeyPlugin.kt @@ -0,0 +1,578 @@ +package com.miden.wallet + +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.StrongBoxUnavailableException +import android.util.Base64 +import android.util.Log +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import org.bouncycastle.asn1.sec.SECNamedCurves +import org.bouncycastle.crypto.digests.SHA256Digest +import org.bouncycastle.crypto.params.ECDomainParameters +import org.bouncycastle.crypto.params.ECPrivateKeyParameters +import org.bouncycastle.crypto.signers.ECDSASigner +import org.bouncycastle.crypto.signers.HMacDSAKCalculator +import org.bouncycastle.jcajce.provider.digest.Keccak +import org.bouncycastle.math.ec.ECAlgorithms +import org.bouncycastle.math.ec.ECPoint +import java.math.BigInteger +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import java.security.SecureRandom +import java.security.spec.MGF1ParameterSpec +import javax.crypto.Cipher +import javax.crypto.spec.OAEPParameterSpec +import javax.crypto.spec.PSource + +// Per-account Guardian "hot" signing key (3-key migration, Phase 4b — Android +// counterpart of ios/App/App/HotKeyPlugin.swift). +// +// Storage layout mirrors iOS so the JS facade stays platform-agnostic: +// - Per-account RSA-2048 key in Android Keystore aliased +// "com.miden.wallet.hot.", StrongBox-backed when available, +// auth-bound on the private key only (encrypt with the public key needs +// no auth, decrypt does — same shape as the iOS SE ECIES path). +// - Returned ciphertext is ":" so signWith / +// deleteWith can recover the alias from the blob alone. +// +// The k256 secret is wrapped with RSA-OAEP-SHA-256 because Android Keystore +// can't do EC-encrypt directly; iOS uses ECIES (the SE-native primitive). The +// wire format (`:`) and signature format (`0x`, +// 65 bytes hex) are identical across both platforms. +@CapacitorPlugin(name = "HotKey") +class HotKeyPlugin : Plugin() { + + companion object { + private const val TAG = "HotKey" + private const val ANDROID_KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS_PREFIX = "com.miden.wallet.hot." + private const val OAEP_TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding" + + // BC's secp256k1 domain parameters; reused by every sign call. + private val SECP256K1 = SECNamedCurves.getByName("secp256k1") + private val DOMAIN = ECDomainParameters(SECP256K1.curve, SECP256K1.g, SECP256K1.n, SECP256K1.h) + private val HALF_N: BigInteger = SECP256K1.n.shiftRight(1) + } + + private enum class PendingOp { SIGN, REVEAL } + + private var pendingCall: PluginCall? = null + private var pendingPayload: ByteArray? = null + private var pendingDigest: ByteArray? = null + private var pendingOp: PendingOp? = null + + @PluginMethod + fun generateHotKey(call: PluginCall) { + Log.d(TAG, "generateHotKey called") + + val secretBytes = ByteArray(32) + var alias: String? = null + try { + // 1. Random k256 secret + compressed (33-byte: 0x02/0x03 || x) public key. + // iOS returns the same 33-byte form; commitmentFromPublicKeyHex on the + // JS side rejects anything else. + SecureRandom().nextBytes(secretBytes) + val publicKeyHex = derivePublicKeyHex(secretBytes) + + // 2. 16-byte tag suffix → base64; full Keystore alias = prefix + suffix. + val tagSuffix = ByteArray(16) + SecureRandom().nextBytes(tagSuffix) + val tagSuffixB64 = Base64.encodeToString(tagSuffix, Base64.NO_WRAP) + alias = KEY_ALIAS_PREFIX + tagSuffixB64 + + // 3. Generate the per-account RSA wrapper key (StrongBox-preferred, + // auth-bound private). Public-key encrypt below needs no auth. + val wrapperPub = generateKeystoreWrapperKey(alias) + + // 4. RSA-OAEP-SHA-256 wrap the k256 secret. Same role as the iOS + // eciesEncryptionStandardX963SHA256AESGCM blob — opaque to JS. + val cipher = Cipher.getInstance(OAEP_TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, wrapperPub, oaepParams()) + val wrapped = cipher.doFinal(secretBytes) + + // 5. Pack into ":" so signWithHotKey can + // recover the alias from the ciphertext alone. + val packed = tagSuffixB64 + ":" + Base64.encodeToString(wrapped, Base64.NO_WRAP) + + val result = JSObject() + result.put("ciphertext", packed) + result.put("publicKeyHex", publicKeyHex) + Log.d(TAG, "generateHotKey success") + call.resolve(result) + } catch (e: Exception) { + Log.e(TAG, "generateHotKey failed: ${e.message}", e) + // Best-effort cleanup of the orphan Keystore key we may have just created. + alias?.let { deleteAliasQuietly(it) } + call.reject("Failed to generate hot key: ${e.message}") + } finally { + zero(secretBytes) + } + } + + /// Unwrap the hot-key secret inside the Keystore (triggers BiometricPrompt), + /// Keccak-256 the supplied 32-byte word, ECDSA-sign (recoverable) over + /// secp256k1, and return r||s||v as 0x-prefixed hex (65 bytes). The + /// unwrapped secret is zeroed before returning. + @PluginMethod + fun signWithHotKey(call: PluginCall) { + Log.d(TAG, "signWithHotKey called") + + // The BiometricPrompt callback reads instance fields (pendingCall/ + // pendingPayload/pendingDigest); a second concurrent call would clobber + // them, crossing signatures and responses. Reject while one is in flight. + if (pendingCall != null) { + call.reject("Another hot-key operation is in progress", "BIOMETRIC_BUSY") + return + } + + val ciphertext = call.getString("ciphertext") + val digestHex = call.getString("digestHex") + if (ciphertext == null || digestHex == null) { + call.reject("Missing 'ciphertext' or 'digestHex' parameter") + return + } + + try { + // 1. Split tag from payload. + val (alias, payload) = parseCiphertext(ciphertext) + + // 2. Decode the digest (caller passes it 0x-prefixed, matching + // Word.toHex()). Must be 32 bytes — Miden Words are 4 felts × 8. + val cleaned = if (digestHex.startsWith("0x")) digestHex.substring(2) else digestHex + val digestBytes = hexDecode(cleaned) + if (digestBytes.size != 32) { + call.reject("Hot-key digest must be 32 hex bytes") + return + } + + // 3. Look up the Keystore wrapper private key by alias. + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + val privateKey = keyStore.getKey(alias, null) as? PrivateKey + if (privateKey == null) { + Log.e(TAG, "signWithHotKey: Keystore key not found at $alias") + call.reject("Hot-key Keystore key not found", "KEY_NOT_FOUND") + return + } + + // 4. Initialize the OAEP cipher in DECRYPT_MODE; the actual doFinal + // runs inside the BiometricPrompt callback, which is what gates + // the unwrap on user presence (mirrors SecKeyCreateDecryptedData + // on iOS). + val cipher = Cipher.getInstance(OAEP_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams()) + + pendingCall = call + pendingPayload = payload + pendingDigest = digestBytes + pendingOp = PendingOp.SIGN + promptForBiometric(cipher, "Sign transaction") + } catch (e: Exception) { + Log.e(TAG, "signWithHotKey failed: ${e.message}", e) + call.reject("Hot-key sign failed: ${e.message}") + } + } + + /// Unwrap the hot-key secret inside the Keystore (triggers BiometricPrompt) + /// and return the raw 32-byte secp256k1 secret as hex. Used by Settings → + /// Reveal Hot Key. Same OAEP unwrap path as `signWithHotKey`, minus the + /// signing step. The unwrapped secret is zeroed before returning. + @PluginMethod + fun revealHotKey(call: PluginCall) { + Log.d(TAG, "revealHotKey called") + + // See signWithHotKey: reject while a biometric op is already in flight so + // the shared pending* fields aren't clobbered. + if (pendingCall != null) { + call.reject("Another hot-key operation is in progress", "BIOMETRIC_BUSY") + return + } + + val ciphertext = call.getString("ciphertext") + if (ciphertext == null) { + call.reject("Missing 'ciphertext' parameter") + return + } + + try { + val (alias, payload) = parseCiphertext(ciphertext) + + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + val privateKey = keyStore.getKey(alias, null) as? PrivateKey + if (privateKey == null) { + Log.e(TAG, "revealHotKey: Keystore key not found at $alias") + call.reject("Hot-key Keystore key not found", "KEY_NOT_FOUND") + return + } + + val cipher = Cipher.getInstance(OAEP_TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepParams()) + + pendingCall = call + pendingPayload = payload + pendingDigest = null + pendingOp = PendingOp.REVEAL + promptForBiometric(cipher, "Reveal device key") + } catch (e: Exception) { + Log.e(TAG, "revealHotKey failed: ${e.message}", e) + call.reject("Hot-key reveal failed: ${e.message}") + } + } + + /// Delete the per-account Keystore hot key. Idempotent — a missing alias + /// resolves successfully so callers can call this during account deletion + /// without branching on existence. + @PluginMethod + fun deleteHotKey(call: PluginCall) { + Log.d(TAG, "deleteHotKey called") + + val ciphertext = call.getString("ciphertext") + if (ciphertext == null) { + call.reject("Missing 'ciphertext' parameter") + return + } + + try { + val parts = ciphertext.split(":", limit = 2) + if (parts.isEmpty() || parts[0].isEmpty()) { + call.reject("Malformed hot-key ciphertext") + return + } + val alias = KEY_ALIAS_PREFIX + parts[0] + deleteAliasQuietly(alias) + Log.d(TAG, "deleteHotKey resolved") + call.resolve() + } catch (e: Exception) { + Log.e(TAG, "deleteHotKey failed: ${e.message}", e) + call.reject("Failed to delete hot key: ${e.message}") + } + } + + // -- Biometric prompt + post-auth sign ------------------------------------ + + private fun promptForBiometric(cipher: Cipher, subtitle: String) { + val activity = activity as? FragmentActivity + if (activity == null) { + failPending("Activity not available") + return + } + + val executor = ContextCompat.getMainExecutor(context) + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + Log.d(TAG, "Biometric authentication succeeded for hot-key op") + val cryptoCipher = result.cryptoObject?.cipher + val payload = pendingPayload + val digest = pendingDigest + val op = pendingOp + val pendingCallLocal = pendingCall + pendingCall = null + pendingPayload = null + pendingDigest = null + pendingOp = null + + if (cryptoCipher == null || payload == null || op == null || pendingCallLocal == null) { + pendingCallLocal?.reject("Cipher not available after authentication") + return + } + + var unwrapped: ByteArray? = null + try { + unwrapped = cryptoCipher.doFinal(payload) + if (unwrapped.size != 32) { + pendingCallLocal.reject("Unwrapped hot-key has wrong length") + return + } + when (op) { + PendingOp.SIGN -> { + if (digest == null) { + pendingCallLocal.reject("Hot-key sign post-auth: missing digest") + return + } + val signatureHex = signRecoverable(unwrapped, digest) + val res = JSObject() + res.put("signatureHex", signatureHex) + Log.d(TAG, "signWithHotKey success") + pendingCallLocal.resolve(res) + } + PendingOp.REVEAL -> { + val secretKeyHex = unwrapped.toHex() + val res = JSObject() + res.put("secretKeyHex", secretKeyHex) + Log.d(TAG, "revealHotKey success") + pendingCallLocal.resolve(res) + } + } + } catch (e: Exception) { + Log.e(TAG, "Hot-key post-auth failed: ${e.message}", e) + pendingCallLocal.reject("Hot-key operation failed: ${e.message}") + } finally { + unwrapped?.let { zero(it) } + } + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + Log.e(TAG, "Biometric authentication error: $errorCode - $errString") + val pendingCallLocal = pendingCall + pendingCall = null + pendingPayload = null + pendingDigest = null + pendingOp = null + when (errorCode) { + BiometricPrompt.ERROR_USER_CANCELED, + BiometricPrompt.ERROR_NEGATIVE_BUTTON -> + pendingCallLocal?.reject("Authentication cancelled", "USER_CANCELLED") + else -> + pendingCallLocal?.reject("Authentication failed: $errString", "AUTH_FAILED") + } + } + + override fun onAuthenticationFailed() { + Log.d(TAG, "Biometric authentication failed (user can retry)") + } + } + + // Match HardwareSecurityPlugin: biometric-strong OR device credential, + // no negative button (DEVICE_CREDENTIAL forbids it). + val promptInfo = BiometricPrompt.PromptInfo.Builder() + .setTitle("Miden Wallet") + .setSubtitle(subtitle) + .setAllowedAuthenticators( + BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + ) + .build() + + activity.runOnUiThread { + val biometricPrompt = BiometricPrompt(activity, executor, callback) + biometricPrompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(cipher)) + } + } + + private fun failPending(msg: String) { + val pendingCallLocal = pendingCall + pendingCall = null + pendingPayload = null + pendingDigest = null + pendingOp = null + pendingCallLocal?.reject(msg) + } + + // -- Keystore wrapper key ------------------------------------------------- + + private fun generateKeystoreWrapperKey(alias: String): PublicKey { + // StrongBox first when the hardware supports it; fall back to TEE on + // StrongBoxUnavailableException so older devices still work. Pattern + // intentionally tries StrongBox, catches the throw, rebuilds without + // the flag — same trade-off the iOS plugin makes when omitting + // kSecAttrTokenIDSecureEnclave on simulator. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + try { + return generateRsaWrapperKey(alias, strongBox = true) + } catch (e: StrongBoxUnavailableException) { + Log.w(TAG, "StrongBox unavailable, falling back to TEE") + deleteAliasQuietly(alias) // partial init can leave a stale entry + } + } + return generateRsaWrapperKey(alias, strongBox = false) + } + + private fun generateRsaWrapperKey(alias: String, strongBox: Boolean): PublicKey { + val gen = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, ANDROID_KEYSTORE) + val builder = KeyGenParameterSpec.Builder( + alias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setKeySize(2048) + .setDigests(KeyProperties.DIGEST_SHA256) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .setUserAuthenticationRequired(true) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11+: biometric-strong OR device credential, every-use auth. + builder.setUserAuthenticationParameters( + 0, + KeyProperties.AUTH_BIOMETRIC_STRONG or KeyProperties.AUTH_DEVICE_CREDENTIAL + ) + } else { + // Pre-Android-11: every-use auth, but BIOMETRIC_STRONG cannot be + // enforced here — on API 23–29 a non-FIDO-certified (BIOMETRIC_WEAK) + // fingerprint sensor can satisfy the guard. Key strength is therefore + // hardware-dependent on these older devices (StrongBox below still + // applies when present). `-1` = authentication required on every use. + @Suppress("DEPRECATION") + builder.setUserAuthenticationValidityDurationSeconds(-1) + } + + if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + builder.setIsStrongBoxBacked(true) + } + + gen.initialize(builder.build()) + return gen.generateKeyPair().public + } + + private fun deleteAliasQuietly(alias: String) { + try { + val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE) + keyStore.load(null) + if (keyStore.containsAlias(alias)) { + keyStore.deleteEntry(alias) + } + } catch (e: Exception) { + Log.w(TAG, "deleteAliasQuietly($alias) ignored: ${e.message}") + } + } + + // -- secp256k1 + Keccak-256 ----------------------------------------------- + + /** + * Compressed secp256k1 public key (33 bytes: 0x02/0x03 parity prefix + + * 32-byte x). Matches what iOS returns via `P256K.Signing.PrivateKey + * .publicKey.dataRepresentation`; the JS facade asserts `length === 33`. + */ + private fun derivePublicKeyHex(secret: ByteArray): String { + val d = BigInteger(1, secret) + val q = SECP256K1.g.multiply(d).normalize() + return q.getEncoded(true).toHex() + } + + /** + * Keccak-256 the 32-byte word, then ECDSA-sign (recoverable) with + * deterministic-k (RFC 6979) over secp256k1. Returns r||s||v as a + * 0x-prefixed hex string (65 bytes). Matches the iOS plugin which uses + * libsecp256k1's `secp256k1_ecdsa_sign_recoverable`. We canonicalize s + * to the low half of n so signatures are uniquely-encoded — the iOS + * libsecp256k1 path does the same internally. + */ + private fun signRecoverable(secret: ByteArray, digestBytes: ByteArray): String { + val keccak = Keccak.Digest256() + val hash = keccak.digest(digestBytes) + + val d = BigInteger(1, secret) + val signer = ECDSASigner(HMacDSAKCalculator(SHA256Digest())) + signer.init(true, ECPrivateKeyParameters(d, DOMAIN)) + val rs = signer.generateSignature(hash) + val r = rs[0] + var s = rs[1] + if (s > HALF_N) s = SECP256K1.n.subtract(s) + + val pubKey = SECP256K1.g.multiply(d).normalize() + val v = computeRecoveryId(r, s, hash, pubKey) + + val sig = ByteArray(65) + System.arraycopy(toFixed32(r), 0, sig, 0, 32) + System.arraycopy(toFixed32(s), 0, sig, 32, 32) + sig[64] = v + return "0x" + sig.toHex() + } + + private fun computeRecoveryId(r: BigInteger, s: BigInteger, hash: ByteArray, expected: ECPoint): Byte { + // For low-s canonical signatures with x < n there are at most 2 + // candidates (recId 0 and 1). Try both, pick the one that recovers + // back to the signer's actual public key. + for (recId in 0..1) { + val recovered = recoverPubKey(r, s, hash, recId) + if (recovered != null && recovered.equals(expected)) { + return recId.toByte() + } + } + throw IllegalStateException("ECDSA recovery id derivation failed") + } + + private fun recoverPubKey(r: BigInteger, s: BigInteger, hash: ByteArray, recId: Int): ECPoint? { + val n = SECP256K1.n + val curve = SECP256K1.curve + val prime = curve.field.characteristic + val i = BigInteger.valueOf((recId / 2).toLong()) + val x = r.add(i.multiply(n)) + if (x >= prime) return null + val R = decompressPoint(x, recId and 1 == 1) ?: return null + if (!R.multiply(n).isInfinity) return null + val e = BigInteger(1, hash) + val rInv = r.modInverse(n) + val srInv = rInv.multiply(s).mod(n) + val eInvrInv = rInv.multiply(n.subtract(e)).mod(n) + return ECAlgorithms.sumOfTwoMultiplies(SECP256K1.g, eInvrInv, R, srInv).normalize() + } + + private fun decompressPoint(x: BigInteger, yOdd: Boolean): ECPoint? { + return try { + val enc = ByteArray(33) + enc[0] = if (yOdd) 0x03 else 0x02 + val xb = toFixed32(x) + System.arraycopy(xb, 0, enc, 1, 32) + SECP256K1.curve.decodePoint(enc) + } catch (e: Exception) { + null + } + } + + // -- Helpers --------------------------------------------------------------- + + private fun parseCiphertext(ct: String): Pair { + val parts = ct.split(":", limit = 2) + if (parts.size != 2 || parts[0].isEmpty()) { + throw IllegalArgumentException("Malformed hot-key ciphertext") + } + val alias = KEY_ALIAS_PREFIX + parts[0] + val payload = Base64.decode(parts[1], Base64.NO_WRAP) + return alias to payload + } + + private fun oaepParams(): OAEPParameterSpec = + // Explicit OAEP params: Android Keystore's default MGF1 digest is + // SHA-1, which mismatches the SHA-256 main digest we set above. + // Spelling it out keeps encrypt and decrypt agreeing. + OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT) + + private fun toFixed32(v: BigInteger): ByteArray { + val raw = v.toByteArray() + if (raw.size == 32) return raw + if (raw.size == 33 && raw[0] == 0.toByte()) { + val out = ByteArray(32) + System.arraycopy(raw, 1, out, 0, 32) + return out + } + if (raw.size < 32) { + val out = ByteArray(32) + System.arraycopy(raw, 0, out, 32 - raw.size, raw.size) + return out + } + throw IllegalStateException("BigInteger too large for 32-byte fixed buffer: ${raw.size}") + } + + private fun ByteArray.toHex(): String { + val sb = StringBuilder(size * 2) + for (b in this) sb.append(String.format("%02x", b)) + return sb.toString() + } + + private fun hexDecode(s: String): ByteArray { + if (s.length % 2 != 0) throw IllegalArgumentException("invalid hex length") + val out = ByteArray(s.length / 2) + for (i in out.indices) { + val hi = Character.digit(s[i * 2], 16) + val lo = Character.digit(s[i * 2 + 1], 16) + if (hi < 0 || lo < 0) throw IllegalArgumentException("invalid hex char") + out[i] = ((hi shl 4) + lo).toByte() + } + return out + } + + private fun zero(b: ByteArray) { + for (i in b.indices) b[i] = 0 + } +} diff --git a/android/app/src/main/java/com/miden/wallet/MainActivity.java b/android/app/src/main/java/com/miden/wallet/MainActivity.java index 7eff59079..035eca7ec 100644 --- a/android/app/src/main/java/com/miden/wallet/MainActivity.java +++ b/android/app/src/main/java/com/miden/wallet/MainActivity.java @@ -17,6 +17,7 @@ public class MainActivity extends BridgeActivity { protected void onCreate(Bundle savedInstanceState) { // Register custom plugins before super.onCreate registerPlugin(HardwareSecurityPlugin.class); + registerPlugin(HotKeyPlugin.class); super.onCreate(savedInstanceState); setupStatusBar(); diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 66dceb52d..a915683ac 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -15,6 +15,9 @@ 504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; }; 504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; }; 50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; }; + 7B8A15032FA8BC67006B631E /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 7B8A15022FA8BC67006B631E /* CryptoSwift */; }; + 7BC0FFEE2FAA000000000001 /* HotKeyPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BC0FFEF2FAA000000000001 /* HotKeyPlugin.swift */; }; + 7BECE3022FA7691500F466A5 /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = 7BECE3012FA7691500F466A5 /* P256K */; settings = {ATTRIBUTES = (Required, ); }; }; 83CCE39C02FD0BF8DD39551F /* AppViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A6B2865F76F213517CFCCD1 /* AppViewController.swift */; }; B54E83DC5BCCDB512256423A /* LocalBiometricPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21A34DAD709C71D553F88951 /* LocalBiometricPlugin.swift */; }; C7D4E92A3F8B1C5D00A2B9E1 /* BarcodeScannerPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */; }; @@ -32,6 +35,7 @@ 504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = ""; }; + 7BC0FFEF2FAA000000000001 /* HotKeyPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HotKeyPlugin.swift; sourceTree = ""; }; 873F0344C8952CB5585102E0 /* App.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; 958DCC722DB07C7200EA8C5F /* debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = debug.xcconfig; path = ../debug.xcconfig; sourceTree = SOURCE_ROOT; }; C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BarcodeScannerPlugin.swift; sourceTree = ""; }; @@ -43,6 +47,8 @@ buildActionMask = 2147483647; files = ( 4D22ABE92AF431CB00220026 /* CapApp-SPM in Frameworks */, + 7BECE3022FA7691500F466A5 /* P256K in Frameworks */, + 7B8A15032FA8BC67006B631E /* CryptoSwift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,6 +85,7 @@ 2FAD9762203C412B000D30F8 /* config.xml */, 50B271D01FEDC1A000F3C39B /* public */, 21A34DAD709C71D553F88951 /* LocalBiometricPlugin.swift */, + 7BC0FFEF2FAA000000000001 /* HotKeyPlugin.swift */, C7D4E92B3F8B1C5D00A2B9E2 /* BarcodeScannerPlugin.swift */, 1A6B2865F76F213517CFCCD1 /* AppViewController.swift */, 873F0344C8952CB5585102E0 /* App.entitlements */, @@ -89,7 +96,6 @@ 7BF04FDF2F1F799A005E306E /* Recovered References */ = { isa = PBXGroup; children = ( - BFB20C26958B0AB36D108D0E /* Pods-App.release.xcconfig */, ); name = "Recovered References"; sourceTree = ""; @@ -112,6 +118,8 @@ name = App; packageProductDependencies = ( 4D22ABE82AF431CB00220026 /* CapApp-SPM */, + 7BECE3012FA7691500F466A5 /* P256K */, + 7B8A15022FA8BC67006B631E /* CryptoSwift */, ); productName = App; productReference = 504EC3041FED79650016851F /* App.app */; @@ -144,6 +152,8 @@ mainGroup = 504EC2FB1FED79650016851F; packageReferences = ( D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */, + 7BECE3002FA7691500F466A5 /* XCRemoteSwiftPackageReference "swift-secp256k1" */, + 7B8A15012FA8BC67006B631E /* XCRemoteSwiftPackageReference "CryptoSwift" */, ); productRefGroup = 504EC3051FED79650016851F /* Products */; projectDirPath = ""; @@ -177,6 +187,7 @@ files = ( 504EC3081FED79650016851F /* AppDelegate.swift in Sources */, B54E83DC5BCCDB512256423A /* LocalBiometricPlugin.swift in Sources */, + 7BC0FFEE2FAA000000000001 /* HotKeyPlugin.swift in Sources */, C7D4E92A3F8B1C5D00A2B9E1 /* BarcodeScannerPlugin.swift in Sources */, 83CCE39C02FD0BF8DD39551F /* AppViewController.swift in Sources */, ); @@ -393,12 +404,41 @@ }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + 7B8A15012FA8BC67006B631E /* XCRemoteSwiftPackageReference "CryptoSwift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/krzyzanowskim/CryptoSwift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.10.0; + }; + }; + 7BECE3002FA7691500F466A5 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/21-DOT-DEV/swift-secp256k1.git"; + requirement = { + kind = upToNextMinorVersion; + minimumVersion = 0.21.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 4D22ABE82AF431CB00220026 /* CapApp-SPM */ = { isa = XCSwiftPackageProductDependency; package = D4C12C0A2AAA248700AAC8A2 /* XCLocalSwiftPackageReference "CapApp-SPM" */; productName = "CapApp-SPM"; }; + 7B8A15022FA8BC67006B631E /* CryptoSwift */ = { + isa = XCSwiftPackageProductDependency; + package = 7B8A15012FA8BC67006B631E /* XCRemoteSwiftPackageReference "CryptoSwift" */; + productName = CryptoSwift; + }; + 7BECE3012FA7691500F466A5 /* P256K */ = { + isa = XCSwiftPackageProductDependency; + package = 7BECE3002FA7691500F466A5 /* XCRemoteSwiftPackageReference "swift-secp256k1" */; + productName = P256K; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 504EC2FC1FED79650016851F /* Project object */; diff --git a/ios/App/App/AppViewController.swift b/ios/App/App/AppViewController.swift index 15f61071b..66c4190d7 100644 --- a/ios/App/App/AppViewController.swift +++ b/ios/App/App/AppViewController.swift @@ -6,6 +6,7 @@ class AppViewController: CAPBridgeViewController { override open func capacitorDidLoad() { bridge?.registerPluginInstance(LocalBiometricPlugin()) bridge?.registerPluginInstance(BarcodeScannerPlugin()) + bridge?.registerPluginInstance(HotKeyPlugin()) bridge?.registerPluginInstance(MidenNativeProverPlugin()) } } diff --git a/ios/App/App/HotKeyPlugin.swift b/ios/App/App/HotKeyPlugin.swift new file mode 100644 index 000000000..f34cb03a9 --- /dev/null +++ b/ios/App/App/HotKeyPlugin.swift @@ -0,0 +1,413 @@ +import Foundation +import Capacitor +import LocalAuthentication +import Security +import os.log +import P256K +import CryptoSwift + +// Per-account Guardian "hot" signing key (3-key migration, Phase 4). +// Split out of LocalBiometricPlugin so this plugin owns one concern: the SE- +// wrapped secp256k1 hot key used for transaction signing. The Keychain / +// hardware-key paths stay in LocalBiometric. +// +// Storage layout: +// - Per-account SE-backed P-256 key tagged "com.miden.wallet.hot." +// - Returned ciphertext is ":" so signWith / +// deleteWith can recover the tag from the blob alone. + +private let logger = OSLog(subsystem: "com.miden.wallet", category: "HotKey") + +private let kHotKeyTagPrefix = "com.miden.wallet.hot." + +@objc(HotKeyPlugin) +public class HotKeyPlugin: CAPPlugin, CAPBridgedPlugin { + public let identifier = "HotKeyPlugin" + public let jsName = "HotKey" + public let pluginMethods: [CAPPluginMethod] = [ + CAPPluginMethod(name: "generateHotKey", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "signWithHotKey", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "deleteHotKey", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "revealHotKey", returnType: CAPPluginReturnPromise) + ] + + @objc func generateHotKey(_ call: CAPPluginCall) { + os_log("[HotKey] generateHotKey called", log: logger, type: .debug) + + // 1. Random k256 secret + var secretBytes = Data(count: 32) + let rngStatus = secretBytes.withUnsafeMutableBytes { raw -> Int32 in + guard let base = raw.baseAddress else { return errSecParam } + return SecRandomCopyBytes(kSecRandomDefault, 32, base) + } + guard rngStatus == errSecSuccess else { + call.reject("Failed to generate hot-key secret: \(rngStatus)") + return + } + + // 2. Derive compressed k256 public key (33 bytes: 0x02/0x03 parity + // prefix + 32-byte x). Miden SDK's PublicKey.deserialize expects + // the compressed form; this matches what jsFallback.ts emits via + // AuthSecretKey.publicKey().serialize().slice(1). P256K's default + // format is .compressed, so `dataRepresentation` is already the + // 33-byte form — no stripping needed. + let publicKeyHex: String + do { + let pk = try P256K.Signing.PrivateKey(dataRepresentation: secretBytes) + let rawPub = pk.publicKey.dataRepresentation + publicKeyHex = rawPub.map { String(format: "%02x", $0) }.joined() + } catch { + zeroBytes(&secretBytes) + call.reject("Failed to derive hot-key public key: \(error.localizedDescription)") + return + } + + // 3. Random 16-byte tag suffix; full Keychain tag is prefix+suffix. + var tagSuffix = Data(count: 16) + let tagStatus = tagSuffix.withUnsafeMutableBytes { raw -> Int32 in + guard let base = raw.baseAddress else { return errSecParam } + return SecRandomCopyBytes(kSecRandomDefault, 16, base) + } + guard tagStatus == errSecSuccess else { + zeroBytes(&secretBytes) + call.reject("Failed to generate hot-key tag: \(tagStatus)") + return + } + let tagSuffixB64 = tagSuffix.base64EncodedString() + let fullTag = kHotKeyTagPrefix + tagSuffixB64 + guard let fullTagData = fullTag.data(using: .utf8) else { + zeroBytes(&secretBytes) + call.reject("Failed to encode hot-key tag") + return + } + + // 4. Create the SE-backed P-256 key. .privateKeyUsage triggers Face ID + // only when the private key is used (i.e. SecKeyCreateDecryptedData + // in signWithHotKey), not at create time. + // THREAT MODEL: we intentionally do NOT add `.biometryCurrentSet`. + // That flag would invalidate the SE key whenever the biometric + // enrollment changes (a face/finger added or re-enrolled), forcing the + // user to re-activate the hot key. We accept "any enrolled biometric + // can sign" in exchange for not bricking the hot key on an enrollment + // change; the key never leaves the Secure Enclave either way. Switch + // to `.biometryCurrentSet` (with a re-activation migration) if a + // stricter "enrollment change = re-activate" posture is required. + var accessError: Unmanaged? + guard let accessControl = SecAccessControlCreateWithFlags( + kCFAllocatorDefault, + kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + .privateKeyUsage, + &accessError + ) else { + zeroBytes(&secretBytes) + let msg = accessError?.takeRetainedValue().localizedDescription ?? "unknown" + call.reject("Failed to create access control: \(msg)") + return + } + + var seKeyAttributes: [String: Any] = [ + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecAttrKeySizeInBits as String: 256, + kSecPrivateKeyAttrs as String: [ + kSecAttrIsPermanent as String: true, + kSecAttrApplicationTag as String: fullTagData, + kSecAttrAccessControl as String: accessControl + ] + ] + // On simulator the host SE is unavailable; without the token attribute + // the key falls back to software-backed but the same APIs work, which + // lets us iterate against the iPhone simulator. Real devices require + // SE — same trade-off as generateHardwareKey. + #if !targetEnvironment(simulator) + seKeyAttributes[kSecAttrTokenID as String] = kSecAttrTokenIDSecureEnclave + #endif + + var keyError: Unmanaged? + guard let sePrivateKey = SecKeyCreateRandomKey(seKeyAttributes as CFDictionary, &keyError) else { + zeroBytes(&secretBytes) + let msg = keyError?.takeRetainedValue().localizedDescription ?? "unknown" + call.reject("Failed to generate hot-key SE key: \(msg)") + return + } + guard let sePublicKey = SecKeyCopyPublicKey(sePrivateKey) else { + zeroBytes(&secretBytes) + // Best-effort cleanup of the orphan SE key we just created. + SecItemDelete([ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: fullTagData + ] as CFDictionary) + call.reject("Failed to obtain hot-key SE public key") + return + } + + // 5. ECIES-encrypt the k256 secret to the SE public key. Apple's + // eciesEncryptionStandardX963SHA256AESGCM produces a self-describing + // blob (ephem pubkey || iv || ct || tag), opaque to us. + var encError: Unmanaged? + guard let wrapped = SecKeyCreateEncryptedData( + sePublicKey, + .eciesEncryptionStandardX963SHA256AESGCM, + secretBytes as CFData, + &encError + ) as Data? else { + zeroBytes(&secretBytes) + SecItemDelete([ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: fullTagData + ] as CFDictionary) + let msg = encError?.takeRetainedValue().localizedDescription ?? "unknown" + call.reject("Failed to wrap hot-key secret: \(msg)") + return + } + + zeroBytes(&secretBytes) + + // 6. Pack into ":" so signWithHotKey can + // recover the SE key tag from the ciphertext alone. + let packed = "\(tagSuffixB64):\(wrapped.base64EncodedString())" + os_log("[HotKey] generateHotKey success", log: logger, type: .debug) + call.resolve([ + "ciphertext": packed, + "publicKeyHex": publicKeyHex + ]) + } + + /// Unwrap the hot-key secret inside the SE (triggers Face ID), Keccak-256 + /// the supplied 32-byte word, ECDSA-sign (recoverable) over secp256k1, + /// and return r||s||v as 0x-prefixed hex (65 bytes). The unwrapped secret + /// is zeroed before returning. + @objc func signWithHotKey(_ call: CAPPluginCall) { + os_log("[HotKey] signWithHotKey called", log: logger, type: .debug) + + guard let ciphertext = call.getString("ciphertext"), + let digestHex = call.getString("digestHex") else { + call.reject("Missing 'ciphertext' or 'digestHex' parameter") + return + } + + // 1. Split tag from payload. + let parts = ciphertext.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, + let payload = Data(base64Encoded: String(parts[1])) else { + call.reject("Malformed hot-key ciphertext") + return + } + let fullTag = kHotKeyTagPrefix + String(parts[0]) + guard let fullTagData = fullTag.data(using: .utf8) else { + call.reject("Failed to encode hot-key tag") + return + } + + // 2. Decode the digest (caller passes it 0x-prefixed, matching + // Word.toHex()). Must be 32 bytes — Miden Words are 4 felts × 8. + // Use CryptoSwift's `Data(hex:)` so the same lib that does the + // Keccak hashes the bytes it parsed. + let cleanedHex = digestHex.hasPrefix("0x") ? String(digestHex.dropFirst(2)) : digestHex + let digestBytes = Data(hex: cleanedHex) + guard digestBytes.count == 32 else { + call.reject("Hot-key digest must be 32 hex bytes") + return + } + + // 3. Look up the SE private key by tag. + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: fullTagData, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecReturnRef as String: true + ] + var keyRef: CFTypeRef? + let lookupStatus = SecItemCopyMatching(query as CFDictionary, &keyRef) + guard lookupStatus == errSecSuccess, let foundKey = keyRef else { + os_log("[HotKey] signWithHotKey: SE key not found %{public}d", log: logger, type: .error, lookupStatus) + if lookupStatus == errSecUserCanceled { + call.reject("Authentication cancelled", "USER_CANCELLED") + } else if lookupStatus == errSecAuthFailed { + call.reject("Authentication failed", "AUTH_FAILED") + } else { + call.reject("Hot-key SE key not found: \(lookupStatus)") + } + return + } + // Conditional cast: a corrupted/foreign Keychain item at this tag would + // crash the app process on a force-cast. + guard let sePrivateKey = foundKey as? SecKey else { + call.reject("Hot-key Keychain item is not a SecKey") + return + } + + // 4. SecKeyCreateDecryptedData triggers Face ID via .privateKeyUsage + // on the SE key. + var decError: Unmanaged? + guard var unwrapped = SecKeyCreateDecryptedData( + sePrivateKey, + .eciesEncryptionStandardX963SHA256AESGCM, + payload as CFData, + &decError + ) as Data? else { + let nsError = decError?.takeRetainedValue() as? NSError + let msg = nsError?.localizedDescription ?? "unknown" + os_log("[HotKey] signWithHotKey decrypt failed: %{public}@", log: logger, type: .error, msg) + if nsError?.domain == LAError.errorDomain && nsError?.code == LAError.userCancel.rawValue { + call.reject("Authentication cancelled", "USER_CANCELLED") + } else if nsError?.domain == LAError.errorDomain && nsError?.code == LAError.authenticationFailed.rawValue { + call.reject("Authentication failed", "AUTH_FAILED") + } else { + call.reject("Failed to unwrap hot-key secret: \(msg)") + } + return + } + guard unwrapped.count == 32 else { + zeroBytes(&unwrapped) + call.reject("Unwrapped hot-key has wrong length") + return + } + + // 5. Keccak-256 the word, then ECDSA-sign (recoverable) with secp256k1. + // Use P256K.Recovery (not Signing) so we can pull the recovery id. + // `compactRepresentation` returns (signature: Data, recoveryId: Int32): + // 64-byte r||s plus the 0/1 recovery byte. We emit r||s||v (65 bytes) + // where v is the raw recovery id. If the consumer expects Ethereum- + // style v, add 27 on the JS side — keeping it raw here so we don't + // bake a chain convention into the native plugin. + let keccakDigest = digestBytes.sha3(.keccak256) + let signatureHex: String + do { + let pk = try P256K.Recovery.PrivateKey(dataRepresentation: unwrapped) + let digestBuffer = HashDigest(Array(keccakDigest)) + let sig = try pk.signature(for: digestBuffer) + let compact = try sig.compactRepresentation + let v = UInt8(truncatingIfNeeded: compact.recoveryId) + let rs = compact.signature.map { String(format: "%02x", $0) }.joined() + signatureHex = "0x" + rs + String(format: "%02x", v) + } catch { + zeroBytes(&unwrapped) + call.reject("Hot-key ECDSA sign failed: \(error.localizedDescription)") + return + } + + zeroBytes(&unwrapped) + + os_log("[HotKey] signWithHotKey success", log: logger, type: .debug) + call.resolve(["signatureHex": signatureHex]) + } + + /// Unwrap the hot-key secret inside the SE (triggers Face ID) and return + /// the raw 32-byte secp256k1 secret as hex. Used by Settings → Reveal Hot + /// Key. Same SE/ECIES unwrap path as `signWithHotKey`, minus the actual + /// signing step. The unwrapped secret is zeroed before returning. + @objc func revealHotKey(_ call: CAPPluginCall) { + os_log("[HotKey] revealHotKey called", log: logger, type: .debug) + + guard let ciphertext = call.getString("ciphertext") else { + call.reject("Missing 'ciphertext' parameter") + return + } + + let parts = ciphertext.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2, + let payload = Data(base64Encoded: String(parts[1])) else { + call.reject("Malformed hot-key ciphertext") + return + } + let fullTag = kHotKeyTagPrefix + String(parts[0]) + guard let fullTagData = fullTag.data(using: .utf8) else { + call.reject("Failed to encode hot-key tag") + return + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: fullTagData, + kSecAttrKeyType as String: kSecAttrKeyTypeECSECPrimeRandom, + kSecReturnRef as String: true + ] + var keyRef: CFTypeRef? + let lookupStatus = SecItemCopyMatching(query as CFDictionary, &keyRef) + guard lookupStatus == errSecSuccess, let foundKey = keyRef else { + os_log("[HotKey] revealHotKey: SE key not found %{public}d", log: logger, type: .error, lookupStatus) + if lookupStatus == errSecUserCanceled { + call.reject("Authentication cancelled", "USER_CANCELLED") + } else if lookupStatus == errSecAuthFailed { + call.reject("Authentication failed", "AUTH_FAILED") + } else { + call.reject("Hot-key SE key not found: \(lookupStatus)") + } + return + } + // Conditional cast: avoid crashing on a corrupted/foreign Keychain item. + guard let sePrivateKey = foundKey as? SecKey else { + call.reject("Hot-key Keychain item is not a SecKey") + return + } + + var decError: Unmanaged? + guard var unwrapped = SecKeyCreateDecryptedData( + sePrivateKey, + .eciesEncryptionStandardX963SHA256AESGCM, + payload as CFData, + &decError + ) as Data? else { + let nsError = decError?.takeRetainedValue() as? NSError + let msg = nsError?.localizedDescription ?? "unknown" + os_log("[HotKey] revealHotKey decrypt failed: %{public}@", log: logger, type: .error, msg) + if nsError?.domain == LAError.errorDomain && nsError?.code == LAError.userCancel.rawValue { + call.reject("Authentication cancelled", "USER_CANCELLED") + } else if nsError?.domain == LAError.errorDomain && nsError?.code == LAError.authenticationFailed.rawValue { + call.reject("Authentication failed", "AUTH_FAILED") + } else { + call.reject("Failed to unwrap hot-key secret: \(msg)") + } + return + } + guard unwrapped.count == 32 else { + zeroBytes(&unwrapped) + call.reject("Unwrapped hot-key has wrong length") + return + } + + let secretKeyHex = unwrapped.map { String(format: "%02x", $0) }.joined() + zeroBytes(&unwrapped) + + os_log("[HotKey] revealHotKey success", log: logger, type: .debug) + call.resolve(["secretKeyHex": secretKeyHex]) + } + + /// Delete the per-account SE hot key. Idempotent — a missing key resolves + /// successfully so callers can call this during account deletion without + /// branching on existence. + @objc func deleteHotKey(_ call: CAPPluginCall) { + os_log("[HotKey] deleteHotKey called", log: logger, type: .debug) + + guard let ciphertext = call.getString("ciphertext") else { + call.reject("Missing 'ciphertext' parameter") + return + } + + let parts = ciphertext.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count >= 1 else { + call.reject("Malformed hot-key ciphertext") + return + } + let fullTag = kHotKeyTagPrefix + String(parts[0]) + guard let fullTagData = fullTag.data(using: .utf8) else { + call.reject("Failed to encode hot-key tag") + return + } + + let status = SecItemDelete([ + kSecClass as String: kSecClassKey, + kSecAttrApplicationTag as String: fullTagData + ] as CFDictionary) + os_log("[HotKey] deleteHotKey status: %{public}d", log: logger, type: .debug, status) + call.resolve() + } + + private func zeroBytes(_ data: inout Data) { + data.withUnsafeMutableBytes { raw in + if let base = raw.baseAddress { + memset_s(base, raw.count, 0, raw.count) + } + } + } +} diff --git a/ios/App/App/LocalBiometricPlugin.swift b/ios/App/App/LocalBiometricPlugin.swift index 8ae2cb3ad..f90a1f2b5 100644 --- a/ios/App/App/LocalBiometricPlugin.swift +++ b/ios/App/App/LocalBiometricPlugin.swift @@ -594,4 +594,5 @@ public class LocalBiometricPlugin: CAPPlugin, CAPBridgedPlugin { return nil } } + } diff --git a/ios/App/CapApp-SPM/Package.resolved b/ios/App/CapApp-SPM/Package.resolved new file mode 100644 index 000000000..6c575b00a --- /dev/null +++ b/ios/App/CapApp-SPM/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "capacitor-swift-pm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/capacitor-swift-pm.git", + "state" : { + "revision" : "3e7ccfb8ab4d321ffcbd4e490234758e139f8a09", + "version" : "8.0.1" + } + }, + { + "identity" : "ion-ios-filesystem", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ionic-team/ion-ios-filesystem.git", + "state" : { + "revision" : "0d81e26e828ff9582807e2339112cedf2e0fab85", + "version" : "1.1.2" + } + } + ], + "version" : 2 +} diff --git a/jest.config.ts b/jest.config.ts index 4babe2ac2..94c4c56bb 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -65,12 +65,20 @@ export default { transform: { '.+\\.(ts|tsx|js|mjs)$': '@swc/jest' }, - transformIgnorePatterns: [ - '/node_modules/(?!(p-queue|p-timeout|eventemitter3|date-fns|dexie)/)' - ], + transformIgnorePatterns: ['/node_modules/(?!(p-queue|p-timeout|eventemitter3|date-fns|dexie)/)'], moduleFileExtensions: ['ts', 'tsx', 'js'], - modulePathIgnorePatterns: ['/sdk-debug/'], - testPathIgnorePatterns: ['/playwright/', '/mobile-e2e/'], + // Exclude git worktrees: they hold full copies of the repo, so without this a + // plain `jest` run discovers their stale test files and emits haste-map + // duplicate-mock collisions (spurious local failures). CI checks out clean, so + // this only matters for local runs. + modulePathIgnorePatterns: ['/sdk-debug/', '/.worktrees/', '/.claude/'], + testPathIgnorePatterns: [ + '/playwright/', + '/mobile-e2e/', + '/ios/App/build/', + '/.worktrees/', + '/.claude/' + ], setupFiles: ['dotenv/config', '@serh11p/jest-webextension-mock', 'fake-indexeddb/auto'], setupFilesAfterEnv: ['./jest.setup.js'] }; diff --git a/playwright/e2e/android/helpers/android-wallet-page.ts b/playwright/e2e/android/helpers/android-wallet-page.ts index 8298e83b9..66454eb02 100644 --- a/playwright/e2e/android/helpers/android-wallet-page.ts +++ b/playwright/e2e/android/helpers/android-wallet-page.ts @@ -1,5 +1,5 @@ import type { TimelineRecorder } from '../../harness/timeline-recorder'; -import type { WalletPage } from '../../helpers/wallet-page'; +import type { GuardianAuthInfo, WalletPage } from '../../helpers/wallet-page'; import type { CdpSession } from './cdp-bridge'; import type { EmulatorControl } from './emulator-control'; @@ -409,6 +409,40 @@ export class AndroidWalletPage implements WalletPage { ); } + /** + * Read a Guardian account's on-chain auth structure (overall threshold, + * signer commitments, per-procedure thresholds). Same contract and body as + * the iOS POM — the __TEST_GUARDIAN_AUTH__ hook is async (awaits + * getOrCreateMultisigService + a time-bounded sync), so it runs under the + * async CDP atom whose callback convention matches iOS + * (`arguments[arguments.length - 1]`). + */ + async getGuardianAuthInfo(accountPublicKey: string): Promise { + return this.cdp.evalAsync( + `var cb = arguments[arguments.length - 1]; + var fn = globalThis.__TEST_GUARDIAN_AUTH__; + if (typeof fn !== 'function') { + cb({ + threshold: NaN, + signerCommitments: [], + procedureThresholds: {}, + error: '__TEST_GUARDIAN_AUTH__ unavailable (needs MIDEN_E2E_TEST build)' + }); + return; + } + Promise.resolve(fn(${JSON.stringify(accountPublicKey)})) + .then(function (r) { cb(r); }) + .catch(function (e) { + cb({ + threshold: NaN, + signerCommitments: [], + procedureThresholds: {}, + error: String(e && e.message ? e.message : e) + }); + });` + ); + } + private async triggerNavbarAction(timeoutMs: number): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { diff --git a/playwright/e2e/helpers/wallet-page.ts b/playwright/e2e/helpers/wallet-page.ts index a38c3f31e..4a2069307 100644 --- a/playwright/e2e/helpers/wallet-page.ts +++ b/playwright/e2e/helpers/wallet-page.ts @@ -48,6 +48,14 @@ export interface WalletPage { * the change takes effect on the next render — no reload needed. */ setDelegateProofEnabled(enabled: boolean): Promise; + /** + * On-chain auth structure of a Guardian account (overall threshold, signer + * commitments, per-procedure thresholds) — for asserting the 3-key shape + * (e.g. `update_guardian === 2`, two signers) which balance checks can't see. + * Shared across Chrome (page.evaluate) and iOS (CDP evalAsync) so the same + * assertion runs on both platforms. + */ + getGuardianAuthInfo(accountPublicKey: string): Promise; } /** @@ -76,12 +84,21 @@ export interface ChromeWalletPageApi extends WalletPage, IdbDumpSource { }>; /** Full dump of chrome.storage.local — end-of-run forensic snapshot. */ dumpChromeStorage(): Promise>; + // getGuardianAuthInfo is declared on the shared WalletPage interface (above) + // so the iOS POM implements it too — the 3-key auth assertion runs on both. // IndexedDB forensics (listIndexedDBStores / dumpIndexedDBStore) come from // IdbDumpSource — driven store-at-a-time by streamIndexedDBToFile so a long // run's dump can't OOM the page. This is where the Miden SDK keeps per-tx // commit status — the ground truth for "did this tx land?". } +export interface GuardianAuthInfo { + threshold: number; + signerCommitments: string[]; + procedureThresholds: Record; + error?: string; +} + /** * Page Object Model for a single wallet extension instance. * Encapsulates all UI interactions, reusing selectors from popup-smoke.spec.ts. @@ -627,6 +644,22 @@ export class ChromeWalletPage implements ChromeWalletPageApi { } } + async getGuardianAuthInfo(accountPublicKey: string): Promise { + return this.page.evaluate(async (pk: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fn = (globalThis as any).__TEST_GUARDIAN_AUTH__; + if (!fn) { + return { + threshold: NaN, + signerCommitments: [], + procedureThresholds: {}, + error: '__TEST_GUARDIAN_AUTH__ unavailable (needs MIDEN_E2E_TEST build)' + }; + } + return await fn(pk); + }, accountPublicKey); + } + /** * Enumerate every (db, store) on the extension origin — cheap, loads no row * data. Pairs with `dumpIndexedDBStore` so `streamIndexedDBToFile` can pull diff --git a/playwright/e2e/ios/helpers/ios-wallet-page.ts b/playwright/e2e/ios/helpers/ios-wallet-page.ts index 7a98037d6..c3de28376 100644 --- a/playwright/e2e/ios/helpers/ios-wallet-page.ts +++ b/playwright/e2e/ios/helpers/ios-wallet-page.ts @@ -1,5 +1,5 @@ import type { TimelineRecorder } from '../../harness/timeline-recorder'; -import type { WalletPage } from '../../helpers/wallet-page'; +import type { GuardianAuthInfo, WalletPage } from '../../helpers/wallet-page'; import type { CdpSession } from './cdp-bridge'; import type { SimulatorControl } from './simulator-control'; @@ -593,6 +593,42 @@ export class IosWalletPage implements WalletPage { ); } + /** + * Read a Guardian account's on-chain auth structure (overall threshold, + * signer commitments, per-procedure thresholds). Calls the same + * __TEST_GUARDIAN_AUTH__ hook the Chrome POM uses, but over the async CDP + * atom: the hook awaits getOrCreateMultisigService + a best-effort + * (time-bounded) sync, so it returns a Promise and must run under + * execute_async_script. The hook itself caps its internal sync at 8s, so the + * 30s evalAsync budget is comfortable even when the background sync holds the + * WASM lock. + */ + async getGuardianAuthInfo(accountPublicKey: string): Promise { + return this.cdp.evalAsync( + `var cb = arguments[arguments.length - 1]; + var fn = globalThis.__TEST_GUARDIAN_AUTH__; + if (typeof fn !== 'function') { + cb({ + threshold: NaN, + signerCommitments: [], + procedureThresholds: {}, + error: '__TEST_GUARDIAN_AUTH__ unavailable (needs MIDEN_E2E_TEST build)' + }); + return; + } + Promise.resolve(fn(${JSON.stringify(accountPublicKey)})) + .then(function (r) { cb(r); }) + .catch(function (e) { + cb({ + threshold: NaN, + signerCommitments: [], + procedureThresholds: {}, + error: String(e && e.message ? e.message : e) + }); + });` + ); + } + /** * Fire the currently-registered native navbar action on mobile. The * wallet's native iOS overlay (MidenNavbarOverlayWindow) lives in a diff --git a/playwright/e2e/ios/tests/guardian-send-consume.ios.spec.ts b/playwright/e2e/ios/tests/guardian-send-consume.ios.spec.ts index ca7244eb2..96513c869 100644 --- a/playwright/e2e/ios/tests/guardian-send-consume.ios.spec.ts +++ b/playwright/e2e/ios/tests/guardian-send-consume.ios.spec.ts @@ -69,6 +69,21 @@ test.describe('Guardian account - consume + send', () => { } ); + await steps.step('verify_guardian_auth_structure_a', async () => { + // First on-chain AUTH assertion in the iOS harness (mirrors the Chrome + // spec): a 3-key Guardian account must carry two signers ([hot, cold]) + // and the `update_guardian` procedure hardened to threshold 2 — both set + // at creation. Runs after the consume has committed the account so the + // cached MultisigService read reflects the on-chain shape. The read goes + // through the async CDP atom (the hook awaits a time-bounded sync), which + // CDP can see — unlike the native navbar, which is why this is a WebView + // read rather than a UI check. + const auth = await walletA.getGuardianAuthInfo(addressA!); + expect(auth.error, `guardian auth read failed: ${auth.error}`).toBeUndefined(); + expect(auth.signerCommitments.length, 'fresh 3-key account should have 2 signers (hot, cold)').toBe(2); + expect(auth.procedureThresholds.update_guardian, 'update_guardian must be hardened to threshold 2').toBe(2); + }); + await steps.step( 'send_guardian_a_to_b', async () => { diff --git a/playwright/e2e/tests/guardian-send-consume.spec.ts b/playwright/e2e/tests/guardian-send-consume.spec.ts index 04d184aad..7902745e7 100644 --- a/playwright/e2e/tests/guardian-send-consume.spec.ts +++ b/playwright/e2e/tests/guardian-send-consume.spec.ts @@ -54,6 +54,18 @@ test.describe('Guardian account - consume + send', () => { } ); + await steps.step('verify_guardian_auth_structure_a', async () => { + // First on-chain AUTH assertion in the harness (balance checks can't see + // this): a fresh 3-key Guardian account must carry two signers ([hot, + // cold]) and the `update_guardian` procedure hardened to threshold 2 — + // both set at creation. The same reader verifies the migrated/activated + // path in the (P1) migration spec. + const auth = await walletA.getGuardianAuthInfo(addressA!); + expect(auth.error, `guardian auth read failed: ${auth.error}`).toBeUndefined(); + expect(auth.signerCommitments.length, 'fresh 3-key account should have 2 signers (hot, cold)').toBe(2); + expect(auth.procedureThresholds.update_guardian, 'update_guardian must be hardened to threshold 2').toBe(2); + }); + await steps.step( 'consume_notes_guardian_a', async () => { diff --git a/public/_locales/de/messages.json b/public/_locales/de/messages.json index fdc8dffc4..3369028b0 100644 --- a/public/_locales/de/messages.json +++ b/public/_locales/de/messages.json @@ -1490,6 +1490,10 @@ "message": "Teilen Sie es nicht mit anderen.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Ihr seed phrase ist Ihr Wiederherstellungsschlüssel – er wird zum Rotieren von Geräteschlüsseln verwendet, nicht für alltägliche Transaktionen.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Auswählen", "note": "Onboarding", @@ -1672,6 +1676,14 @@ "message": "Richten Sie Ihr Guardian-Konto auf einen neuen Guardian-Endpunkt. Der Wechsel wird als On-Chain-Vorschlag signiert.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "Dies wird mit Ihrem Wiederherstellungsschlüssel (Kaltschlüssel) signiert und von Ihrem aktuellen Vormund mitunterzeichnet. Ihr Guthaben und Ihr Konto bleiben gleich. Weitermachen?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Wechsel bestätigen", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Vormund wechseln...", "englishSource": "Switching guardian..." @@ -1684,6 +1696,78 @@ "message": "Das ist bereits Ihr aktueller Vormund.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Geräteschlüssel drehen", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Generieren Sie einen neuen Geräteschlüssel (Hotkey) und ersetzen Sie den aktuellen in der Kette. Nützlich, wenn Sie den Verdacht haben, dass Ihr Gerät kompromittiert ist.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "Dies wird mit Ihrem Wiederherstellungsschlüssel (Kaltschlüssel) signiert. Ihr seed phrase ist nicht erforderlich und Ihr Guthaben und Ihr Konto bleiben gleich. Weitermachen?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Drehung bestätigen", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "Geräteschlüssel erfolgreich gedreht.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Geräteschlüssel aktivieren", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Generieren Sie einen Geräteschlüssel für dieses wiederhergestellte Konto. Vor dem Senden erforderlich.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Aktivieren", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Der Geräteschlüssel konnte nicht aktiviert werden. Versuchen Sie es erneut.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Geräteschlüssel anzeigen", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "Ihr Geräteschlüssel (Hotkey) kann unter „Einstellungen“ → „Geräteschlüssel drehen“ gedreht werden. Es ist also weniger riskant, ihn zu enthüllen als Ihren seed phrase – aber jeder, der ihn besitzt, kann Transaktionen signieren, bis Sie ihn drehen.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Privater Geräteschlüssel", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Öffentlicher Geräteschlüssel", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Privater Wiederherstellungsschlüssel (kalt).", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Öffentlicher Wiederherstellungsschlüssel (kalt).", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Ihr Guardian-Konto ist durch drei Schlüssel geschützt: einen Geräteschlüssel (Hotschlüssel) für routinemäßige Signaturen, einen von Ihrem seed phrase abgeleiteten Wiederherstellungsschlüssel (Kaltschlüssel) für Kontoverwaltungsvorgänge und einen externen Guardian-Mitunterzeichner. Nachfolgend finden Sie Ihren kalten privaten Schlüssel sowie beide öffentlichen Schlüssel. Um den privaten Schlüssel des Geräts anzuzeigen, verwenden Sie Einstellungen → Geräteschlüssel anzeigen.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Bevor Sie Ihren Geräteschlüssel preisgeben", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Jeder mit diesem Schlüssel kann Transaktionen signieren, bis Sie ihn rotieren. Wenn der Schlüssel durchgesickert ist, drehen Sie ihn über Einstellungen → Geräteschlüssel drehen.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Geben Sie eine gültige URL ein, die mit http:// oder https:// beginnt.", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/en/en.json b/public/_locales/en/en.json index f367bcdac..98d494c73 100644 --- a/public/_locales/en/en.json +++ b/public/_locales/en/en.json @@ -341,6 +341,7 @@ "minimumCharsWithAtLeast": "Minimum 8 characters with at least 1 number", "backUpWalletInstructions": "Save these 12 words and store in a secure place.", "doNotShareWithAnywone": "Do not share with anyone.", + "seedPhraseRecoveryCaption": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions.", "select": "Select", "selected": "Selected", "additionalDownloads": "Additional Downloads", @@ -382,9 +383,29 @@ "newGuardianEndpoint": "New Guardian Endpoint", "switchGuardian": "Switch Guardian", "switchGuardianDescription": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal.", + "switchGuardianConfirmation": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?", + "confirmSwitchGuardian": "Confirm switch", "switchingGuardian": "Switching guardian...", "guardianSwitched": "Guardian switched successfully.", "guardianEndpointUnchanged": "That's already your current guardian.", + "replaceHotKey": "Rotate device key", + "replaceHotKeyDescription": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised.", + "replaceHotKeyConfirmation": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?", + "confirmReplaceHotKey": "Confirm rotation", + "hotKeyRotated": "Device key rotated successfully.", + "activateHotKeyBannerTitle": "Activate device key", + "activateHotKeyBannerBody": "Generate a device key for this recovered account. Required before sending.", + "activateHotKeyBannerCta": "Activate", + "activateHotKeyBannerError": "Failed to activate device key. Try again.", + "revealHotKey": "Reveal device key", + "revealHotKeyDescription": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate.", + "hotPrivateKey": "Device Private Key", + "hotPublicKeyLabel": "Device Public Key", + "coldPrivateKey": "Recovery (Cold) Private Key", + "coldPublicKeyLabel": "Recovery (Cold) Public Key", + "guardianKeysRevealDescription": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key.", + "hotKeyRevealWarningTitle": "Before you reveal your device key", + "hotKeyRevealWarningBody": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key.", "invalidUrl": "Enter a valid URL starting with http:// or https://", "encryptedWalletFileDescription": "Enter your password to access your Encrypted Wallet File.", "encryptedWalletFileDescriptionHardware": "Unlock with your passcode to access your Encrypted Wallet File.", diff --git a/public/_locales/en/messages.json b/public/_locales/en/messages.json index 7f862b516..5bb7604b1 100644 --- a/public/_locales/en/messages.json +++ b/public/_locales/en/messages.json @@ -1477,6 +1477,10 @@ "message": "Do not share with anyone.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Select", "englishSource": "Select" @@ -1641,6 +1645,14 @@ "message": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Confirm switch", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Switching guardian...", "englishSource": "Switching guardian..." @@ -1653,6 +1665,78 @@ "message": "That's already your current guardian.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Rotate device key", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Confirm rotation", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "Device key rotated successfully.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Activate device key", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Generate a device key for this recovered account. Required before sending.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Activate", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Failed to activate device key. Try again.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Reveal device key", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Device Private Key", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Device Public Key", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Recovery (Cold) Private Key", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Recovery (Cold) Public Key", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Before you reveal your device key", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Enter a valid URL starting with http:// or https://", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/en_GB/messages.json b/public/_locales/en_GB/messages.json index 1339f2a79..af8c8130f 100644 --- a/public/_locales/en_GB/messages.json +++ b/public/_locales/en_GB/messages.json @@ -1517,6 +1517,10 @@ "message": "Do not share with anyone.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Select", "note": "Onboarding", @@ -1700,6 +1704,14 @@ "message": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Confirm switch", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Switching guardian...", "englishSource": "Switching guardian..." @@ -1712,6 +1724,78 @@ "message": "That's already your current guardian.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Rotate device key", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Confirm rotation", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "Device key rotated successfully.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Activate device key", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Generate a device key for this recovered account. Required before sending.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Activate", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Failed to activate device key. Try again.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Reveal device key", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Device Private Key", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Device Public Key", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Recovery (Cold) Private Key", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Recovery (Cold) Public Key", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Before you reveal your device key", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Enter a valid URL starting with http:// or https://", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/es/messages.json b/public/_locales/es/messages.json index 6b974f235..6aa0d7da4 100644 --- a/public/_locales/es/messages.json +++ b/public/_locales/es/messages.json @@ -1450,6 +1450,10 @@ "message": "No compartir con nadie.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Su seed phrase es su clave de recuperación: se utiliza para rotar las claves del dispositivo, no para las transacciones diarias.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Seleccionar", "englishSource": "Select" @@ -1614,6 +1618,14 @@ "message": "Apunte su cuenta de Guardian a un nuevo punto final de Guardian. El cambio está firmado como una propuesta en cadena.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "Esto está firmado con su clave de recuperación (fría) y firmado conjuntamente por su tutor actual. Sus fondos y su cuenta permanecen igual. ¿Continuar?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Confirmar cambio", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Cambiando de guardián...", "englishSource": "Switching guardian..." @@ -1626,6 +1638,78 @@ "message": "Ese ya es tu tutor actual.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Girar la tecla del dispositivo", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Genere una nueva clave (de acceso rápido) de dispositivo y reemplace la actual en la cadena. Útil si sospecha que su dispositivo está comprometido.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "Esto está firmado con su clave de recuperación (fría). Su seed phrase no es obligatorio y sus fondos y su cuenta siguen siendo los mismos. ¿Continuar?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Confirmar rotación", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "La clave del dispositivo se giró correctamente.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Activar clave del dispositivo", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Genere una clave de dispositivo para esta cuenta recuperada. Requerido antes de enviar.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Activar", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "No se pudo activar la clave del dispositivo. Intentar otra vez.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Revelar clave del dispositivo", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "La tecla de acceso rápido de su dispositivo se puede girar desde Configuración → Rotar clave de dispositivo, por lo que revelarla es menos arriesgado que su seed phrase, pero cualquiera que la tenga puede firmar transacciones hasta que la rote.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Clave privada del dispositivo", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Clave pública del dispositivo", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Clave privada de recuperación (en frío)", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Clave pública de recuperación (en frío)", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Su cuenta de Guardian está protegida por tres claves: una clave de dispositivo (activa) para la firma de rutina, una clave de recuperación (fría) derivada de su seed phrase para operaciones de administración de cuentas y un cofirmante externo de Guardian. A continuación se muestra su clave privada fría más ambas claves públicas. Para revelar la clave privada del dispositivo, use Configuración → Revelar clave del dispositivo.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Antes de revelar la clave de tu dispositivo", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Cualquier persona con esta clave puede firmar transacciones hasta que la rotes. Si se filtra la clave, gírela a través de Configuración → Girar clave del dispositivo.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Introduzca una URL válida que comience con http:// o https://", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/fr/messages.json b/public/_locales/fr/messages.json index 7b74594f6..df4a61bd3 100644 --- a/public/_locales/fr/messages.json +++ b/public/_locales/fr/messages.json @@ -1490,6 +1490,10 @@ "message": "Ne partagez avec personne.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Votre seed phrase est votre clé de récupération : elle est utilisée pour alterner les clés de l'appareil, et non pour les transactions quotidiennes.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Sélectionner", "note": "Onboarding", @@ -1671,6 +1675,14 @@ "message": "Pointez votre compte Guardian vers un nouveau point de terminaison Guardian. Le changement est signé comme une proposition en chaîne.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "Celui-ci est signé avec votre clé de récupération (à froid) et co-signé par votre tuteur actuel. Vos fonds et votre compte restent les mêmes. Continuer?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Confirmer le changement", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Changer de tuteur...", "englishSource": "Switching guardian..." @@ -1683,6 +1695,78 @@ "message": "C'est déjà votre tuteur actuel.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Faire pivoter la clé de l'appareil", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Générez une nouvelle touche (hot) de périphérique et remplacez celle actuelle sur la chaîne. Utile si vous pensez que votre appareil est compromis.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "Ceci est signé avec votre clé de récupération (à froid). Votre seed phrase n'est pas requis, et vos fonds et votre compte restent les mêmes. Continuer?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Confirmer la rotation", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "La clé de l'appareil a été pivotée avec succès.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Activer la clé de l'appareil", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Générez une clé de périphérique pour ce compte récupéré. Obligatoire avant l'envoi.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Activer", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Échec de l'activation de la clé de l'appareil. Essayer à nouveau.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Révéler la clé de l'appareil", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "La touche (raccourcie) de votre appareil peut être tournée depuis Paramètres → Rotation de la clé de l'appareil, ce qui révèle que ses enjeux sont inférieurs à ceux de votre seed phrase — mais toute personne qui la détient peut signer des transactions jusqu'à ce que vous effectuiez la rotation.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Clé privée de l'appareil", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Clé publique de l'appareil", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Clé privée de récupération (à froid)", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Clé publique de récupération (à froid)", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Votre compte Guardian est protégé par trois clés : une touche de périphérique (raccourcie) pour la signature de routine, une clé de récupération (à froid) dérivée de votre seed phrase pour les opérations de gestion de compte et un cosignataire tuteur externe. Vous trouverez ci-dessous votre clé privée froide ainsi que les deux clés publiques. Pour révéler la clé privée de l'appareil, utilisez Paramètres → Révéler la clé de l'appareil.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Avant de révéler la clé de votre appareil", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Toute personne possédant cette clé peut signer des transactions jusqu'à ce que vous la tourniez. Si la clé fuit, faites-la pivoter via Paramètres → Rotation de la clé de l'appareil.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Saisissez une URL valide commençant par http:// ou https://", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/ja/messages.json b/public/_locales/ja/messages.json index 59b3ae81b..905b6b785 100644 --- a/public/_locales/ja/messages.json +++ b/public/_locales/ja/messages.json @@ -1490,6 +1490,10 @@ "message": "誰とも共有しないでください。", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "seed phrase は回復キーです。日常のトランザクションではなく、デバイス キーをローテーションするために使用されます。", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "選択する", "note": "Onboarding", @@ -1672,6 +1676,14 @@ "message": "Guardian アカウントを新しいガーディアン エンドポイントに向けます。スイッチはオンチェーン プロポーザルとして署名されます。", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "これは回復 (コールド) キーで署名され、現在の保護者によって共同署名されています。資金とアカウントはそのまま残ります。続く?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "スイッチの確認", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "守護者を切り替えます...", "englishSource": "Switching guardian..." @@ -1684,6 +1696,78 @@ "message": "それはすでにあなたの現在の保護者です。", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "デバイスキーをローテーションする", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "新しいデバイス (ホット) キーを生成し、オンチェーン上の現在のキーを置き換えます。デバイスが侵害されている疑いがある場合に役立ちます。", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "これは回復 (コールド) キーで署名されています。 seed phrase は必要ありません。資金とアカウントは変わりません。続く?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "回転確認", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "デバイスキーが正常にローテーションされました。", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "デバイスキーをアクティブ化する", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "この回復されたアカウントのデバイス キーを生成します。送信する前に必須です。", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "活性化", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "デバイスキーの有効化に失敗しました。もう一度やり直してください。", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "デバイスキーを明らかにする", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "デバイス (ホット) キーは [設定] → [デバイス キーの回転] で回転できるため、seed phrase よりも公開するリスクは低くなります。ただし、デバイス キーを保持している人は誰でも、あなたが回転するまでトランザクションに署名できます。", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "デバイスの秘密鍵", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "デバイスの公開鍵", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "リカバリ (コールド) 秘密キー", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "リカバリ (コールド) 公開キー", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Guardian アカウントは、日常的な署名用のデバイス (ホット) キー、アカウント管理操作用の seed phrase から派生した回復 (コールド) キー、および外部ガーディアン共同署名者の 3 つのキーによって保護されています。以下は、コールド秘密キーと両方の公開キーです。デバイスの秘密キーを公開するには、[設定] → [デバイス キーの公開] を使用します。", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "デバイスキーを公開する前に", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "このキーを持っている人は誰でも、キーをローテーションするまでトランザクションに署名できます。キーが漏洩した場合は、「設定」→「デバイス キーのローテーション」でキーをローテーションします。", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "http:// または https:// で始まる有効な URL を入力してください", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/ko/messages.json b/public/_locales/ko/messages.json index 49ad1060e..70aa2bcbb 100644 --- a/public/_locales/ko/messages.json +++ b/public/_locales/ko/messages.json @@ -1490,6 +1490,10 @@ "message": "누구와도 공유하지 마십시오.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "seed phrase은 복구 키입니다. 일상적인 거래가 아닌 장치 키를 교체하는 데 사용됩니다.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "선택", "note": "Onboarding", @@ -1672,6 +1676,14 @@ "message": "Guardian 계정을 새로운 Guardian 엔드포인트로 지정하세요. 스위치는 온체인 제안으로 서명됩니다.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "이는 복구(콜드) 키로 서명되며 현재 보호자가 공동 서명합니다. 귀하의 자금과 계좌는 동일하게 유지됩니다. 계속하다?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "스위치 확인", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "보호자를 바꾸는 중...", "englishSource": "Switching guardian..." @@ -1684,6 +1696,78 @@ "message": "그 사람은 이미 현재 보호자입니다.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "장치 키 회전", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "새 장치(핫) 키를 생성하고 현재 온체인 키를 교체합니다. 장치가 손상된 것으로 의심되는 경우 유용합니다.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "이는 복구(콜드) 키로 서명됩니다. 귀하의 seed phrase은 필요하지 않으며 귀하의 자금과 계좌는 동일하게 유지됩니다. 계속하다?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "순환 확인", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "장치 키가 성공적으로 순환되었습니다.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "장치 키 활성화", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "복구된 계정에 대한 장치 키를 생성하세요. 보내기 전에 필요합니다.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "활성화", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "장치 키를 활성화하지 못했습니다. 다시 시도해 보세요.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "장치 키 공개", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "귀하의 장치(핫) 키는 설정 → 장치 키 회전에서 회전 가능하므로 이를 공개하는 것은 seed phrase보다 위험이 낮습니다. 하지만 키를 보유하고 있는 사람은 귀하가 회전할 때까지 거래에 서명할 수 있습니다.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "장치 개인 키", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "장치 공개 키", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "복구(콜드) 개인 키", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "복구(콜드) 공개 키", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "귀하의 Guardian 계정은 정기 서명을 위한 장치(핫) 키, 계정 관리 작업을 위한 seed phrase에서 파생된 복구(콜드) 키, 외부 보호자 공동 서명자의 세 가지 키로 보호됩니다. 다음은 콜드 개인 키와 두 공개 키입니다. 장치 개인 키를 공개하려면 설정 → 장치 키 공개를 사용하세요.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "장치 키를 공개하기 전에", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "이 키를 가진 사람은 누구나 교체할 때까지 거래에 서명할 수 있습니다. 키가 유출된 경우 설정 → 기기 키 회전을 통해 키를 회전하세요.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "http:// 또는 https://로 시작하는 유효한 URL을 입력하세요.", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/pl/messages.json b/public/_locales/pl/messages.json index c565e40e0..2d0d541e9 100644 --- a/public/_locales/pl/messages.json +++ b/public/_locales/pl/messages.json @@ -1450,6 +1450,10 @@ "message": "Nie udostępniaj nikomu.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Twój seed phrase to klucz odzyskiwania — używany do rotacji kluczy urządzenia, a nie do codziennych transakcji.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Wybierać", "englishSource": "Select" @@ -1614,6 +1618,14 @@ "message": "Skieruj swoje konto Guardiana na nowy punkt końcowy opiekuna. Przełącznik jest podpisany jako propozycja on-chain.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "Jest to podpisane Twoim (zimnym) kluczem odzyskiwania i podpisane przez Twojego obecnego opiekuna. Twoje środki i konto pozostają takie same. Kontynuować?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Potwierdź przełącznik", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Zmiana opiekuna...", "englishSource": "Switching guardian..." @@ -1626,6 +1638,78 @@ "message": "To już twój obecny opiekun.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Obróć klucz urządzenia", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Wygeneruj nowy (gorący) klucz urządzenia i zastąp bieżący w łańcuchu. Przydatne, jeśli podejrzewasz, że Twoje urządzenie zostało naruszone.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "Jest to podpisane za pomocą klucza odzyskiwania (zimnego). Twój seed phrase nie jest wymagany, a Twoje środki i konto pozostają takie same. Kontynuować?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Potwierdź obrót", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "Klucz urządzenia został pomyślnie obrócony.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Aktywuj klucz urządzenia", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Wygeneruj klucz urządzenia dla tego odzyskanego konta. Wymagane przed wysłaniem.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Aktywować", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Nie udało się aktywować klucza urządzenia. Spróbuj ponownie.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Odkryj klucz urządzenia", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "Twój klucz urządzenia (gorący) można obracać, wybierając Ustawienia → Obróć klucz urządzenia, więc ujawnienie go jest obarczone niższą stawką niż klucz seed phrase — ale każdy, kto go trzyma, może podpisywać transakcje do czasu rotacji.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Klucz prywatny urządzenia", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Klucz publiczny urządzenia", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Odzyskiwanie (zimnego) klucza prywatnego", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Klucz publiczny odzyskiwania (zimny).", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Twoje konto Guardian jest chronione trzema kluczami: kluczem urządzenia (gorącym) do rutynowego podpisywania, kluczem odzyskiwania (zimnym) uzyskanym z Twojego seed phrase do operacji zarządzania kontem oraz współsygnatariuszem zewnętrznego opiekuna. Poniżej znajduje się Twój zimny klucz prywatny oraz oba klucze publiczne. Aby ujawnić klucz prywatny urządzenia, użyj opcji Ustawienia → Pokaż klucz urządzenia.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Zanim ujawnisz klucz swojego urządzenia", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Każda osoba posiadająca ten klucz może podpisywać transakcje, dopóki go nie obrócisz. Jeśli klucz wyciekł, obróć go, wybierając Ustawienia → Obróć klucz urządzenia.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Wprowadź prawidłowy adres URL zaczynający się od http:// lub https://", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/pt/messages.json b/public/_locales/pt/messages.json index ed8f5b1fc..6d5341848 100644 --- a/public/_locales/pt/messages.json +++ b/public/_locales/pt/messages.json @@ -1489,6 +1489,10 @@ "message": "Não compartilhe com ninguém.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Seu seed phrase é sua chave de recuperação — usada para alternar chaves de dispositivos, não para transações diárias.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Selecione", "note": "Onboarding", @@ -1670,6 +1674,14 @@ "message": "Aponte sua conta do Guardian para um novo endpoint do Guardian. A mudança é assinada como uma proposta on-chain.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "Este é assinado com sua chave de recuperação (fria) e co-assinado pelo seu responsável atual. Seus fundos e conta permanecem os mesmos. Continuar?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Confirmar mudança", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Trocando de guardião...", "englishSource": "Switching guardian..." @@ -1682,6 +1694,78 @@ "message": "Esse já é seu guardião atual.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Girar tecla do dispositivo", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Gere uma nova chave de dispositivo (hot) e substitua a atual na cadeia. Útil se você suspeitar que seu dispositivo está comprometido.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "Isto é assinado com sua chave de recuperação (fria). Seu seed phrase não é necessário e seus fundos e conta permanecem os mesmos. Continuar?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Confirmar rotação", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "Chave do dispositivo girada com sucesso.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Ativar chave do dispositivo", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Gere uma chave de dispositivo para esta conta recuperada. Obrigatório antes do envio.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Ativar", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Falha ao ativar a chave do dispositivo. Tente novamente.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Revelar chave do dispositivo", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "A tecla (de atalho) do seu dispositivo pode ser girada em Configurações → Girar chave do dispositivo, portanto, revelá-la tem riscos mais baixos do que seu seed phrase - mas qualquer pessoa que a possua pode assinar transações até que você gire.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Chave privada do dispositivo", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Chave pública do dispositivo", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Chave privada de recuperação (fria)", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Chave pública de recuperação (fria)", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Sua conta Guardian é protegida por três chaves: uma chave de dispositivo (quente) para assinatura de rotina, uma chave de recuperação (fria) derivada de seu seed phrase para operações de gerenciamento de conta e um fiador guardião externo. Abaixo estão sua chave privada fria e ambas as chaves públicas. Para revelar a chave privada do dispositivo, use Configurações → Revelar chave do dispositivo.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Antes de revelar a chave do seu dispositivo", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Qualquer pessoa com esta chave pode assinar transações até que você a gire. Se a chave vazar, gire-a em Configurações → Girar chave do dispositivo.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Insira um URL válido começando com http:// ou https://", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/ru/messages.json b/public/_locales/ru/messages.json index 3079a6070..e16a76b6e 100644 --- a/public/_locales/ru/messages.json +++ b/public/_locales/ru/messages.json @@ -1490,6 +1490,10 @@ "message": "Не делитесь ни с кем.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Ваш seed phrase — это ваш ключ восстановления, который используется для смены ключей устройства, а не для повседневных транзакций.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Выбрать", "note": "Onboarding", @@ -1673,6 +1677,14 @@ "message": "Направьте свою учетную запись Guardian на новую конечную точку Guardian. Переключатель подписывается как предложение в цепочке.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "Он подписан вашим ключом восстановления (холодным) и подписан вашим нынешним опекуном. Ваши средства и счет останутся прежними. Продолжать?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Подтвердить переключение", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Смена опекуна...", "englishSource": "Switching guardian..." @@ -1685,6 +1697,78 @@ "message": "Это уже ваш нынешний опекун.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Поворот ключа устройства", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Создайте новый (горячий) ключ устройства и замените текущий в цепочке. Полезно, если вы подозреваете, что ваше устройство взломано.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "Он подписан вашим ключом восстановления (холодным). Ваш seed phrase не требуется, а ваши средства и счет останутся прежними. Продолжать?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Подтвердить ротацию", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "Ключ устройства успешно поменялся.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Активировать ключ устройства", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Создайте ключ устройства для этой восстановленной учетной записи. Обязательно перед отправкой.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Активировать", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Не удалось активировать ключ устройства. Попробуйте еще раз.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Показать ключ устройства", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "(Горячую) клавишу вашего устройства можно менять в меню «Настройки» → «Поменять ключ устройства», поэтому ее раскрытие менее важно, чем ваш seed phrase — но любой, кто ее держит, может подписывать транзакции до тех пор, пока вы не поменяете ее.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Закрытый ключ устройства", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Открытый ключ устройства", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Восстановление (холодного) закрытого ключа", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Открытый ключ восстановления (холодный)", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Ваша учетная запись Guardian защищена тремя ключами: ключом устройства (горячим) для обычного подписания, ключом восстановления (холодным) ключом, полученным из вашего seed phrase для операций управления учетной записью, и внешним подписавшим лицом-опекуном. Ниже приведены ваш холодный закрытый ключ и оба открытых ключа. Чтобы раскрыть закрытый ключ устройства, выберите «Настройки» → «Показать ключ устройства».", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Прежде чем раскрыть ключ вашего устройства", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Любой, у кого есть этот ключ, может подписывать транзакции, пока вы его не поменяете. Если ключ утек, поверните его через «Настройки» → «Повернуть ключ устройства».", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Введите действительный URL-адрес, начинающийся с http:// или https://.", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/tr/messages.json b/public/_locales/tr/messages.json index 780e8be4a..6f895d5a8 100644 --- a/public/_locales/tr/messages.json +++ b/public/_locales/tr/messages.json @@ -1490,6 +1490,10 @@ "message": "Kimseyle paylaşmayın.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "seed phrase kurtarma anahtarınızdır; günlük işlemler için değil, cihaz anahtarlarını döndürmek için kullanılır.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Seç", "noteeee": "Onboarding", @@ -1672,6 +1676,14 @@ "message": "Guardian hesabınızı yeni bir koruyucu uç noktasına yönlendirin. Anahtar, zincir içi bir teklif olarak imzalandı.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "Bu, kurtarma (soğuk) anahtarınızla imzalanır ve mevcut vasiniz tarafından da imzalanır. Paranız ve hesabınız aynı kalır. Devam etmek?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Anahtarı onayla", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Vasi değiştiriliyor...", "englishSource": "Switching guardian..." @@ -1684,6 +1696,78 @@ "message": "Bu zaten sizin şu anki vasiniz.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Cihaz anahtarını döndür", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Yeni bir cihaz (geçiş) anahtarı oluşturun ve zincirdeki mevcut olanı değiştirin. Cihazınızın güvenliğinin ihlal edildiğinden şüpheleniyorsanız kullanışlıdır.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "Bu, kurtarma (soğuk) anahtarınızla imzalanır. seed phrase kodunuz gerekli değildir ve paranız ve hesabınız aynı kalır. Devam etmek?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Döndürmeyi onayla", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "Cihaz anahtarı başarıyla döndürüldü.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Cihaz anahtarını etkinleştir", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Kurtarılan bu hesap için bir cihaz anahtarı oluşturun. Göndermeden önce gereklidir.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "Etkinleştir", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Cihaz anahtarı etkinleştirilemedi. Tekrar deneyin.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Cihaz anahtarını göster", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "Cihaz (geçiş) anahtarınız, Ayarlar → Cihaz Anahtarını Döndür seçeneğinden döndürülebilir; bu nedenle, bunun açığa çıkarılması seed phrase anahtarınızdan daha düşük risklidir; ancak onu tutan herkes, siz rotasyon yapana kadar işlemleri imzalayabilir.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Cihaz Özel Anahtarı", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Cihaz Genel Anahtarı", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Kurtarma (Soğuk) Özel Anahtar", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Kurtarma (Soğuk) Genel Anahtar", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Guardian hesabınız üç anahtarla korunur: rutin imzalama için bir cihaz (geçici) anahtar, hesap yönetimi işlemleri için seed phrase hesabınızdan türetilen bir kurtarma (soğuk) anahtar ve harici bir veli ortak imzalayan. Aşağıda soğuk özel anahtarınız ve her iki genel anahtarınız bulunmaktadır. Cihazın özel anahtarını ortaya çıkarmak için Ayarlar → Cihaz Anahtarını Göster seçeneğini kullanın.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Cihaz anahtarınızı açıklamadan önce", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Bu anahtara sahip olan herkes, siz onu değiştirene kadar işlemleri imzalayabilir. Anahtar sızdırılmışsa, Ayarlar → Cihaz Anahtarını Döndür seçeneğini kullanarak onu döndürün.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "http:// veya https:// ile başlayan geçerli bir URL girin", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/uk/messages.json b/public/_locales/uk/messages.json index 044a16f92..379d00061 100644 --- a/public/_locales/uk/messages.json +++ b/public/_locales/uk/messages.json @@ -1490,6 +1490,10 @@ "message": "Ні з ким не діліться.", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "Ваш seed phrase є вашим ключем відновлення, який використовується для ротації ключів пристрою, а не для щоденних транзакцій.", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "Вибрати", "note": "Onboarding", @@ -1673,6 +1677,14 @@ "message": "Направте свій обліковий запис Guardian на нову кінцеву точку опікуна. Комутатор підписаний як пропозиція в ланцюжку.", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "Це підписано вашим (холодним) ключем відновлення та підписано вашим поточним опікуном. Ваші кошти та рахунок залишаються без змін. Продовжити?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "Підтвердьте перемикання", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "Зміна опікуна...", "englishSource": "Switching guardian..." @@ -1685,6 +1697,78 @@ "message": "Це вже ваш поточний опікун.", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "Поверніть клавішу пристрою", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "Згенеруйте новий (гарячий) ключ пристрою та замініть поточний у мережі. Корисно, якщо ви підозрюєте, що ваш пристрій зламано.", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "Це підписано вашим (холодним) ключем відновлення. Ваш seed phrase не потрібен, а ваші кошти та рахунок залишаються незмінними. Продовжити?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "Підтвердити обертання", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "Ключ пристрою успішно повернуто.", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "Активуйте ключ пристрою", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "Згенеруйте ключ пристрою для цього відновленого облікового запису. Обов'язково перед відправкою.", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "активувати", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "Не вдалося активувати ключ пристрою. Спробуйте знову.", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "Розкрити ключ пристрою", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "Ваш (гарячий) ключ пристрою можна обертати в меню «Налаштування» → «Обертати ключ пристрою», тому його розкриття є нижчим, ніж ваш seed phrase — але будь-хто, хто його тримає, може підписувати транзакції, доки ви не повернете його.", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "Приватний ключ пристрою", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "Відкритий ключ пристрою", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "Приватний ключ відновлення (холодний).", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "Відновлення (холодного) відкритого ключа", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "Ваш обліковий запис опікуна захищено трьома ключами: ключем пристрою (гарячим) для звичайного підпису, ключем відновлення (холодним), отриманим із вашого seed phrase для операцій керування обліковим записом, і зовнішнім співпідписувачем опікуна. Нижче ваш холодний приватний ключ плюс обидва відкритих ключі. Щоб розкрити закритий ключ пристрою, скористайтеся Налаштуваннями → Відкрити ключ пристрою.", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "Перш ніж відкрити ключ свого пристрою", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "Будь-хто з цим ключем може підписувати транзакції, доки ви його не повернете. Якщо ключ витік, поверніть його через Налаштування → Обернути ключ пристрою.", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "Введіть дійсну URL-адресу, яка починається з http:// або https://", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/zh_CN/messages.json b/public/_locales/zh_CN/messages.json index 3948dafb9..e59c345c9 100644 --- a/public/_locales/zh_CN/messages.json +++ b/public/_locales/zh_CN/messages.json @@ -1508,6 +1508,10 @@ "message": "不要与任何人分享。", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "您的 seed phrase 是您的恢复密钥 — 用于轮换设备密钥,而不是用于日常交易。", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "选择", "note": "Onboarding", @@ -1690,6 +1694,14 @@ "message": "将您的监护人帐户指向新的监护人端点。该切换被签署为链上提案。", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "该密钥使用您的恢复(冷)密钥进行签名,并由您当前的监护人共同签署。您的资金和账户保持不变。继续?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "确认切换", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "切换监护人...", "englishSource": "Switching guardian..." @@ -1702,6 +1714,78 @@ "message": "那已经是你现在的监护人了。", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "旋转设备密钥", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "生成一个新的设备(热)密钥并替换链上当前的设备密钥。如果您怀疑您的设备受到威胁,这很有用。", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "这是用您的恢复(冷)密钥签名的。您的 seed phrase 不是必需的,您的资金和帐户保持不变。继续?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "确认轮换", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "设备密钥轮换成功。", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "激活设备密钥", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "为该恢复的帐户生成设备密钥。发送前需要。", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "激活", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "无法激活设备密钥。再试一次。", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "显示设备密钥", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "您的设备(热)密钥可通过“设置”→“旋转设备密钥”进行旋转,因此暴露它的风险比您的 seed phrase 低 - 但持有它的任何人都可以签署交易,直到您旋转为止。", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "设备私钥", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "设备公钥", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "恢复(冷)私钥", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "恢复(冷)公钥", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "您的监护人帐户受到三个密钥的保护:用于例行签名的设备(热)密钥、从您的 seed phrase 派生的用于帐户管理操作的恢复(冷)密钥,以及外部监护人共同签名者。下面是您的冷私钥和两个公钥。要显示设备私钥,请使用“设置”→“显示设备密钥”。", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "在透露您的设备密钥之前", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "拥有此密钥的任何人都可以签署交易,直到您轮换它为止。如果密钥泄漏,请通过“设置”→“轮换设备密钥”进行轮换。", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "输入以 http:// 或 https:// 开头的有效 URL", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/public/_locales/zh_TW/messages.json b/public/_locales/zh_TW/messages.json index e91b86828..4550dfb54 100644 --- a/public/_locales/zh_TW/messages.json +++ b/public/_locales/zh_TW/messages.json @@ -1499,6 +1499,10 @@ "message": "不要与任何人分享。", "englishSource": "Do not share with anyone." }, + "seedPhraseRecoveryCaption": { + "message": "您的 seed phrase 是您的恢复密钥 — 用于轮换设备密钥,而不是用于日常交易。", + "englishSource": "Your seed phrase is your recovery key — used to rotate device keys, not for everyday transactions." + }, "select": { "message": "選擇", "note": "Onboarding", @@ -1681,6 +1685,14 @@ "message": "将您的监护人帐户指向新的监护人端点。该切换被签署为链上提案。", "englishSource": "Point your Guardian account at a new guardian endpoint. The switch is signed as an on-chain proposal." }, + "switchGuardianConfirmation": { + "message": "该密钥使用您的恢复(冷)密钥进行签名,并由您当前的监护人共同签署。您的资金和账户保持不变。继续?", + "englishSource": "This is signed with your recovery (cold) key and co-signed by your current guardian. Your funds and account stay the same. Continue?" + }, + "confirmSwitchGuardian": { + "message": "确认切换", + "englishSource": "Confirm switch" + }, "switchingGuardian": { "message": "切换监护人...", "englishSource": "Switching guardian..." @@ -1693,6 +1705,78 @@ "message": "那已经是你现在的监护人了。", "englishSource": "That's already your current guardian." }, + "replaceHotKey": { + "message": "旋转设备密钥", + "englishSource": "Rotate device key" + }, + "replaceHotKeyDescription": { + "message": "生成一个新的设备(热)密钥并替换链上当前的设备密钥。如果您怀疑您的设备受到威胁,这很有用。", + "englishSource": "Generate a new device (hot) key and replace the current one on-chain. Useful if you suspect your device is compromised." + }, + "replaceHotKeyConfirmation": { + "message": "这是用您的恢复(冷)密钥签名的。您的 seed phrase 不是必需的,您的资金和帐户保持不变。继续?", + "englishSource": "This is signed with your recovery (cold) key. Your seed phrase is not required, and your funds and account stay the same. Continue?" + }, + "confirmReplaceHotKey": { + "message": "确认轮换", + "englishSource": "Confirm rotation" + }, + "hotKeyRotated": { + "message": "设备密钥轮换成功。", + "englishSource": "Device key rotated successfully." + }, + "activateHotKeyBannerTitle": { + "message": "激活设备密钥", + "englishSource": "Activate device key" + }, + "activateHotKeyBannerBody": { + "message": "为该恢复的帐户生成设备密钥。发送前需要。", + "englishSource": "Generate a device key for this recovered account. Required before sending." + }, + "activateHotKeyBannerCta": { + "message": "激活", + "englishSource": "Activate" + }, + "activateHotKeyBannerError": { + "message": "无法激活设备密钥。再试一次。", + "englishSource": "Failed to activate device key. Try again." + }, + "revealHotKey": { + "message": "显示设备密钥", + "englishSource": "Reveal device key" + }, + "revealHotKeyDescription": { + "message": "您的设备(热)密钥可通过“设置”→“旋转设备密钥”进行旋转,因此暴露它的风险比您的 seed phrase 低 - 但持有它的任何人都可以签署交易,直到您旋转为止。", + "englishSource": "Your device (hot) key is rotatable from Settings → Rotate Device Key, so revealing it is lower-stakes than your seed phrase — but anyone who holds it can sign transactions until you rotate." + }, + "hotPrivateKey": { + "message": "设备私钥", + "englishSource": "Device Private Key" + }, + "hotPublicKeyLabel": { + "message": "设备公钥", + "englishSource": "Device Public Key" + }, + "coldPrivateKey": { + "message": "恢复(冷)私钥", + "englishSource": "Recovery (Cold) Private Key" + }, + "coldPublicKeyLabel": { + "message": "恢复(冷)公钥", + "englishSource": "Recovery (Cold) Public Key" + }, + "guardianKeysRevealDescription": { + "message": "您的监护人帐户受到三个密钥的保护:用于例行签名的设备(热)密钥、从您的 seed phrase 派生的用于帐户管理操作的恢复(冷)密钥,以及外部监护人共同签名者。下面是您的冷私钥和两个公钥。要显示设备私钥,请使用“设置”→“显示设备密钥”。", + "englishSource": "Your Guardian account is protected by three keys: a device (hot) key for routine signing, a recovery (cold) key derived from your seed phrase for account-management operations, and an external guardian co-signer. Below are your cold private key plus both public keys. To reveal the device private key, use Settings → Reveal Device Key." + }, + "hotKeyRevealWarningTitle": { + "message": "在透露您的设备密钥之前", + "englishSource": "Before you reveal your device key" + }, + "hotKeyRevealWarningBody": { + "message": "拥有此密钥的任何人都可以签署交易,直到您轮换它为止。如果密钥泄漏,请通过“设置”→“轮换设备密钥”进行轮换。", + "englishSource": "Anyone with this key can sign transactions until you rotate it. If the key is leaked, rotate it via Settings → Rotate Device Key." + }, "invalidUrl": { "message": "输入以 http:// 或 https:// 开头的有效 URL", "englishSource": "Enter a valid URL starting with http:// or https://" diff --git a/src-tauri/gen/schemas/acl-manifests.json b/src-tauri/gen/schemas/acl-manifests.json index 86cdb1f5f..f43b85280 100644 --- a/src-tauri/gen/schemas/acl-manifests.json +++ b/src-tauri/gen/schemas/acl-manifests.json @@ -1 +1 @@ -{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-internal-toggle-maximize"]},"permissions":{"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file +{"core":{"default_permission":{"identifier":"default","description":"Default core plugins set.","permissions":["core:path:default","core:event:default","core:window:default","core:webview:default","core:app:default","core:image:default","core:resources:default","core:menu:default","core:tray:default"]},"permissions":{},"permission_sets":{},"global_scope_schema":null},"core:app":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-version","allow-name","allow-tauri-version","allow-identifier","allow-bundle-type","allow-register-listener","allow-remove-listener","allow-supports-multiple-windows"]},"permissions":{"allow-app-hide":{"identifier":"allow-app-hide","description":"Enables the app_hide command without any pre-configured scope.","commands":{"allow":["app_hide"],"deny":[]}},"allow-app-show":{"identifier":"allow-app-show","description":"Enables the app_show command without any pre-configured scope.","commands":{"allow":["app_show"],"deny":[]}},"allow-bundle-type":{"identifier":"allow-bundle-type","description":"Enables the bundle_type command without any pre-configured scope.","commands":{"allow":["bundle_type"],"deny":[]}},"allow-default-window-icon":{"identifier":"allow-default-window-icon","description":"Enables the default_window_icon command without any pre-configured scope.","commands":{"allow":["default_window_icon"],"deny":[]}},"allow-fetch-data-store-identifiers":{"identifier":"allow-fetch-data-store-identifiers","description":"Enables the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":["fetch_data_store_identifiers"],"deny":[]}},"allow-identifier":{"identifier":"allow-identifier","description":"Enables the identifier command without any pre-configured scope.","commands":{"allow":["identifier"],"deny":[]}},"allow-name":{"identifier":"allow-name","description":"Enables the name command without any pre-configured scope.","commands":{"allow":["name"],"deny":[]}},"allow-register-listener":{"identifier":"allow-register-listener","description":"Enables the register_listener command without any pre-configured scope.","commands":{"allow":["register_listener"],"deny":[]}},"allow-remove-data-store":{"identifier":"allow-remove-data-store","description":"Enables the remove_data_store command without any pre-configured scope.","commands":{"allow":["remove_data_store"],"deny":[]}},"allow-remove-listener":{"identifier":"allow-remove-listener","description":"Enables the remove_listener command without any pre-configured scope.","commands":{"allow":["remove_listener"],"deny":[]}},"allow-set-app-theme":{"identifier":"allow-set-app-theme","description":"Enables the set_app_theme command without any pre-configured scope.","commands":{"allow":["set_app_theme"],"deny":[]}},"allow-set-dock-visibility":{"identifier":"allow-set-dock-visibility","description":"Enables the set_dock_visibility command without any pre-configured scope.","commands":{"allow":["set_dock_visibility"],"deny":[]}},"allow-supports-multiple-windows":{"identifier":"allow-supports-multiple-windows","description":"Enables the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":["supports_multiple_windows"],"deny":[]}},"allow-tauri-version":{"identifier":"allow-tauri-version","description":"Enables the tauri_version command without any pre-configured scope.","commands":{"allow":["tauri_version"],"deny":[]}},"allow-version":{"identifier":"allow-version","description":"Enables the version command without any pre-configured scope.","commands":{"allow":["version"],"deny":[]}},"deny-app-hide":{"identifier":"deny-app-hide","description":"Denies the app_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["app_hide"]}},"deny-app-show":{"identifier":"deny-app-show","description":"Denies the app_show command without any pre-configured scope.","commands":{"allow":[],"deny":["app_show"]}},"deny-bundle-type":{"identifier":"deny-bundle-type","description":"Denies the bundle_type command without any pre-configured scope.","commands":{"allow":[],"deny":["bundle_type"]}},"deny-default-window-icon":{"identifier":"deny-default-window-icon","description":"Denies the default_window_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["default_window_icon"]}},"deny-fetch-data-store-identifiers":{"identifier":"deny-fetch-data-store-identifiers","description":"Denies the fetch_data_store_identifiers command without any pre-configured scope.","commands":{"allow":[],"deny":["fetch_data_store_identifiers"]}},"deny-identifier":{"identifier":"deny-identifier","description":"Denies the identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["identifier"]}},"deny-name":{"identifier":"deny-name","description":"Denies the name command without any pre-configured scope.","commands":{"allow":[],"deny":["name"]}},"deny-register-listener":{"identifier":"deny-register-listener","description":"Denies the register_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["register_listener"]}},"deny-remove-data-store":{"identifier":"deny-remove-data-store","description":"Denies the remove_data_store command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_data_store"]}},"deny-remove-listener":{"identifier":"deny-remove-listener","description":"Denies the remove_listener command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_listener"]}},"deny-set-app-theme":{"identifier":"deny-set-app-theme","description":"Denies the set_app_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_app_theme"]}},"deny-set-dock-visibility":{"identifier":"deny-set-dock-visibility","description":"Denies the set_dock_visibility command without any pre-configured scope.","commands":{"allow":[],"deny":["set_dock_visibility"]}},"deny-supports-multiple-windows":{"identifier":"deny-supports-multiple-windows","description":"Denies the supports_multiple_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["supports_multiple_windows"]}},"deny-tauri-version":{"identifier":"deny-tauri-version","description":"Denies the tauri_version command without any pre-configured scope.","commands":{"allow":[],"deny":["tauri_version"]}},"deny-version":{"identifier":"deny-version","description":"Denies the version command without any pre-configured scope.","commands":{"allow":[],"deny":["version"]}}},"permission_sets":{},"global_scope_schema":null},"core:event":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-listen","allow-unlisten","allow-emit","allow-emit-to"]},"permissions":{"allow-emit":{"identifier":"allow-emit","description":"Enables the emit command without any pre-configured scope.","commands":{"allow":["emit"],"deny":[]}},"allow-emit-to":{"identifier":"allow-emit-to","description":"Enables the emit_to command without any pre-configured scope.","commands":{"allow":["emit_to"],"deny":[]}},"allow-listen":{"identifier":"allow-listen","description":"Enables the listen command without any pre-configured scope.","commands":{"allow":["listen"],"deny":[]}},"allow-unlisten":{"identifier":"allow-unlisten","description":"Enables the unlisten command without any pre-configured scope.","commands":{"allow":["unlisten"],"deny":[]}},"deny-emit":{"identifier":"deny-emit","description":"Denies the emit command without any pre-configured scope.","commands":{"allow":[],"deny":["emit"]}},"deny-emit-to":{"identifier":"deny-emit-to","description":"Denies the emit_to command without any pre-configured scope.","commands":{"allow":[],"deny":["emit_to"]}},"deny-listen":{"identifier":"deny-listen","description":"Denies the listen command without any pre-configured scope.","commands":{"allow":[],"deny":["listen"]}},"deny-unlisten":{"identifier":"deny-unlisten","description":"Denies the unlisten command without any pre-configured scope.","commands":{"allow":[],"deny":["unlisten"]}}},"permission_sets":{},"global_scope_schema":null},"core:image":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-from-bytes","allow-from-path","allow-rgba","allow-size"]},"permissions":{"allow-from-bytes":{"identifier":"allow-from-bytes","description":"Enables the from_bytes command without any pre-configured scope.","commands":{"allow":["from_bytes"],"deny":[]}},"allow-from-path":{"identifier":"allow-from-path","description":"Enables the from_path command without any pre-configured scope.","commands":{"allow":["from_path"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-rgba":{"identifier":"allow-rgba","description":"Enables the rgba command without any pre-configured scope.","commands":{"allow":["rgba"],"deny":[]}},"allow-size":{"identifier":"allow-size","description":"Enables the size command without any pre-configured scope.","commands":{"allow":["size"],"deny":[]}},"deny-from-bytes":{"identifier":"deny-from-bytes","description":"Denies the from_bytes command without any pre-configured scope.","commands":{"allow":[],"deny":["from_bytes"]}},"deny-from-path":{"identifier":"deny-from-path","description":"Denies the from_path command without any pre-configured scope.","commands":{"allow":[],"deny":["from_path"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-rgba":{"identifier":"deny-rgba","description":"Denies the rgba command without any pre-configured scope.","commands":{"allow":[],"deny":["rgba"]}},"deny-size":{"identifier":"deny-size","description":"Denies the size command without any pre-configured scope.","commands":{"allow":[],"deny":["size"]}}},"permission_sets":{},"global_scope_schema":null},"core:menu":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-append","allow-prepend","allow-insert","allow-remove","allow-remove-at","allow-items","allow-get","allow-popup","allow-create-default","allow-set-as-app-menu","allow-set-as-window-menu","allow-text","allow-set-text","allow-is-enabled","allow-set-enabled","allow-set-accelerator","allow-set-as-windows-menu-for-nsapp","allow-set-as-help-menu-for-nsapp","allow-is-checked","allow-set-checked","allow-set-icon"]},"permissions":{"allow-append":{"identifier":"allow-append","description":"Enables the append command without any pre-configured scope.","commands":{"allow":["append"],"deny":[]}},"allow-create-default":{"identifier":"allow-create-default","description":"Enables the create_default command without any pre-configured scope.","commands":{"allow":["create_default"],"deny":[]}},"allow-get":{"identifier":"allow-get","description":"Enables the get command without any pre-configured scope.","commands":{"allow":["get"],"deny":[]}},"allow-insert":{"identifier":"allow-insert","description":"Enables the insert command without any pre-configured scope.","commands":{"allow":["insert"],"deny":[]}},"allow-is-checked":{"identifier":"allow-is-checked","description":"Enables the is_checked command without any pre-configured scope.","commands":{"allow":["is_checked"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-items":{"identifier":"allow-items","description":"Enables the items command without any pre-configured scope.","commands":{"allow":["items"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-popup":{"identifier":"allow-popup","description":"Enables the popup command without any pre-configured scope.","commands":{"allow":["popup"],"deny":[]}},"allow-prepend":{"identifier":"allow-prepend","description":"Enables the prepend command without any pre-configured scope.","commands":{"allow":["prepend"],"deny":[]}},"allow-remove":{"identifier":"allow-remove","description":"Enables the remove command without any pre-configured scope.","commands":{"allow":["remove"],"deny":[]}},"allow-remove-at":{"identifier":"allow-remove-at","description":"Enables the remove_at command without any pre-configured scope.","commands":{"allow":["remove_at"],"deny":[]}},"allow-set-accelerator":{"identifier":"allow-set-accelerator","description":"Enables the set_accelerator command without any pre-configured scope.","commands":{"allow":["set_accelerator"],"deny":[]}},"allow-set-as-app-menu":{"identifier":"allow-set-as-app-menu","description":"Enables the set_as_app_menu command without any pre-configured scope.","commands":{"allow":["set_as_app_menu"],"deny":[]}},"allow-set-as-help-menu-for-nsapp":{"identifier":"allow-set-as-help-menu-for-nsapp","description":"Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_help_menu_for_nsapp"],"deny":[]}},"allow-set-as-window-menu":{"identifier":"allow-set-as-window-menu","description":"Enables the set_as_window_menu command without any pre-configured scope.","commands":{"allow":["set_as_window_menu"],"deny":[]}},"allow-set-as-windows-menu-for-nsapp":{"identifier":"allow-set-as-windows-menu-for-nsapp","description":"Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":["set_as_windows_menu_for_nsapp"],"deny":[]}},"allow-set-checked":{"identifier":"allow-set-checked","description":"Enables the set_checked command without any pre-configured scope.","commands":{"allow":["set_checked"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-text":{"identifier":"allow-set-text","description":"Enables the set_text command without any pre-configured scope.","commands":{"allow":["set_text"],"deny":[]}},"allow-text":{"identifier":"allow-text","description":"Enables the text command without any pre-configured scope.","commands":{"allow":["text"],"deny":[]}},"deny-append":{"identifier":"deny-append","description":"Denies the append command without any pre-configured scope.","commands":{"allow":[],"deny":["append"]}},"deny-create-default":{"identifier":"deny-create-default","description":"Denies the create_default command without any pre-configured scope.","commands":{"allow":[],"deny":["create_default"]}},"deny-get":{"identifier":"deny-get","description":"Denies the get command without any pre-configured scope.","commands":{"allow":[],"deny":["get"]}},"deny-insert":{"identifier":"deny-insert","description":"Denies the insert command without any pre-configured scope.","commands":{"allow":[],"deny":["insert"]}},"deny-is-checked":{"identifier":"deny-is-checked","description":"Denies the is_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["is_checked"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-items":{"identifier":"deny-items","description":"Denies the items command without any pre-configured scope.","commands":{"allow":[],"deny":["items"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-popup":{"identifier":"deny-popup","description":"Denies the popup command without any pre-configured scope.","commands":{"allow":[],"deny":["popup"]}},"deny-prepend":{"identifier":"deny-prepend","description":"Denies the prepend command without any pre-configured scope.","commands":{"allow":[],"deny":["prepend"]}},"deny-remove":{"identifier":"deny-remove","description":"Denies the remove command without any pre-configured scope.","commands":{"allow":[],"deny":["remove"]}},"deny-remove-at":{"identifier":"deny-remove-at","description":"Denies the remove_at command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_at"]}},"deny-set-accelerator":{"identifier":"deny-set-accelerator","description":"Denies the set_accelerator command without any pre-configured scope.","commands":{"allow":[],"deny":["set_accelerator"]}},"deny-set-as-app-menu":{"identifier":"deny-set-as-app-menu","description":"Denies the set_as_app_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_app_menu"]}},"deny-set-as-help-menu-for-nsapp":{"identifier":"deny-set-as-help-menu-for-nsapp","description":"Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_help_menu_for_nsapp"]}},"deny-set-as-window-menu":{"identifier":"deny-set-as-window-menu","description":"Denies the set_as_window_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_window_menu"]}},"deny-set-as-windows-menu-for-nsapp":{"identifier":"deny-set-as-windows-menu-for-nsapp","description":"Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.","commands":{"allow":[],"deny":["set_as_windows_menu_for_nsapp"]}},"deny-set-checked":{"identifier":"deny-set-checked","description":"Denies the set_checked command without any pre-configured scope.","commands":{"allow":[],"deny":["set_checked"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-text":{"identifier":"deny-set-text","description":"Denies the set_text command without any pre-configured scope.","commands":{"allow":[],"deny":["set_text"]}},"deny-text":{"identifier":"deny-text","description":"Denies the text command without any pre-configured scope.","commands":{"allow":[],"deny":["text"]}}},"permission_sets":{},"global_scope_schema":null},"core:path":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-resolve-directory","allow-resolve","allow-normalize","allow-join","allow-dirname","allow-extname","allow-basename","allow-is-absolute"]},"permissions":{"allow-basename":{"identifier":"allow-basename","description":"Enables the basename command without any pre-configured scope.","commands":{"allow":["basename"],"deny":[]}},"allow-dirname":{"identifier":"allow-dirname","description":"Enables the dirname command without any pre-configured scope.","commands":{"allow":["dirname"],"deny":[]}},"allow-extname":{"identifier":"allow-extname","description":"Enables the extname command without any pre-configured scope.","commands":{"allow":["extname"],"deny":[]}},"allow-is-absolute":{"identifier":"allow-is-absolute","description":"Enables the is_absolute command without any pre-configured scope.","commands":{"allow":["is_absolute"],"deny":[]}},"allow-join":{"identifier":"allow-join","description":"Enables the join command without any pre-configured scope.","commands":{"allow":["join"],"deny":[]}},"allow-normalize":{"identifier":"allow-normalize","description":"Enables the normalize command without any pre-configured scope.","commands":{"allow":["normalize"],"deny":[]}},"allow-resolve":{"identifier":"allow-resolve","description":"Enables the resolve command without any pre-configured scope.","commands":{"allow":["resolve"],"deny":[]}},"allow-resolve-directory":{"identifier":"allow-resolve-directory","description":"Enables the resolve_directory command without any pre-configured scope.","commands":{"allow":["resolve_directory"],"deny":[]}},"deny-basename":{"identifier":"deny-basename","description":"Denies the basename command without any pre-configured scope.","commands":{"allow":[],"deny":["basename"]}},"deny-dirname":{"identifier":"deny-dirname","description":"Denies the dirname command without any pre-configured scope.","commands":{"allow":[],"deny":["dirname"]}},"deny-extname":{"identifier":"deny-extname","description":"Denies the extname command without any pre-configured scope.","commands":{"allow":[],"deny":["extname"]}},"deny-is-absolute":{"identifier":"deny-is-absolute","description":"Denies the is_absolute command without any pre-configured scope.","commands":{"allow":[],"deny":["is_absolute"]}},"deny-join":{"identifier":"deny-join","description":"Denies the join command without any pre-configured scope.","commands":{"allow":[],"deny":["join"]}},"deny-normalize":{"identifier":"deny-normalize","description":"Denies the normalize command without any pre-configured scope.","commands":{"allow":[],"deny":["normalize"]}},"deny-resolve":{"identifier":"deny-resolve","description":"Denies the resolve command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve"]}},"deny-resolve-directory":{"identifier":"deny-resolve-directory","description":"Denies the resolve_directory command without any pre-configured scope.","commands":{"allow":[],"deny":["resolve_directory"]}}},"permission_sets":{},"global_scope_schema":null},"core:resources":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-close"]},"permissions":{"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}}},"permission_sets":{},"global_scope_schema":null},"core:tray":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin, which enables all commands.","permissions":["allow-new","allow-get-by-id","allow-remove-by-id","allow-set-icon","allow-set-menu","allow-set-tooltip","allow-set-title","allow-set-visible","allow-set-temp-dir-path","allow-set-icon-as-template","allow-set-icon-with-as-template","allow-set-show-menu-on-left-click"]},"permissions":{"allow-get-by-id":{"identifier":"allow-get-by-id","description":"Enables the get_by_id command without any pre-configured scope.","commands":{"allow":["get_by_id"],"deny":[]}},"allow-new":{"identifier":"allow-new","description":"Enables the new command without any pre-configured scope.","commands":{"allow":["new"],"deny":[]}},"allow-remove-by-id":{"identifier":"allow-remove-by-id","description":"Enables the remove_by_id command without any pre-configured scope.","commands":{"allow":["remove_by_id"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-icon-as-template":{"identifier":"allow-set-icon-as-template","description":"Enables the set_icon_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_as_template"],"deny":[]}},"allow-set-icon-with-as-template":{"identifier":"allow-set-icon-with-as-template","description":"Enables the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":["set_icon_with_as_template"],"deny":[]}},"allow-set-menu":{"identifier":"allow-set-menu","description":"Enables the set_menu command without any pre-configured scope.","commands":{"allow":["set_menu"],"deny":[]}},"allow-set-show-menu-on-left-click":{"identifier":"allow-set-show-menu-on-left-click","description":"Enables the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":["set_show_menu_on_left_click"],"deny":[]}},"allow-set-temp-dir-path":{"identifier":"allow-set-temp-dir-path","description":"Enables the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":["set_temp_dir_path"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-tooltip":{"identifier":"allow-set-tooltip","description":"Enables the set_tooltip command without any pre-configured scope.","commands":{"allow":["set_tooltip"],"deny":[]}},"allow-set-visible":{"identifier":"allow-set-visible","description":"Enables the set_visible command without any pre-configured scope.","commands":{"allow":["set_visible"],"deny":[]}},"deny-get-by-id":{"identifier":"deny-get-by-id","description":"Denies the get_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["get_by_id"]}},"deny-new":{"identifier":"deny-new","description":"Denies the new command without any pre-configured scope.","commands":{"allow":[],"deny":["new"]}},"deny-remove-by-id":{"identifier":"deny-remove-by-id","description":"Denies the remove_by_id command without any pre-configured scope.","commands":{"allow":[],"deny":["remove_by_id"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-icon-as-template":{"identifier":"deny-set-icon-as-template","description":"Denies the set_icon_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_as_template"]}},"deny-set-icon-with-as-template":{"identifier":"deny-set-icon-with-as-template","description":"Denies the set_icon_with_as_template command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon_with_as_template"]}},"deny-set-menu":{"identifier":"deny-set-menu","description":"Denies the set_menu command without any pre-configured scope.","commands":{"allow":[],"deny":["set_menu"]}},"deny-set-show-menu-on-left-click":{"identifier":"deny-set-show-menu-on-left-click","description":"Denies the set_show_menu_on_left_click command without any pre-configured scope.","commands":{"allow":[],"deny":["set_show_menu_on_left_click"]}},"deny-set-temp-dir-path":{"identifier":"deny-set-temp-dir-path","description":"Denies the set_temp_dir_path command without any pre-configured scope.","commands":{"allow":[],"deny":["set_temp_dir_path"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-tooltip":{"identifier":"deny-set-tooltip","description":"Denies the set_tooltip command without any pre-configured scope.","commands":{"allow":[],"deny":["set_tooltip"]}},"deny-set-visible":{"identifier":"deny-set-visible","description":"Denies the set_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible"]}}},"permission_sets":{},"global_scope_schema":null},"core:webview":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-webviews","allow-webview-position","allow-webview-size","allow-internal-toggle-devtools"]},"permissions":{"allow-clear-all-browsing-data":{"identifier":"allow-clear-all-browsing-data","description":"Enables the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":["clear_all_browsing_data"],"deny":[]}},"allow-create-webview":{"identifier":"allow-create-webview","description":"Enables the create_webview command without any pre-configured scope.","commands":{"allow":["create_webview"],"deny":[]}},"allow-create-webview-window":{"identifier":"allow-create-webview-window","description":"Enables the create_webview_window command without any pre-configured scope.","commands":{"allow":["create_webview_window"],"deny":[]}},"allow-get-all-webviews":{"identifier":"allow-get-all-webviews","description":"Enables the get_all_webviews command without any pre-configured scope.","commands":{"allow":["get_all_webviews"],"deny":[]}},"allow-internal-toggle-devtools":{"identifier":"allow-internal-toggle-devtools","description":"Enables the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":["internal_toggle_devtools"],"deny":[]}},"allow-print":{"identifier":"allow-print","description":"Enables the print command without any pre-configured scope.","commands":{"allow":["print"],"deny":[]}},"allow-reparent":{"identifier":"allow-reparent","description":"Enables the reparent command without any pre-configured scope.","commands":{"allow":["reparent"],"deny":[]}},"allow-set-webview-auto-resize":{"identifier":"allow-set-webview-auto-resize","description":"Enables the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":["set_webview_auto_resize"],"deny":[]}},"allow-set-webview-background-color":{"identifier":"allow-set-webview-background-color","description":"Enables the set_webview_background_color command without any pre-configured scope.","commands":{"allow":["set_webview_background_color"],"deny":[]}},"allow-set-webview-focus":{"identifier":"allow-set-webview-focus","description":"Enables the set_webview_focus command without any pre-configured scope.","commands":{"allow":["set_webview_focus"],"deny":[]}},"allow-set-webview-position":{"identifier":"allow-set-webview-position","description":"Enables the set_webview_position command without any pre-configured scope.","commands":{"allow":["set_webview_position"],"deny":[]}},"allow-set-webview-size":{"identifier":"allow-set-webview-size","description":"Enables the set_webview_size command without any pre-configured scope.","commands":{"allow":["set_webview_size"],"deny":[]}},"allow-set-webview-zoom":{"identifier":"allow-set-webview-zoom","description":"Enables the set_webview_zoom command without any pre-configured scope.","commands":{"allow":["set_webview_zoom"],"deny":[]}},"allow-webview-close":{"identifier":"allow-webview-close","description":"Enables the webview_close command without any pre-configured scope.","commands":{"allow":["webview_close"],"deny":[]}},"allow-webview-hide":{"identifier":"allow-webview-hide","description":"Enables the webview_hide command without any pre-configured scope.","commands":{"allow":["webview_hide"],"deny":[]}},"allow-webview-position":{"identifier":"allow-webview-position","description":"Enables the webview_position command without any pre-configured scope.","commands":{"allow":["webview_position"],"deny":[]}},"allow-webview-show":{"identifier":"allow-webview-show","description":"Enables the webview_show command without any pre-configured scope.","commands":{"allow":["webview_show"],"deny":[]}},"allow-webview-size":{"identifier":"allow-webview-size","description":"Enables the webview_size command without any pre-configured scope.","commands":{"allow":["webview_size"],"deny":[]}},"deny-clear-all-browsing-data":{"identifier":"deny-clear-all-browsing-data","description":"Denies the clear_all_browsing_data command without any pre-configured scope.","commands":{"allow":[],"deny":["clear_all_browsing_data"]}},"deny-create-webview":{"identifier":"deny-create-webview","description":"Denies the create_webview command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview"]}},"deny-create-webview-window":{"identifier":"deny-create-webview-window","description":"Denies the create_webview_window command without any pre-configured scope.","commands":{"allow":[],"deny":["create_webview_window"]}},"deny-get-all-webviews":{"identifier":"deny-get-all-webviews","description":"Denies the get_all_webviews command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_webviews"]}},"deny-internal-toggle-devtools":{"identifier":"deny-internal-toggle-devtools","description":"Denies the internal_toggle_devtools command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_devtools"]}},"deny-print":{"identifier":"deny-print","description":"Denies the print command without any pre-configured scope.","commands":{"allow":[],"deny":["print"]}},"deny-reparent":{"identifier":"deny-reparent","description":"Denies the reparent command without any pre-configured scope.","commands":{"allow":[],"deny":["reparent"]}},"deny-set-webview-auto-resize":{"identifier":"deny-set-webview-auto-resize","description":"Denies the set_webview_auto_resize command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_auto_resize"]}},"deny-set-webview-background-color":{"identifier":"deny-set-webview-background-color","description":"Denies the set_webview_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_background_color"]}},"deny-set-webview-focus":{"identifier":"deny-set-webview-focus","description":"Denies the set_webview_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_focus"]}},"deny-set-webview-position":{"identifier":"deny-set-webview-position","description":"Denies the set_webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_position"]}},"deny-set-webview-size":{"identifier":"deny-set-webview-size","description":"Denies the set_webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_size"]}},"deny-set-webview-zoom":{"identifier":"deny-set-webview-zoom","description":"Denies the set_webview_zoom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_webview_zoom"]}},"deny-webview-close":{"identifier":"deny-webview-close","description":"Denies the webview_close command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_close"]}},"deny-webview-hide":{"identifier":"deny-webview-hide","description":"Denies the webview_hide command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_hide"]}},"deny-webview-position":{"identifier":"deny-webview-position","description":"Denies the webview_position command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_position"]}},"deny-webview-show":{"identifier":"deny-webview-show","description":"Denies the webview_show command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_show"]}},"deny-webview-size":{"identifier":"deny-webview-size","description":"Denies the webview_size command without any pre-configured scope.","commands":{"allow":[],"deny":["webview_size"]}}},"permission_sets":{},"global_scope_schema":null},"core:window":{"default_permission":{"identifier":"default","description":"Default permissions for the plugin.","permissions":["allow-get-all-windows","allow-scale-factor","allow-inner-position","allow-outer-position","allow-inner-size","allow-outer-size","allow-is-fullscreen","allow-is-minimized","allow-is-maximized","allow-is-focused","allow-is-decorated","allow-is-resizable","allow-is-maximizable","allow-is-minimizable","allow-is-closable","allow-is-visible","allow-is-enabled","allow-title","allow-current-monitor","allow-primary-monitor","allow-monitor-from-point","allow-available-monitors","allow-cursor-position","allow-theme","allow-is-always-on-top","allow-activity-name","allow-scene-identifier","allow-internal-toggle-maximize"]},"permissions":{"allow-activity-name":{"identifier":"allow-activity-name","description":"Enables the activity_name command without any pre-configured scope.","commands":{"allow":["activity_name"],"deny":[]}},"allow-available-monitors":{"identifier":"allow-available-monitors","description":"Enables the available_monitors command without any pre-configured scope.","commands":{"allow":["available_monitors"],"deny":[]}},"allow-center":{"identifier":"allow-center","description":"Enables the center command without any pre-configured scope.","commands":{"allow":["center"],"deny":[]}},"allow-close":{"identifier":"allow-close","description":"Enables the close command without any pre-configured scope.","commands":{"allow":["close"],"deny":[]}},"allow-create":{"identifier":"allow-create","description":"Enables the create command without any pre-configured scope.","commands":{"allow":["create"],"deny":[]}},"allow-current-monitor":{"identifier":"allow-current-monitor","description":"Enables the current_monitor command without any pre-configured scope.","commands":{"allow":["current_monitor"],"deny":[]}},"allow-cursor-position":{"identifier":"allow-cursor-position","description":"Enables the cursor_position command without any pre-configured scope.","commands":{"allow":["cursor_position"],"deny":[]}},"allow-destroy":{"identifier":"allow-destroy","description":"Enables the destroy command without any pre-configured scope.","commands":{"allow":["destroy"],"deny":[]}},"allow-get-all-windows":{"identifier":"allow-get-all-windows","description":"Enables the get_all_windows command without any pre-configured scope.","commands":{"allow":["get_all_windows"],"deny":[]}},"allow-hide":{"identifier":"allow-hide","description":"Enables the hide command without any pre-configured scope.","commands":{"allow":["hide"],"deny":[]}},"allow-inner-position":{"identifier":"allow-inner-position","description":"Enables the inner_position command without any pre-configured scope.","commands":{"allow":["inner_position"],"deny":[]}},"allow-inner-size":{"identifier":"allow-inner-size","description":"Enables the inner_size command without any pre-configured scope.","commands":{"allow":["inner_size"],"deny":[]}},"allow-internal-toggle-maximize":{"identifier":"allow-internal-toggle-maximize","description":"Enables the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":["internal_toggle_maximize"],"deny":[]}},"allow-is-always-on-top":{"identifier":"allow-is-always-on-top","description":"Enables the is_always_on_top command without any pre-configured scope.","commands":{"allow":["is_always_on_top"],"deny":[]}},"allow-is-closable":{"identifier":"allow-is-closable","description":"Enables the is_closable command without any pre-configured scope.","commands":{"allow":["is_closable"],"deny":[]}},"allow-is-decorated":{"identifier":"allow-is-decorated","description":"Enables the is_decorated command without any pre-configured scope.","commands":{"allow":["is_decorated"],"deny":[]}},"allow-is-enabled":{"identifier":"allow-is-enabled","description":"Enables the is_enabled command without any pre-configured scope.","commands":{"allow":["is_enabled"],"deny":[]}},"allow-is-focused":{"identifier":"allow-is-focused","description":"Enables the is_focused command without any pre-configured scope.","commands":{"allow":["is_focused"],"deny":[]}},"allow-is-fullscreen":{"identifier":"allow-is-fullscreen","description":"Enables the is_fullscreen command without any pre-configured scope.","commands":{"allow":["is_fullscreen"],"deny":[]}},"allow-is-maximizable":{"identifier":"allow-is-maximizable","description":"Enables the is_maximizable command without any pre-configured scope.","commands":{"allow":["is_maximizable"],"deny":[]}},"allow-is-maximized":{"identifier":"allow-is-maximized","description":"Enables the is_maximized command without any pre-configured scope.","commands":{"allow":["is_maximized"],"deny":[]}},"allow-is-minimizable":{"identifier":"allow-is-minimizable","description":"Enables the is_minimizable command without any pre-configured scope.","commands":{"allow":["is_minimizable"],"deny":[]}},"allow-is-minimized":{"identifier":"allow-is-minimized","description":"Enables the is_minimized command without any pre-configured scope.","commands":{"allow":["is_minimized"],"deny":[]}},"allow-is-resizable":{"identifier":"allow-is-resizable","description":"Enables the is_resizable command without any pre-configured scope.","commands":{"allow":["is_resizable"],"deny":[]}},"allow-is-visible":{"identifier":"allow-is-visible","description":"Enables the is_visible command without any pre-configured scope.","commands":{"allow":["is_visible"],"deny":[]}},"allow-maximize":{"identifier":"allow-maximize","description":"Enables the maximize command without any pre-configured scope.","commands":{"allow":["maximize"],"deny":[]}},"allow-minimize":{"identifier":"allow-minimize","description":"Enables the minimize command without any pre-configured scope.","commands":{"allow":["minimize"],"deny":[]}},"allow-monitor-from-point":{"identifier":"allow-monitor-from-point","description":"Enables the monitor_from_point command without any pre-configured scope.","commands":{"allow":["monitor_from_point"],"deny":[]}},"allow-outer-position":{"identifier":"allow-outer-position","description":"Enables the outer_position command without any pre-configured scope.","commands":{"allow":["outer_position"],"deny":[]}},"allow-outer-size":{"identifier":"allow-outer-size","description":"Enables the outer_size command without any pre-configured scope.","commands":{"allow":["outer_size"],"deny":[]}},"allow-primary-monitor":{"identifier":"allow-primary-monitor","description":"Enables the primary_monitor command without any pre-configured scope.","commands":{"allow":["primary_monitor"],"deny":[]}},"allow-request-user-attention":{"identifier":"allow-request-user-attention","description":"Enables the request_user_attention command without any pre-configured scope.","commands":{"allow":["request_user_attention"],"deny":[]}},"allow-scale-factor":{"identifier":"allow-scale-factor","description":"Enables the scale_factor command without any pre-configured scope.","commands":{"allow":["scale_factor"],"deny":[]}},"allow-scene-identifier":{"identifier":"allow-scene-identifier","description":"Enables the scene_identifier command without any pre-configured scope.","commands":{"allow":["scene_identifier"],"deny":[]}},"allow-set-always-on-bottom":{"identifier":"allow-set-always-on-bottom","description":"Enables the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":["set_always_on_bottom"],"deny":[]}},"allow-set-always-on-top":{"identifier":"allow-set-always-on-top","description":"Enables the set_always_on_top command without any pre-configured scope.","commands":{"allow":["set_always_on_top"],"deny":[]}},"allow-set-background-color":{"identifier":"allow-set-background-color","description":"Enables the set_background_color command without any pre-configured scope.","commands":{"allow":["set_background_color"],"deny":[]}},"allow-set-badge-count":{"identifier":"allow-set-badge-count","description":"Enables the set_badge_count command without any pre-configured scope.","commands":{"allow":["set_badge_count"],"deny":[]}},"allow-set-badge-label":{"identifier":"allow-set-badge-label","description":"Enables the set_badge_label command without any pre-configured scope.","commands":{"allow":["set_badge_label"],"deny":[]}},"allow-set-closable":{"identifier":"allow-set-closable","description":"Enables the set_closable command without any pre-configured scope.","commands":{"allow":["set_closable"],"deny":[]}},"allow-set-content-protected":{"identifier":"allow-set-content-protected","description":"Enables the set_content_protected command without any pre-configured scope.","commands":{"allow":["set_content_protected"],"deny":[]}},"allow-set-cursor-grab":{"identifier":"allow-set-cursor-grab","description":"Enables the set_cursor_grab command without any pre-configured scope.","commands":{"allow":["set_cursor_grab"],"deny":[]}},"allow-set-cursor-icon":{"identifier":"allow-set-cursor-icon","description":"Enables the set_cursor_icon command without any pre-configured scope.","commands":{"allow":["set_cursor_icon"],"deny":[]}},"allow-set-cursor-position":{"identifier":"allow-set-cursor-position","description":"Enables the set_cursor_position command without any pre-configured scope.","commands":{"allow":["set_cursor_position"],"deny":[]}},"allow-set-cursor-visible":{"identifier":"allow-set-cursor-visible","description":"Enables the set_cursor_visible command without any pre-configured scope.","commands":{"allow":["set_cursor_visible"],"deny":[]}},"allow-set-decorations":{"identifier":"allow-set-decorations","description":"Enables the set_decorations command without any pre-configured scope.","commands":{"allow":["set_decorations"],"deny":[]}},"allow-set-effects":{"identifier":"allow-set-effects","description":"Enables the set_effects command without any pre-configured scope.","commands":{"allow":["set_effects"],"deny":[]}},"allow-set-enabled":{"identifier":"allow-set-enabled","description":"Enables the set_enabled command without any pre-configured scope.","commands":{"allow":["set_enabled"],"deny":[]}},"allow-set-focus":{"identifier":"allow-set-focus","description":"Enables the set_focus command without any pre-configured scope.","commands":{"allow":["set_focus"],"deny":[]}},"allow-set-focusable":{"identifier":"allow-set-focusable","description":"Enables the set_focusable command without any pre-configured scope.","commands":{"allow":["set_focusable"],"deny":[]}},"allow-set-fullscreen":{"identifier":"allow-set-fullscreen","description":"Enables the set_fullscreen command without any pre-configured scope.","commands":{"allow":["set_fullscreen"],"deny":[]}},"allow-set-icon":{"identifier":"allow-set-icon","description":"Enables the set_icon command without any pre-configured scope.","commands":{"allow":["set_icon"],"deny":[]}},"allow-set-ignore-cursor-events":{"identifier":"allow-set-ignore-cursor-events","description":"Enables the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":["set_ignore_cursor_events"],"deny":[]}},"allow-set-max-size":{"identifier":"allow-set-max-size","description":"Enables the set_max_size command without any pre-configured scope.","commands":{"allow":["set_max_size"],"deny":[]}},"allow-set-maximizable":{"identifier":"allow-set-maximizable","description":"Enables the set_maximizable command without any pre-configured scope.","commands":{"allow":["set_maximizable"],"deny":[]}},"allow-set-min-size":{"identifier":"allow-set-min-size","description":"Enables the set_min_size command without any pre-configured scope.","commands":{"allow":["set_min_size"],"deny":[]}},"allow-set-minimizable":{"identifier":"allow-set-minimizable","description":"Enables the set_minimizable command without any pre-configured scope.","commands":{"allow":["set_minimizable"],"deny":[]}},"allow-set-overlay-icon":{"identifier":"allow-set-overlay-icon","description":"Enables the set_overlay_icon command without any pre-configured scope.","commands":{"allow":["set_overlay_icon"],"deny":[]}},"allow-set-position":{"identifier":"allow-set-position","description":"Enables the set_position command without any pre-configured scope.","commands":{"allow":["set_position"],"deny":[]}},"allow-set-progress-bar":{"identifier":"allow-set-progress-bar","description":"Enables the set_progress_bar command without any pre-configured scope.","commands":{"allow":["set_progress_bar"],"deny":[]}},"allow-set-resizable":{"identifier":"allow-set-resizable","description":"Enables the set_resizable command without any pre-configured scope.","commands":{"allow":["set_resizable"],"deny":[]}},"allow-set-shadow":{"identifier":"allow-set-shadow","description":"Enables the set_shadow command without any pre-configured scope.","commands":{"allow":["set_shadow"],"deny":[]}},"allow-set-simple-fullscreen":{"identifier":"allow-set-simple-fullscreen","description":"Enables the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":["set_simple_fullscreen"],"deny":[]}},"allow-set-size":{"identifier":"allow-set-size","description":"Enables the set_size command without any pre-configured scope.","commands":{"allow":["set_size"],"deny":[]}},"allow-set-size-constraints":{"identifier":"allow-set-size-constraints","description":"Enables the set_size_constraints command without any pre-configured scope.","commands":{"allow":["set_size_constraints"],"deny":[]}},"allow-set-skip-taskbar":{"identifier":"allow-set-skip-taskbar","description":"Enables the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":["set_skip_taskbar"],"deny":[]}},"allow-set-theme":{"identifier":"allow-set-theme","description":"Enables the set_theme command without any pre-configured scope.","commands":{"allow":["set_theme"],"deny":[]}},"allow-set-title":{"identifier":"allow-set-title","description":"Enables the set_title command without any pre-configured scope.","commands":{"allow":["set_title"],"deny":[]}},"allow-set-title-bar-style":{"identifier":"allow-set-title-bar-style","description":"Enables the set_title_bar_style command without any pre-configured scope.","commands":{"allow":["set_title_bar_style"],"deny":[]}},"allow-set-visible-on-all-workspaces":{"identifier":"allow-set-visible-on-all-workspaces","description":"Enables the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":["set_visible_on_all_workspaces"],"deny":[]}},"allow-show":{"identifier":"allow-show","description":"Enables the show command without any pre-configured scope.","commands":{"allow":["show"],"deny":[]}},"allow-start-dragging":{"identifier":"allow-start-dragging","description":"Enables the start_dragging command without any pre-configured scope.","commands":{"allow":["start_dragging"],"deny":[]}},"allow-start-resize-dragging":{"identifier":"allow-start-resize-dragging","description":"Enables the start_resize_dragging command without any pre-configured scope.","commands":{"allow":["start_resize_dragging"],"deny":[]}},"allow-theme":{"identifier":"allow-theme","description":"Enables the theme command without any pre-configured scope.","commands":{"allow":["theme"],"deny":[]}},"allow-title":{"identifier":"allow-title","description":"Enables the title command without any pre-configured scope.","commands":{"allow":["title"],"deny":[]}},"allow-toggle-maximize":{"identifier":"allow-toggle-maximize","description":"Enables the toggle_maximize command without any pre-configured scope.","commands":{"allow":["toggle_maximize"],"deny":[]}},"allow-unmaximize":{"identifier":"allow-unmaximize","description":"Enables the unmaximize command without any pre-configured scope.","commands":{"allow":["unmaximize"],"deny":[]}},"allow-unminimize":{"identifier":"allow-unminimize","description":"Enables the unminimize command without any pre-configured scope.","commands":{"allow":["unminimize"],"deny":[]}},"deny-activity-name":{"identifier":"deny-activity-name","description":"Denies the activity_name command without any pre-configured scope.","commands":{"allow":[],"deny":["activity_name"]}},"deny-available-monitors":{"identifier":"deny-available-monitors","description":"Denies the available_monitors command without any pre-configured scope.","commands":{"allow":[],"deny":["available_monitors"]}},"deny-center":{"identifier":"deny-center","description":"Denies the center command without any pre-configured scope.","commands":{"allow":[],"deny":["center"]}},"deny-close":{"identifier":"deny-close","description":"Denies the close command without any pre-configured scope.","commands":{"allow":[],"deny":["close"]}},"deny-create":{"identifier":"deny-create","description":"Denies the create command without any pre-configured scope.","commands":{"allow":[],"deny":["create"]}},"deny-current-monitor":{"identifier":"deny-current-monitor","description":"Denies the current_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["current_monitor"]}},"deny-cursor-position":{"identifier":"deny-cursor-position","description":"Denies the cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["cursor_position"]}},"deny-destroy":{"identifier":"deny-destroy","description":"Denies the destroy command without any pre-configured scope.","commands":{"allow":[],"deny":["destroy"]}},"deny-get-all-windows":{"identifier":"deny-get-all-windows","description":"Denies the get_all_windows command without any pre-configured scope.","commands":{"allow":[],"deny":["get_all_windows"]}},"deny-hide":{"identifier":"deny-hide","description":"Denies the hide command without any pre-configured scope.","commands":{"allow":[],"deny":["hide"]}},"deny-inner-position":{"identifier":"deny-inner-position","description":"Denies the inner_position command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_position"]}},"deny-inner-size":{"identifier":"deny-inner-size","description":"Denies the inner_size command without any pre-configured scope.","commands":{"allow":[],"deny":["inner_size"]}},"deny-internal-toggle-maximize":{"identifier":"deny-internal-toggle-maximize","description":"Denies the internal_toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["internal_toggle_maximize"]}},"deny-is-always-on-top":{"identifier":"deny-is-always-on-top","description":"Denies the is_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["is_always_on_top"]}},"deny-is-closable":{"identifier":"deny-is-closable","description":"Denies the is_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_closable"]}},"deny-is-decorated":{"identifier":"deny-is-decorated","description":"Denies the is_decorated command without any pre-configured scope.","commands":{"allow":[],"deny":["is_decorated"]}},"deny-is-enabled":{"identifier":"deny-is-enabled","description":"Denies the is_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["is_enabled"]}},"deny-is-focused":{"identifier":"deny-is-focused","description":"Denies the is_focused command without any pre-configured scope.","commands":{"allow":[],"deny":["is_focused"]}},"deny-is-fullscreen":{"identifier":"deny-is-fullscreen","description":"Denies the is_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["is_fullscreen"]}},"deny-is-maximizable":{"identifier":"deny-is-maximizable","description":"Denies the is_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximizable"]}},"deny-is-maximized":{"identifier":"deny-is-maximized","description":"Denies the is_maximized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_maximized"]}},"deny-is-minimizable":{"identifier":"deny-is-minimizable","description":"Denies the is_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimizable"]}},"deny-is-minimized":{"identifier":"deny-is-minimized","description":"Denies the is_minimized command without any pre-configured scope.","commands":{"allow":[],"deny":["is_minimized"]}},"deny-is-resizable":{"identifier":"deny-is-resizable","description":"Denies the is_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["is_resizable"]}},"deny-is-visible":{"identifier":"deny-is-visible","description":"Denies the is_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["is_visible"]}},"deny-maximize":{"identifier":"deny-maximize","description":"Denies the maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["maximize"]}},"deny-minimize":{"identifier":"deny-minimize","description":"Denies the minimize command without any pre-configured scope.","commands":{"allow":[],"deny":["minimize"]}},"deny-monitor-from-point":{"identifier":"deny-monitor-from-point","description":"Denies the monitor_from_point command without any pre-configured scope.","commands":{"allow":[],"deny":["monitor_from_point"]}},"deny-outer-position":{"identifier":"deny-outer-position","description":"Denies the outer_position command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_position"]}},"deny-outer-size":{"identifier":"deny-outer-size","description":"Denies the outer_size command without any pre-configured scope.","commands":{"allow":[],"deny":["outer_size"]}},"deny-primary-monitor":{"identifier":"deny-primary-monitor","description":"Denies the primary_monitor command without any pre-configured scope.","commands":{"allow":[],"deny":["primary_monitor"]}},"deny-request-user-attention":{"identifier":"deny-request-user-attention","description":"Denies the request_user_attention command without any pre-configured scope.","commands":{"allow":[],"deny":["request_user_attention"]}},"deny-scale-factor":{"identifier":"deny-scale-factor","description":"Denies the scale_factor command without any pre-configured scope.","commands":{"allow":[],"deny":["scale_factor"]}},"deny-scene-identifier":{"identifier":"deny-scene-identifier","description":"Denies the scene_identifier command without any pre-configured scope.","commands":{"allow":[],"deny":["scene_identifier"]}},"deny-set-always-on-bottom":{"identifier":"deny-set-always-on-bottom","description":"Denies the set_always_on_bottom command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_bottom"]}},"deny-set-always-on-top":{"identifier":"deny-set-always-on-top","description":"Denies the set_always_on_top command without any pre-configured scope.","commands":{"allow":[],"deny":["set_always_on_top"]}},"deny-set-background-color":{"identifier":"deny-set-background-color","description":"Denies the set_background_color command without any pre-configured scope.","commands":{"allow":[],"deny":["set_background_color"]}},"deny-set-badge-count":{"identifier":"deny-set-badge-count","description":"Denies the set_badge_count command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_count"]}},"deny-set-badge-label":{"identifier":"deny-set-badge-label","description":"Denies the set_badge_label command without any pre-configured scope.","commands":{"allow":[],"deny":["set_badge_label"]}},"deny-set-closable":{"identifier":"deny-set-closable","description":"Denies the set_closable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_closable"]}},"deny-set-content-protected":{"identifier":"deny-set-content-protected","description":"Denies the set_content_protected command without any pre-configured scope.","commands":{"allow":[],"deny":["set_content_protected"]}},"deny-set-cursor-grab":{"identifier":"deny-set-cursor-grab","description":"Denies the set_cursor_grab command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_grab"]}},"deny-set-cursor-icon":{"identifier":"deny-set-cursor-icon","description":"Denies the set_cursor_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_icon"]}},"deny-set-cursor-position":{"identifier":"deny-set-cursor-position","description":"Denies the set_cursor_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_position"]}},"deny-set-cursor-visible":{"identifier":"deny-set-cursor-visible","description":"Denies the set_cursor_visible command without any pre-configured scope.","commands":{"allow":[],"deny":["set_cursor_visible"]}},"deny-set-decorations":{"identifier":"deny-set-decorations","description":"Denies the set_decorations command without any pre-configured scope.","commands":{"allow":[],"deny":["set_decorations"]}},"deny-set-effects":{"identifier":"deny-set-effects","description":"Denies the set_effects command without any pre-configured scope.","commands":{"allow":[],"deny":["set_effects"]}},"deny-set-enabled":{"identifier":"deny-set-enabled","description":"Denies the set_enabled command without any pre-configured scope.","commands":{"allow":[],"deny":["set_enabled"]}},"deny-set-focus":{"identifier":"deny-set-focus","description":"Denies the set_focus command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focus"]}},"deny-set-focusable":{"identifier":"deny-set-focusable","description":"Denies the set_focusable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_focusable"]}},"deny-set-fullscreen":{"identifier":"deny-set-fullscreen","description":"Denies the set_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_fullscreen"]}},"deny-set-icon":{"identifier":"deny-set-icon","description":"Denies the set_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_icon"]}},"deny-set-ignore-cursor-events":{"identifier":"deny-set-ignore-cursor-events","description":"Denies the set_ignore_cursor_events command without any pre-configured scope.","commands":{"allow":[],"deny":["set_ignore_cursor_events"]}},"deny-set-max-size":{"identifier":"deny-set-max-size","description":"Denies the set_max_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_max_size"]}},"deny-set-maximizable":{"identifier":"deny-set-maximizable","description":"Denies the set_maximizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_maximizable"]}},"deny-set-min-size":{"identifier":"deny-set-min-size","description":"Denies the set_min_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_min_size"]}},"deny-set-minimizable":{"identifier":"deny-set-minimizable","description":"Denies the set_minimizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_minimizable"]}},"deny-set-overlay-icon":{"identifier":"deny-set-overlay-icon","description":"Denies the set_overlay_icon command without any pre-configured scope.","commands":{"allow":[],"deny":["set_overlay_icon"]}},"deny-set-position":{"identifier":"deny-set-position","description":"Denies the set_position command without any pre-configured scope.","commands":{"allow":[],"deny":["set_position"]}},"deny-set-progress-bar":{"identifier":"deny-set-progress-bar","description":"Denies the set_progress_bar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_progress_bar"]}},"deny-set-resizable":{"identifier":"deny-set-resizable","description":"Denies the set_resizable command without any pre-configured scope.","commands":{"allow":[],"deny":["set_resizable"]}},"deny-set-shadow":{"identifier":"deny-set-shadow","description":"Denies the set_shadow command without any pre-configured scope.","commands":{"allow":[],"deny":["set_shadow"]}},"deny-set-simple-fullscreen":{"identifier":"deny-set-simple-fullscreen","description":"Denies the set_simple_fullscreen command without any pre-configured scope.","commands":{"allow":[],"deny":["set_simple_fullscreen"]}},"deny-set-size":{"identifier":"deny-set-size","description":"Denies the set_size command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size"]}},"deny-set-size-constraints":{"identifier":"deny-set-size-constraints","description":"Denies the set_size_constraints command without any pre-configured scope.","commands":{"allow":[],"deny":["set_size_constraints"]}},"deny-set-skip-taskbar":{"identifier":"deny-set-skip-taskbar","description":"Denies the set_skip_taskbar command without any pre-configured scope.","commands":{"allow":[],"deny":["set_skip_taskbar"]}},"deny-set-theme":{"identifier":"deny-set-theme","description":"Denies the set_theme command without any pre-configured scope.","commands":{"allow":[],"deny":["set_theme"]}},"deny-set-title":{"identifier":"deny-set-title","description":"Denies the set_title command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title"]}},"deny-set-title-bar-style":{"identifier":"deny-set-title-bar-style","description":"Denies the set_title_bar_style command without any pre-configured scope.","commands":{"allow":[],"deny":["set_title_bar_style"]}},"deny-set-visible-on-all-workspaces":{"identifier":"deny-set-visible-on-all-workspaces","description":"Denies the set_visible_on_all_workspaces command without any pre-configured scope.","commands":{"allow":[],"deny":["set_visible_on_all_workspaces"]}},"deny-show":{"identifier":"deny-show","description":"Denies the show command without any pre-configured scope.","commands":{"allow":[],"deny":["show"]}},"deny-start-dragging":{"identifier":"deny-start-dragging","description":"Denies the start_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_dragging"]}},"deny-start-resize-dragging":{"identifier":"deny-start-resize-dragging","description":"Denies the start_resize_dragging command without any pre-configured scope.","commands":{"allow":[],"deny":["start_resize_dragging"]}},"deny-theme":{"identifier":"deny-theme","description":"Denies the theme command without any pre-configured scope.","commands":{"allow":[],"deny":["theme"]}},"deny-title":{"identifier":"deny-title","description":"Denies the title command without any pre-configured scope.","commands":{"allow":[],"deny":["title"]}},"deny-toggle-maximize":{"identifier":"deny-toggle-maximize","description":"Denies the toggle_maximize command without any pre-configured scope.","commands":{"allow":[],"deny":["toggle_maximize"]}},"deny-unmaximize":{"identifier":"deny-unmaximize","description":"Denies the unmaximize command without any pre-configured scope.","commands":{"allow":[],"deny":["unmaximize"]}},"deny-unminimize":{"identifier":"deny-unminimize","description":"Denies the unminimize command without any pre-configured scope.","commands":{"allow":[],"deny":["unminimize"]}}},"permission_sets":{},"global_scope_schema":null},"shell":{"default_permission":{"identifier":"default","description":"This permission set configures which\nshell functionality is exposed by default.\n\n#### Granted Permissions\n\nIt allows to use the `open` functionality with a reasonable\nscope pre-configured. It will allow opening `http(s)://`,\n`tel:` and `mailto:` links.\n","permissions":["allow-open"]},"permissions":{"allow-execute":{"identifier":"allow-execute","description":"Enables the execute command without any pre-configured scope.","commands":{"allow":["execute"],"deny":[]}},"allow-kill":{"identifier":"allow-kill","description":"Enables the kill command without any pre-configured scope.","commands":{"allow":["kill"],"deny":[]}},"allow-open":{"identifier":"allow-open","description":"Enables the open command without any pre-configured scope.","commands":{"allow":["open"],"deny":[]}},"allow-spawn":{"identifier":"allow-spawn","description":"Enables the spawn command without any pre-configured scope.","commands":{"allow":["spawn"],"deny":[]}},"allow-stdin-write":{"identifier":"allow-stdin-write","description":"Enables the stdin_write command without any pre-configured scope.","commands":{"allow":["stdin_write"],"deny":[]}},"deny-execute":{"identifier":"deny-execute","description":"Denies the execute command without any pre-configured scope.","commands":{"allow":[],"deny":["execute"]}},"deny-kill":{"identifier":"deny-kill","description":"Denies the kill command without any pre-configured scope.","commands":{"allow":[],"deny":["kill"]}},"deny-open":{"identifier":"deny-open","description":"Denies the open command without any pre-configured scope.","commands":{"allow":[],"deny":["open"]}},"deny-spawn":{"identifier":"deny-spawn","description":"Denies the spawn command without any pre-configured scope.","commands":{"allow":[],"deny":["spawn"]}},"deny-stdin-write":{"identifier":"deny-stdin-write","description":"Denies the stdin_write command without any pre-configured scope.","commands":{"allow":[],"deny":["stdin_write"]}}},"permission_sets":{},"global_scope_schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"cmd":{"description":"The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.","type":"string"},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"}},"required":["cmd","name"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"allOf":[{"$ref":"#/definitions/ShellScopeEntryAllowedArgs"}],"description":"The allowed arguments for the command execution."},"name":{"description":"The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.","type":"string"},"sidecar":{"description":"If this command is a sidecar command.","type":"boolean"}},"required":["name","sidecar"],"type":"object"}],"definitions":{"ShellScopeEntryAllowedArg":{"anyOf":[{"description":"A non-configurable argument that is passed to the command in the order it was specified.","type":"string"},{"additionalProperties":false,"description":"A variable that is set while calling the command from the webview API.","properties":{"raw":{"default":false,"description":"Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.","type":"boolean"},"validator":{"description":"[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: ","type":"string"}},"required":["validator"],"type":"object"}],"description":"A command argument allowed to be executed by the webview API."},"ShellScopeEntryAllowedArgs":{"anyOf":[{"description":"Use a simple boolean to allow all or disable all arguments to this command configuration.","type":"boolean"},{"description":"A specific set of [`ShellScopeEntryAllowedArg`] that are valid to call for the command configuration.","items":{"$ref":"#/definitions/ShellScopeEntryAllowedArg"},"type":"array"}],"description":"A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration."}},"description":"Shell scope entry.","title":"ShellScopeEntry"}}} \ No newline at end of file diff --git a/src-tauri/gen/schemas/desktop-schema.json b/src-tauri/gen/schemas/desktop-schema.json index f827fe175..d1e536142 100644 --- a/src-tauri/gen/schemas/desktop-schema.json +++ b/src-tauri/gen/schemas/desktop-schema.json @@ -393,10 +393,10 @@ "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", "type": "string", "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" }, { "description": "Enables the app_hide command without any pre-configured scope.", @@ -470,6 +470,12 @@ "const": "core:app:allow-set-dock-visibility", "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Enables the tauri_version command without any pre-configured scope.", "type": "string", @@ -554,6 +560,12 @@ "const": "core:app:deny-set-dock-visibility", "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Denies the tauri_version command without any pre-configured scope.", "type": "string", @@ -1077,10 +1089,10 @@ "markdownDescription": "Denies the close command without any pre-configured scope." }, { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", "type": "string", "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" }, { "description": "Enables the get_by_id command without any pre-configured scope.", @@ -1112,6 +1124,12 @@ "const": "core:tray:allow-set-icon-as-template", "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Enables the set_menu command without any pre-configured scope.", "type": "string", @@ -1178,6 +1196,12 @@ "const": "core:tray:deny-set-icon-as-template", "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Denies the set_menu command without any pre-configured scope.", "type": "string", @@ -1437,10 +1461,16 @@ "markdownDescription": "Denies the webview_size command without any pre-configured scope." }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", "type": "string", "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." }, { "description": "Enables the available_monitors command without any pre-configured scope.", @@ -1634,6 +1664,12 @@ "const": "core:window:allow-scale-factor", "markdownDescription": "Enables the scale_factor command without any pre-configured scope." }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, { "description": "Enables the set_always_on_bottom command without any pre-configured scope.", "type": "string", @@ -1898,6 +1934,12 @@ "const": "core:window:allow-unminimize", "markdownDescription": "Enables the unminimize command without any pre-configured scope." }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, { "description": "Denies the available_monitors command without any pre-configured scope.", "type": "string", @@ -2090,6 +2132,12 @@ "const": "core:window:deny-scale-factor", "markdownDescription": "Denies the scale_factor command without any pre-configured scope." }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, { "description": "Denies the set_always_on_bottom command without any pre-configured scope.", "type": "string", diff --git a/src-tauri/gen/schemas/macOS-schema.json b/src-tauri/gen/schemas/macOS-schema.json index f827fe175..d1e536142 100644 --- a/src-tauri/gen/schemas/macOS-schema.json +++ b/src-tauri/gen/schemas/macOS-schema.json @@ -393,10 +393,10 @@ "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`", "type": "string", "const": "core:app:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`" }, { "description": "Enables the app_hide command without any pre-configured scope.", @@ -470,6 +470,12 @@ "const": "core:app:allow-set-dock-visibility", "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Enables the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-supports-multiple-windows", + "markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Enables the tauri_version command without any pre-configured scope.", "type": "string", @@ -554,6 +560,12 @@ "const": "core:app:deny-set-dock-visibility", "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." }, + { + "description": "Denies the supports_multiple_windows command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-supports-multiple-windows", + "markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope." + }, { "description": "Denies the tauri_version command without any pre-configured scope.", "type": "string", @@ -1077,10 +1089,10 @@ "markdownDescription": "Denies the close command without any pre-configured scope." }, { - "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`", "type": "string", "const": "core:tray:default", - "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`" }, { "description": "Enables the get_by_id command without any pre-configured scope.", @@ -1112,6 +1124,12 @@ "const": "core:tray:allow-set-icon-as-template", "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Enables the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-with-as-template", + "markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Enables the set_menu command without any pre-configured scope.", "type": "string", @@ -1178,6 +1196,12 @@ "const": "core:tray:deny-set-icon-as-template", "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." }, + { + "description": "Denies the set_icon_with_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-with-as-template", + "markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope." + }, { "description": "Denies the set_menu command without any pre-configured scope.", "type": "string", @@ -1437,10 +1461,16 @@ "markdownDescription": "Denies the webview_size command without any pre-configured scope." }, { - "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`", "type": "string", "const": "core:window:default", - "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-activity-name", + "markdownDescription": "Enables the activity_name command without any pre-configured scope." }, { "description": "Enables the available_monitors command without any pre-configured scope.", @@ -1634,6 +1664,12 @@ "const": "core:window:allow-scale-factor", "markdownDescription": "Enables the scale_factor command without any pre-configured scope." }, + { + "description": "Enables the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scene-identifier", + "markdownDescription": "Enables the scene_identifier command without any pre-configured scope." + }, { "description": "Enables the set_always_on_bottom command without any pre-configured scope.", "type": "string", @@ -1898,6 +1934,12 @@ "const": "core:window:allow-unminimize", "markdownDescription": "Enables the unminimize command without any pre-configured scope." }, + { + "description": "Denies the activity_name command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-activity-name", + "markdownDescription": "Denies the activity_name command without any pre-configured scope." + }, { "description": "Denies the available_monitors command without any pre-configured scope.", "type": "string", @@ -2090,6 +2132,12 @@ "const": "core:window:deny-scale-factor", "markdownDescription": "Denies the scale_factor command without any pre-configured scope." }, + { + "description": "Denies the scene_identifier command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scene-identifier", + "markdownDescription": "Denies the scene_identifier command without any pre-configured scope." + }, { "description": "Denies the set_always_on_bottom command without any pre-configured scope.", "type": "string", diff --git a/src/app/defaults.test.tsx b/src/app/defaults.test.tsx new file mode 100644 index 000000000..0e5333787 --- /dev/null +++ b/src/app/defaults.test.tsx @@ -0,0 +1,52 @@ +/** + * app/defaults — pure helpers and the i18n-backed badge/caption surfaces. + * Covers formatMnemonic (newline → space + trim), the deprecated + * getAccountBadgeTitle constant, the useAccountBadgeTitle hook, and the + * MnemonicErrorCaption list. + */ + +import React from 'react'; + +import { render, renderHook } from '@testing-library/react'; + +import { MnemonicErrorCaption, formatMnemonic, getAccountBadgeTitle, useAccountBadgeTitle } from './defaults'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key + }) +})); + +describe('app/defaults', () => { + describe('formatMnemonic', () => { + it('replaces newlines with spaces and trims surrounding whitespace', () => { + expect(formatMnemonic(' alpha\nbravo\ncharlie ')).toBe('alpha bravo charlie'); + }); + + it('leaves an already-clean single-line phrase untouched', () => { + expect(formatMnemonic('alpha bravo charlie')).toBe('alpha bravo charlie'); + }); + }); + + describe('getAccountBadgeTitle', () => { + it('returns the deprecated static "Imported" label', () => { + expect(getAccountBadgeTitle()).toBe('Imported'); + }); + }); + + describe('useAccountBadgeTitle', () => { + it('returns the translated imported-account label', () => { + const { result } = renderHook(() => useAccountBadgeTitle()); + expect(result.current).toBe('importedAccount'); + }); + }); + + describe('MnemonicErrorCaption', () => { + it('renders the three mnemonic-constraint list items', () => { + const { getByText } = render(); + expect(getByText('mnemonicWordsAmountConstraint')).toBeInTheDocument(); + expect(getByText('mnemonicSpacingConstraint')).toBeInTheDocument(); + expect(getByText('justValidPreGeneratedMnemonic')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/app/pages/Explore.tsx b/src/app/pages/Explore.tsx index 4cb6ee970..052afa4e8 100644 --- a/src/app/pages/Explore.tsx +++ b/src/app/pages/Explore.tsx @@ -4,6 +4,7 @@ import classNames from 'clsx'; import useMidenFaucetId from 'app/hooks/useMidenFaucetId'; import Header from 'app/layouts/PageLayout/Header'; +import { ActivateHotKeyBanner } from 'app/templates/ActivateHotKeyBanner'; import { ConnectivityIssueBanner } from 'components/ConnectivityIssueBanner'; import { ActionButtons } from 'components/explore/ActionButtons'; import { PriceChangeBadge } from 'components/explore/PriceChangeBadge'; @@ -64,7 +65,9 @@ const Explore: FC = () => { } const promises = notesToClaim.map(async note => { - await initiateConsumeTransaction(account.publicKey, note, isDelegatedProvingEnabled); + // `background: true` — this is a silent auto-consume, so on Guardian + // accounts it's cold-signed (no biometric prompt). See initiateConsumeTransaction. + await initiateConsumeTransaction(account.publicKey, note, isDelegatedProvingEnabled, true); }); await Promise.all(promises); mutateClaimableNotes(); @@ -138,6 +141,7 @@ const Explore: FC = () => {
+
diff --git a/src/app/pages/Explore/Tokens.test.tsx b/src/app/pages/Explore/Tokens.test.tsx index 02ec3f9a0..edb920222 100644 --- a/src/app/pages/Explore/Tokens.test.tsx +++ b/src/app/pages/Explore/Tokens.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import Tokens from './Tokens'; @@ -35,11 +35,19 @@ jest.mock('components/TokenLogo', () => ({ TokenLogo: () =>
})); +const mockGetTokenPrice = jest.fn(); +jest.mock('lib/prices', () => ({ + getTokenPrice: (...args: unknown[]) => mockGetTokenPrice(...args) +})); + const mockUseAllBalances = jest.requireMock('lib/miden/front').useAllBalances; describe('Tokens', () => { beforeEach(() => { jest.clearAllMocks(); + // Default to a positive 24h change (green branch). Individual tests + // override this to exercise the negative (red) branch. + mockGetTokenPrice.mockReturnValue({ price: 1, percentageChange24h: 1 }); }); it('renders even when no balances are loaded yet', () => { @@ -133,4 +141,64 @@ describe('Tokens', () => { expect(container.querySelector('.animate-pulse')).not.toBeInTheDocument(); }); + + it('falls back to the symbol when a token has no name', () => { + mockUseAllBalances.mockReturnValue({ + data: [ + { + tokenId: 'token-noname', + balance: 1, + // No `name` — title should fall back to the symbol. + metadata: { symbol: 'NONAME', decimals: 8 } + } + ], + isLoading: false + }); + + render(); + + expect(screen.getByText('NONAME')).toBeInTheDocument(); + }); + + it('renders a negative 24h price change (red branch)', () => { + mockGetTokenPrice.mockReturnValue({ price: 1, percentageChange24h: -3.5 }); + mockUseAllBalances.mockReturnValue({ + data: [{ tokenId: 'token-1', balance: 100, metadata: { symbol: 'TKN', name: 'Token', decimals: 8 } }], + isLoading: false + }); + + render(); + + expect(screen.getByText('Token')).toBeInTheDocument(); + }); + + it('filters the token list by the search query (name and symbol)', () => { + mockUseAllBalances.mockReturnValue({ + data: [ + // Including the MIDEN faucet in a multi-token list ensures the sort + // comparator actually runs and hits its `=== midenFaucetId` branch. + { tokenId: 'miden-faucet-id', balance: 0, metadata: { symbol: 'MIDEN', name: 'Miden', decimals: 8 } }, + { tokenId: 'a', balance: 5, metadata: { symbol: 'AAA', name: 'Alpha', decimals: 8 } }, + { tokenId: 'b', balance: 5, metadata: { symbol: 'BBB', decimals: 8 } } + ], + isLoading: false + }); + + render(); + + // Both render before filtering. + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('BBB')).toBeInTheDocument(); + + // Search by name — only the matching token survives. + fireEvent.change(screen.getByPlaceholderText('searchForToken'), { target: { value: 'alp' } }); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.queryByText('BBB')).not.toBeInTheDocument(); + + // Search by symbol on the token that has no name (exercises the + // optional-chained name match short-circuit and the symbol branch). + fireEvent.change(screen.getByPlaceholderText('searchForToken'), { target: { value: 'bbb' } }); + expect(screen.getByText('BBB')).toBeInTheDocument(); + expect(screen.queryByText('Alpha')).not.toBeInTheDocument(); + }); }); diff --git a/src/app/pages/Settings.selectors.ts b/src/app/pages/Settings.selectors.ts index 59c952ccd..cfbb0b1c6 100644 --- a/src/app/pages/Settings.selectors.ts +++ b/src/app/pages/Settings.selectors.ts @@ -5,6 +5,7 @@ export enum SettingsSelectors { AddressBookButton = 'Settings/AddressBookButton', RevealViewKeyButton = 'Settings/RevealViewKeyButton', RevealPrivateKeyButton = 'Settings/RevealPrivateKeyButton', + RevealHotKeyButton = 'Settings/RevealHotKeyButton', RevealSeedPhraseButton = 'Settings/RevealSeedPhraseButton', DAppsButton = 'Settings/DAppsButton', NetworksButton = 'Settings/NetworksButton', diff --git a/src/app/pages/Settings.tsx b/src/app/pages/Settings.tsx index d037756ec..d8e43d404 100644 --- a/src/app/pages/Settings.tsx +++ b/src/app/pages/Settings.tsx @@ -70,7 +70,13 @@ type SettingsProps = { tabSlug?: string | null; }; -const RevealPrivateKey: FC = () => ; +const RevealPrivateKey: FC = () => { + const currentAccountType = useWalletStore(s => s.currentAccount?.type); + const isGuardian = currentAccountType === WalletType.Guardian; + return ; +}; + +const RevealHotKey: FC = () => ; const LANGUAGE_LABELS: Record = { en: 'English', @@ -103,6 +109,10 @@ type Tab = { isDrawer?: boolean; onClick?: () => void; guardianOnly?: boolean; + // Hide on Guardian accounts whose hot key is not yet activated (post-recovery, + // pre-banner-click). The corresponding Settings flow needs a `hotPublicKey` + // set on the WalletAccount or it'll fail immediately on the vault lookup. + requiresActivatedHotKey?: boolean; }; type TabGroup = { @@ -158,6 +168,15 @@ const TAB_GROUPS: TabGroup[] = [ Component: RevealPrivateKey, testID: SettingsSelectors.RevealPrivateKeyButton }, + { + slug: 'reveal-hot-key', + titleI18nKey: 'revealHotKey', + Icon: SecretKeyIcon, + Component: RevealHotKey, + testID: SettingsSelectors.RevealHotKeyButton, + guardianOnly: true, + requiresActivatedHotKey: true + }, { slug: 'encrypted-wallet-file', titleI18nKey: 'encryptedWalletFile', @@ -245,7 +264,18 @@ const HIDDEN_TABS: Tab[] = [ const Settings: FC = ({ tabSlug }) => { const { t } = useTranslation(); const currentAccountType = useWalletStore(s => s.currentAccount?.type); + const currentAccountHotPublicKey = useWalletStore(s => s.currentAccount?.hotPublicKey); const isGuardianAccount = currentAccountType === WalletType.Guardian; + const hasActivatedHotKey = Boolean(currentAccountHotPublicKey); + + const tabIsVisible = useCallback( + (tab: Tab) => { + if (tab.guardianOnly && !isGuardianAccount) return false; + if (tab.requiresActivatedHotKey && !hasActivatedHotKey) return false; + return true; + }, + [hasActivatedHotKey, isGuardianAccount] + ); // Filter tabs that are gated to Guardian accounts. Non-Guardian users don't see // the Guardian Settings entry at all (menu, drawer, or routable page). @@ -253,14 +283,14 @@ const Settings: FC = ({ tabSlug }) => { () => TAB_GROUPS.map(group => ({ ...group, - tabs: group.tabs.filter(tab => !tab.guardianOnly || isGuardianAccount) + tabs: group.tabs.filter(tabIsVisible) })).filter(group => group.tabs.length > 0), - [isGuardianAccount] + [tabIsVisible] ); const allTabs = useMemo( - () => [...tabGroups.flatMap(g => g.tabs), ...HIDDEN_TABS.filter(tab => !tab.guardianOnly || isGuardianAccount)], - [tabGroups, isGuardianAccount] + () => [...tabGroups.flatMap(g => g.tabs), ...HIDDEN_TABS.filter(tabIsVisible)], + [tabGroups, tabIsVisible] ); const drawerTabs = useMemo(() => tabGroups.flatMap(g => g.tabs).filter(t => t.isDrawer), [tabGroups]); diff --git a/src/app/templates/ActivateHotKeyBanner.tsx b/src/app/templates/ActivateHotKeyBanner.tsx new file mode 100644 index 000000000..8d79f721c --- /dev/null +++ b/src/app/templates/ActivateHotKeyBanner.tsx @@ -0,0 +1,73 @@ +import React, { FC, useCallback, useState } from 'react'; + +import classNames from 'clsx'; +import { useTranslation } from 'react-i18next'; + +import { Icon, IconName } from 'app/icons/v2'; +import { initiateReplaceHotKeyTransaction, requestSWTransactionProcessing } from 'lib/miden/activity'; +import { useAccount } from 'lib/miden/front'; +import { zustandProvider } from 'lib/miden/front/guardian-sync'; +import { hapticLight } from 'lib/mobile/haptics'; +import { isExtension } from 'lib/platform'; +import { isDelegateProofEnabled } from 'lib/settings/helpers'; +import { useWalletStore } from 'lib/store'; + +interface Props { + className?: string; +} + +/** + * Surfaces post-recovery: Guardian accounts adopted via seed-phrase lookup have + * no usable local hot key (the on-chain hot's secret is unrecoverable). The + * banner CTA fires a cold-signed `replace_signer` rotation that mints a fresh + * hot key and swaps it on-chain. `requiresHotKeyRotation` clears once + * `Vault.swapHotKey` lands, at which point the banner self-hides. + */ +export const ActivateHotKeyBanner: FC = ({ className }) => { + const { t } = useTranslation(); + const account = useAccount(); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + const onClick = useCallback(async () => { + if (submitting) return; + hapticLight(); + setSubmitting(true); + setError(null); + try { + await initiateReplaceHotKeyTransaction(account.publicKey, isDelegateProofEnabled(), zustandProvider); + useWalletStore.getState().openTransactionModal(); + if (isExtension()) requestSWTransactionProcessing(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setSubmitting(false); + } + }, [account.publicKey, submitting]); + + if (!account.requiresHotKeyRotation) return null; + + return ( +
+
+ +
+
+

{t('activateHotKeyBannerTitle')}

+

{error ?? t('activateHotKeyBannerBody')}

+
+ +
+ ); +}; + +export default ActivateHotKeyBanner; diff --git a/src/app/templates/GuardianReplaceHotKey.tsx b/src/app/templates/GuardianReplaceHotKey.tsx new file mode 100644 index 000000000..c372e458e --- /dev/null +++ b/src/app/templates/GuardianReplaceHotKey.tsx @@ -0,0 +1,102 @@ +import React, { FC, useCallback, useState } from 'react'; + +import { useTranslation } from 'react-i18next'; + +import FormSubmitButton from 'app/atoms/FormSubmitButton'; +import { + initiateReplaceHotKeyTransaction, + requestSWTransactionProcessing, + waitForTransactionCompletion +} from 'lib/miden/activity'; +import { zustandProvider } from 'lib/miden/front/guardian-sync'; +import { isExtension } from 'lib/platform'; +import { isDelegateProofEnabled } from 'lib/settings/helpers'; +import { useWalletStore } from 'lib/store'; + +type Props = { + onClose?: () => void; +}; + +/** + * Proactive hot-key rotation. Cold-signed (recovery key); the on-chain proposal + * swaps the hot signer commitment in-place via update_signers. The seed phrase + * is NOT required — the cold key derived at create time is already in the vault. + */ +const GuardianReplaceHotKey: FC = ({ onClose }) => { + const { t } = useTranslation(); + const currentAccount = useWalletStore(s => s.currentAccount); + + const [confirming, setConfirming] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const onClick = useCallback(async () => { + if (!currentAccount) return; + if (!confirming) { + setConfirming(true); + return; + } + setSubmitting(true); + setError(null); + setSuccess(false); + try { + const txId = await initiateReplaceHotKeyTransaction( + currentAccount.publicKey, + isDelegateProofEnabled(), + zustandProvider + ); + useWalletStore.getState().openTransactionModal(); + if (isExtension()) requestSWTransactionProcessing(); + + const result = await waitForTransactionCompletion(txId); + if ('errorMessage' in result) { + setError(result.errorMessage); + return; + } + setSuccess(true); + setConfirming(false); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setSubmitting(false); + } + }, [confirming, currentAccount]); + + return ( +
+

{t('replaceHotKey')}

+

{t('replaceHotKeyDescription')}

+ + {confirming && !success && ( +
{t('replaceHotKeyConfirmation')}
+ )} + + + {confirming ? t('confirmReplaceHotKey') : t('replaceHotKey')} + + + {error &&
{error}
} + + {success && ( +
onClose?.()}> + {t('hotKeyRotated')} +
+ )} +
+ ); +}; + +export default GuardianReplaceHotKey; diff --git a/src/app/templates/GuardianSettings.tsx b/src/app/templates/GuardianSettings.tsx index 7bd71fbfe..bc49ba6f8 100644 --- a/src/app/templates/GuardianSettings.tsx +++ b/src/app/templates/GuardianSettings.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import FormField from 'app/atoms/FormField'; import FormSubmitButton from 'app/atoms/FormSubmitButton'; +import GuardianReplaceHotKey from 'app/templates/GuardianReplaceHotKey'; import { initiateSwitchGuardianTransaction, requestSWTransactionProcessing, @@ -30,6 +31,10 @@ const GuardianSettings: FC = ({ onClose }) => { const { t } = useTranslation(); const { endpoint: currentEndpoint, refresh: refreshCurrentEndpoint } = useCurrentGuardianEndpoint(); const [submitSuccess, setSubmitSuccess] = useState(false); + // Two-stage submit: first click validates + enters confirming, second click fires the tx. + // Mirrors GuardianReplaceHotKey's cold-signing confirmation since switch_guardian also + // requires the cold key (co-signed by the current guardian). + const [confirming, setConfirming] = useState(false); const { register, @@ -54,6 +59,11 @@ const GuardianSettings: FC = ({ onClose }) => { clearErrors(); setSubmitSuccess(false); + if (!confirming) { + setConfirming(true); + return; + } + try { const txId = await initiateSwitchGuardianTransaction( currentAccount.publicKey, @@ -71,6 +81,7 @@ const GuardianSettings: FC = ({ onClose }) => { } setSubmitSuccess(true); + setConfirming(false); reset({ guardianEndpoint: '' }); // Pull the new endpoint back from storage so the "Current guardian" // display reflects the switch on platforms without storage-change events. @@ -80,7 +91,7 @@ const GuardianSettings: FC = ({ onClose }) => { setError('guardianEndpoint', { type: 'manual', message }); } }, - [clearErrors, currentAccount, currentEndpoint, isSubmitting, refreshCurrentEndpoint, reset, setError, t] + [clearErrors, confirming, currentAccount, currentEndpoint, isSubmitting, refreshCurrentEndpoint, reset, setError, t] ); return ( @@ -106,9 +117,16 @@ const GuardianSettings: FC = ({ onClose }) => { onChange={() => { clearErrors(); if (submitSuccess) setSubmitSuccess(false); + // Editing the endpoint after confirming invalidates the confirmation: + // drop back to the form-entry stage so the user re-acknowledges the new value. + if (confirming) setConfirming(false); }} /> + {confirming && !isSubmitting && !submitSuccess && ( +
{t('switchGuardianConfirmation')}
+ )} + = ({ onClose }) => { paddingBottom: '12px' }} > - {t('switchGuardian')} + {confirming ? t('confirmSwitchGuardian') : t('switchGuardian')} {submitSuccess && ( @@ -131,6 +149,10 @@ const GuardianSettings: FC = ({ onClose }) => {
)} + +
+ +
); }; diff --git a/src/app/templates/RevealSecret.tsx b/src/app/templates/RevealSecret.tsx index 68ba61443..186fadd91 100644 --- a/src/app/templates/RevealSecret.tsx +++ b/src/app/templates/RevealSecret.tsx @@ -10,6 +10,7 @@ import { Button, ButtonVariant } from 'components/Button'; import { Vault } from 'lib/miden/back/vault'; import { useAccount, useSecretState, useMidenContext } from 'lib/miden/front'; import { getMidenClient, withWasmClientLock } from 'lib/miden/sdk/miden-client'; +import { useHideNavbarWhileOpen } from 'lib/mobile/useHideNavbarWhileOpen'; import { isMobile } from 'lib/platform'; import useCopyToClipboard from 'lib/ui/useCopyToClipboard'; @@ -20,12 +21,18 @@ type FormData = { }; type RevealSecretProps = { - reveal: 'private-key' | 'seed-phrase'; + reveal: 'private-key' | 'seed-phrase' | 'hot-key' | 'guardian-keys'; +}; + +type GuardianKeysBundle = { + coldPrivateKey: string; + coldPublicKey: string; + hotPublicKey?: string; }; const RevealSecret: FC = ({ reveal }) => { const { t } = useTranslation(); - const { revealMnemonic, revealPrivateKey } = useMidenContext(); + const { revealMnemonic, revealPrivateKey, revealHotKey, revealGuardianKeys } = useMidenContext(); const account = useAccount(); const { fieldRef: secretFieldRef } = useCopyToClipboard(); @@ -40,12 +47,21 @@ const RevealSecret: FC = ({ reveal }) => { const passwordValue = watch('password'); const [secret, setSecret] = useSecretState(); + const [guardianBundle, setGuardianBundle] = useState(null); const [hasHardwareProtector, setHasHardwareProtector] = useState(null); - // Private-key reveals require the user to tick an "I understand" - // checkbox before the Continue button enables. The warning banner - // alone is passive; this gate forces one deliberate interaction - // before handing out a key the user can never rotate. + // The native iOS / Android navbar pill renders in a separate UIWindow / Dialog + // above the WebView, so any content at the bottom of this page (notably the + // Unlock button on hardware-protected wallets, where there's no password + // input to push the button up) gets z-covered and becomes unclickable. + // Morph the pill out while the reveal screen is mounted; restores on unmount. + useHideNavbarWhileOpen(true); + // Private-key + guardian-keys reveals require the user to tick an "I + // understand" checkbox before the Continue button enables. The warning + // banner alone is passive; this gate forces one deliberate interaction + // before handing out recovery material. Hot-key reveal skips the gate + // because hot keys rotate from Settings → Rotate Device Key. const [privateKeyAcknowledged, setPrivateKeyAcknowledged] = useState(false); + const requiresAcknowledge = reveal === 'private-key' || reveal === 'guardian-keys'; useEffect(() => { Vault.hasHardwareProtector().then(setHasHardwareProtector); @@ -53,7 +69,10 @@ const RevealSecret: FC = ({ reveal }) => { useEffect(() => { if (account.publicKey) { - return () => setSecret(null); + return () => { + setSecret(null); + setGuardianBundle(null); + }; } return undefined; }, [account.publicKey, setSecret]); @@ -84,14 +103,16 @@ const RevealSecret: FC = ({ reveal }) => { clearErrors('password'); try { const unlockPassword = hasHardwareProtector ? undefined : password; - let secret: string; if (reveal === 'private-key') { const pubKeyCommitment = await getAccountPublicKeyCommitment(account.publicKey); - secret = await revealPrivateKey(pubKeyCommitment, unlockPassword); + setSecret(await revealPrivateKey(pubKeyCommitment, unlockPassword)); + } else if (reveal === 'hot-key') { + setSecret(await revealHotKey(account.publicKey, unlockPassword)); + } else if (reveal === 'guardian-keys') { + setGuardianBundle(await revealGuardianKeys(account.publicKey, unlockPassword)); } else { - secret = await revealMnemonic(unlockPassword); + setSecret(await revealMnemonic(unlockPassword)); } - setSecret(secret); } catch (err: any) { console.error(err); @@ -107,6 +128,8 @@ const RevealSecret: FC = ({ reveal }) => { setError, revealMnemonic, revealPrivateKey, + revealHotKey, + revealGuardianKeys, setSecret, focusPasswordField, hasHardwareProtector, @@ -149,10 +172,71 @@ const RevealSecret: FC = ({ reveal }) => {
) }; + + case 'hot-key': + return { + name: t('hotPrivateKey'), + accountBanner: null, + attention: null, + fieldDesc:
{t('revealHotKeyDescription')}
+ }; + + case 'guardian-keys': + return { + name: t('coldPrivateKey'), + accountBanner: null, + attention: null, + fieldDesc:
{t('guardianKeysRevealDescription')}
+ }; } }, [reveal, t]); const mainContent = useMemo(() => { + if (guardianBundle) { + return ( +
+ {texts.fieldDesc}
} + id="reveal-guardian-cold-private" + spellCheck={false} + className="resize-none notranslate" + value={guardianBundle.coldPrivateKey} + /> + + {guardianBundle.hotPublicKey && ( + + )} +
+ ); + } + if (secret) { return (
@@ -209,9 +293,21 @@ const RevealSecret: FC = ({ reveal }) => { )} ); - }, [errors, onSubmit, register, secret, texts, clearErrors, secretFieldRef, t, hasHardwareProtector, handleSubmit]); + }, [ + errors, + onSubmit, + register, + secret, + guardianBundle, + texts, + clearErrors, + secretFieldRef, + t, + hasHardwareProtector, + handleSubmit + ]); - const showButton = !secret; + const showButton = !secret && !guardianBundle; if (hasHardwareProtector === null) { return null; @@ -221,7 +317,7 @@ const RevealSecret: FC = ({ reveal }) => {
{texts.accountBanner} - {reveal === 'private-key' && !secret && ( + {requiresAcknowledge && showButton && ( <> = ({ reveal }) => { )} + {reveal === 'hot-key' && showButton && ( + {t('hotKeyRevealWarningBody')}

} + className="mb-4 rounded-lg" + /> + )} + {mainContent} {showButton && ( @@ -251,7 +356,7 @@ const RevealSecret: FC = ({ reveal }) => { title={t(hasHardwareProtector ? 'unlock' : 'continue')} disabled={ isSubmitting || - (reveal === 'private-key' && !privateKeyAcknowledged) || + (requiresAcknowledge && !privateKeyAcknowledged) || (hasHardwareProtector ? false : !passwordValue) } isLoading={isSubmitting} diff --git a/src/lib/biometric/localBiometricPlugin.ts b/src/lib/biometric/localBiometricPlugin.ts index 520f46e58..cdc4ecc5e 100644 --- a/src/lib/biometric/localBiometricPlugin.ts +++ b/src/lib/biometric/localBiometricPlugin.ts @@ -4,6 +4,9 @@ * using iOS LocalAuthentication framework directly. * * Also includes hardware security methods for Secure Enclave vault key protection. + * + * The Guardian per-account hot-key lives in its own plugin — see + * `src/lib/secure-hot-key/hotKeyPlugin.ts`. */ import { registerPlugin } from '@capacitor/core'; diff --git a/src/lib/intercom/mobile-adapter.ts b/src/lib/intercom/mobile-adapter.ts index d228b0cad..01b5884b3 100644 --- a/src/lib/intercom/mobile-adapter.ts +++ b/src/lib/intercom/mobile-adapter.ts @@ -106,6 +106,24 @@ export class MobileIntercomAdapter { privateKey: privateKey ?? '' }; + case WalletMessageType.RevealHotKeyRequest: { + const hotPrivateKey = await Actions.revealHotKey(req.accountPublicKey, req.password); + return { + type: WalletMessageType.RevealHotKeyResponse, + hotPrivateKey: hotPrivateKey ?? '' + }; + } + + case WalletMessageType.RevealGuardianKeysRequest: { + const keys = await Actions.revealGuardianKeys(req.accountPublicKey, req.password); + return { + type: WalletMessageType.RevealGuardianKeysResponse, + coldPrivateKey: keys?.coldPrivateKey ?? '', + coldPublicKey: keys?.coldPublicKey ?? '', + hotPublicKey: keys?.hotPublicKey + }; + } + case WalletMessageType.RemoveAccountRequest: await Actions.removeAccount(req.accountPublicKey, req.password); return { @@ -148,6 +166,20 @@ export class MobileIntercomAdapter { }; } + case WalletMessageType.PersistNewHotKeyRequest: { + await Actions.persistNewHotKey(req.newHotPubKey, req.newHotCiphertext); + return { + type: WalletMessageType.PersistNewHotKeyResponse + }; + } + + case WalletMessageType.SwapHotKeyRequest: { + await Actions.swapHotKey(req.accountPublicKey, req.newHotPubKey); + return { + type: WalletMessageType.SwapHotKeyResponse + }; + } + case WalletMessageType.GetPublicKeyForCommitmentRequest: { const publicKey = await Actions.getPublicKeyForCommitment(req.commitment); return { diff --git a/src/lib/miden/activity/transactions.branches.test.ts b/src/lib/miden/activity/transactions.branches.test.ts index 1eb44dfc3..bcd055133 100644 --- a/src/lib/miden/activity/transactions.branches.test.ts +++ b/src/lib/miden/activity/transactions.branches.test.ts @@ -118,14 +118,6 @@ jest.mock('lib/miden/front/guardian-manager', () => ({ clearGuardianServiceFor: jest.fn() })); -// Required positional arg for generateTransactionsLoop. It's never dereferenced -// here because isGuardianAccount is mocked to false (see note above). -const stubGuardianProvider = { - getAccounts: jest.fn(async () => []), - getPublicKeyForCommitment: jest.fn(async () => ''), - signWord: jest.fn(async () => '') -}; - jest.mock('./notes', () => ({ importAllNotes: jest.fn(), queueNoteImport: jest.fn() @@ -175,8 +167,16 @@ Object.defineProperty(globalThis.navigator, 'locks', { configurable: true }); +const stubGuardianProvider = { + getAccounts: jest.fn(async () => []), + getPublicKeyForCommitment: jest.fn(async () => 'pk'), + signWord: jest.fn(async () => 'sig') +}; + beforeEach(() => { jest.clearAllMocks(); + mockLastAuthError.mockReset(); + mockLastAuthError.mockImplementation((): unknown => null); txStore.length = 0; _g.__txBrTest.liveQueryCallbacks.length = 0; }); diff --git a/src/lib/miden/activity/transactions.gaps.test.ts b/src/lib/miden/activity/transactions.gaps.test.ts index bf968fb92..53f1fc26b 100644 --- a/src/lib/miden/activity/transactions.gaps.test.ts +++ b/src/lib/miden/activity/transactions.gaps.test.ts @@ -677,7 +677,10 @@ describe('generateTransaction execute + consume default switch arms', () => { guardianManager.isGuardianAccount.mockResolvedValueOnce(true); const fakeMultisigService = { createConsumeNotesProposal: jest.fn(async () => ({ id: 'proposal-1' })), - signAndCreateTransactionRequest: jest.fn(async () => ({ serialize: () => new Uint8Array([1]) })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), sync: jest.fn(async () => {}) }; guardianManager.getOrCreateMultisigService.mockResolvedValueOnce(fakeMultisigService); diff --git a/src/lib/miden/activity/transactions.guardian.test.ts b/src/lib/miden/activity/transactions.guardian.test.ts index d833dca6b..fe19ee237 100644 --- a/src/lib/miden/activity/transactions.guardian.test.ts +++ b/src/lib/miden/activity/transactions.guardian.test.ts @@ -12,11 +12,19 @@ */ import { + completeReplaceHotKeyTransaction, completeSwitchGuardianTransaction, + completeUpdateProcedureThresholdTransaction, generateTransaction, + initiateReplaceHotKeyTransaction, initiateSwitchGuardianTransaction } from './transactions'; -import { ITransactionStatus, SwitchGuardianTransaction } from '../db/types'; +import { + ITransactionStatus, + ReplaceHotKeyTransaction, + SwitchGuardianTransaction, + UpdateProcedureThresholdTransaction +} from '../db/types'; const txStore: Array> = []; const putToStorage = jest.fn(async (..._args: unknown[]) => {}); @@ -59,6 +67,13 @@ jest.mock('lib/miden/front/guardian-manager', () => ({ clearGuardianServiceFor: (...a: unknown[]) => mockClearGuardianServiceFor(...a) })); +const mockBuildColdMultisigService = jest.fn(); +jest.mock('lib/miden/guardian', () => ({ + MultisigService: { + buildColdMultisigService: (...a: unknown[]) => mockBuildColdMultisigService(...a) + } +})); + const mockWithWasmClientLock = jest.fn(async (fn: () => Promise) => fn()); const mockGetMidenClient = jest.fn(); // Match the relative path used by transactions.ts so the mock intercepts. @@ -145,18 +160,22 @@ describe('completeSwitchGuardianTransaction', () => { txStore.length = 0; }); - it('registers state with the new guardian, persists the URL, and marks the row Completed', async () => { + it('registers state with the new guardian, persists the per-account endpoint, and marks the row Completed', async () => { const tx = new SwitchGuardianTransaction('acc-1', 'https://new.guardian', false); txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); const multisigService = { finalizeGuardianSwitch: jest.fn(async () => {}) }; + const setGuardianEndpoint = jest.fn(async () => {}); + const provider = { ...makeGuardianProvider(true), setGuardianEndpoint }; - await completeSwitchGuardianTransaction(tx, makeResult() as never, multisigService as never); + await completeSwitchGuardianTransaction(tx, makeResult() as never, multisigService as never, provider as never); expect(multisigService.finalizeGuardianSwitch).toHaveBeenCalledWith('https://new.guardian'); - expect(putToStorage).toHaveBeenCalledWith('guardian_url_setting', 'https://new.guardian'); + // Per-account endpoint write, NOT the legacy global key. + expect(setGuardianEndpoint).toHaveBeenCalledWith('acc-1', 'https://new.guardian'); + expect(putToStorage).not.toHaveBeenCalled(); expect(mockClearGuardianServiceFor).toHaveBeenCalledWith('acc-1'); const row = txStore.find(r => r.id === tx.id) as Record; @@ -164,7 +183,7 @@ describe('completeSwitchGuardianTransaction', () => { expect(row.displayMessage).toBe('Guardian switched'); }); - it('marks the row Failed and skips the storage flip when registration throws', async () => { + it('marks the row Failed and skips the endpoint write when registration throws', async () => { const tx = new SwitchGuardianTransaction('acc-1', 'https://new.guardian', false); txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); @@ -173,11 +192,13 @@ describe('completeSwitchGuardianTransaction', () => { throw new Error('register failed'); }) }; + const setGuardianEndpoint = jest.fn(async () => {}); + const provider = { ...makeGuardianProvider(true), setGuardianEndpoint }; - await completeSwitchGuardianTransaction(tx, makeResult() as never, multisigService as never); + await completeSwitchGuardianTransaction(tx, makeResult() as never, multisigService as never, provider as never); - // The URL was NOT persisted because the guardian rejected the new state. - expect(putToStorage).not.toHaveBeenCalled(); + // The endpoint was NOT persisted because the guardian rejected the new state. + expect(setGuardianEndpoint).not.toHaveBeenCalled(); const row = txStore.find(r => r.id === tx.id) as Record; expect(row.status).toBe(ITransactionStatus.Failed); expect(row.displayMessage).toBe('Failed to switch guardian'); @@ -210,7 +231,10 @@ describe('generateTransaction — Guardian routing', () => { const multisigService = { createSendProposal: jest.fn(async () => ({ id: 'prop-1' })), - signAndCreateTransactionRequest: jest.fn(async () => ({ serialize: () => new Uint8Array([1]) })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), sync: jest.fn(async () => {}) }; mockGetOrCreateMultisigService.mockResolvedValue(multisigService); @@ -249,74 +273,86 @@ describe('generateTransaction — Guardian routing', () => { expect(multisigService.sync).toHaveBeenCalled(); }); - it('Guardian send: a failing post-completion sync does NOT flip the completed row to Failed', async () => { - const txId = 'send-guardian-sync-fail'; + it('Guardian consume: builds a consume-notes proposal off the noteId', async () => { + const txId = 'consume-guardian-1'; const result = makeResult(); - txStore.push({ - id: txId, - type: 'send', - accountId: 'guardian-acc', - status: ITransactionStatus.Queued, - displayMessage: 'Queued', - displayIcon: 'DEFAULT', - secondaryAccountId: 'recipient', - faucetId: 'faucet', - amount: '1000', - delegateTransaction: false, - initiatedAt: Math.floor(Date.now() / 1000) - }); - const multisigService = { - createSendProposal: jest.fn(async () => ({ id: 'prop-1' })), - signAndCreateTransactionRequest: jest.fn(async () => ({ serialize: () => new Uint8Array([1]) })), - // The on-chain submit already succeeded; the trailing bookkeeping sync throws. - sync: jest.fn(async () => Promise.reject(new Error('nonce is too low'))) + createConsumeNotesProposal: jest.fn(async () => ({ id: 'prop-consume' })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), + sync: jest.fn(async () => {}) }; mockGetOrCreateMultisigService.mockResolvedValue(multisigService); mockGetMidenClient.mockResolvedValue({ syncState: jest.fn(async () => {}), client: { transactions: { submit: jest.fn(async () => ({ result })) } } }); + txStore.push({ + id: txId, + type: 'consume', + accountId: 'guardian-acc', + status: ITransactionStatus.Queued, + noteId: 'note-xyz' + }); await generateTransaction( { id: txId, - type: 'send', + type: 'consume', accountId: 'guardian-acc', - secondaryAccountId: 'recipient', - faucetId: 'faucet', - amount: '1000', + noteId: 'note-xyz', delegateTransaction: false } as never, - jest.fn(async () => new Uint8Array([2])), + jest.fn(async () => new Uint8Array([1])), false, makeGuardianProvider(true) ); - const row = txStore.find(r => r.id === txId) as Record; - expect(row.status).toBe(ITransactionStatus.Completed); - expect(row.status).not.toBe(ITransactionStatus.Failed); + expect(multisigService.createConsumeNotesProposal).toHaveBeenCalledWith(['note-xyz']); + // A user-initiated (non-background) consume stays hot-bound — no cold service built. + expect(mockBuildColdMultisigService).not.toHaveBeenCalled(); }); - it('Guardian consume: builds a consume-notes proposal off the noteId', async () => { - const txId = 'consume-guardian-1'; + it('Guardian background/auto-consume: routes through the COLD key (no biometric prompt)', async () => { + const txId = 'consume-bg-1'; const result = makeResult(); - const multisigService = { - createConsumeNotesProposal: jest.fn(async () => ({ id: 'prop-consume' })), - signAndCreateTransactionRequest: jest.fn(async () => ({ serialize: () => new Uint8Array([1]) })), + const coldService = { + createConsumeNotesProposal: jest.fn(async () => ({ id: 'prop-consume-cold' })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), sync: jest.fn(async () => {}) }; - mockGetOrCreateMultisigService.mockResolvedValue(multisigService); + const hotService = { + createConsumeNotesProposal: jest.fn(async () => ({ id: 'prop-consume-hot' })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), + sync: jest.fn(async () => {}) + }; + mockGetOrCreateMultisigService.mockResolvedValue(hotService); + mockBuildColdMultisigService.mockResolvedValue(coldService); mockGetMidenClient.mockResolvedValue({ syncState: jest.fn(async () => {}), + getAccount: jest.fn(async () => ({ id: () => ({ toString: () => 'guardian-acc' }) })), client: { transactions: { submit: jest.fn(async () => ({ result })) } } }); + const provider = { + getAccounts: async () => [{ publicKey: 'guardian-acc', coldPublicKey: 'cold-pub', hotPublicKey: 'hot-pub' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig' + }; + mockIsGuardianAccount.mockResolvedValue(true); txStore.push({ id: txId, type: 'consume', accountId: 'guardian-acc', status: ITransactionStatus.Queued, - noteId: 'note-xyz' + noteId: 'note-bg' }); await generateTransaction( @@ -324,18 +360,22 @@ describe('generateTransaction — Guardian routing', () => { id: txId, type: 'consume', accountId: 'guardian-acc', - noteId: 'note-xyz', - delegateTransaction: false + noteId: 'note-bg', + delegateTransaction: false, + background: true } as never, jest.fn(async () => new Uint8Array([1])), false, - makeGuardianProvider(true) + provider as never ); - expect(multisigService.createConsumeNotesProposal).toHaveBeenCalledWith(['note-xyz']); + // Cold service is built and signs the consume; the hot (biometric) service is not used. + expect(mockBuildColdMultisigService).toHaveBeenCalled(); + expect(coldService.createConsumeNotesProposal).toHaveBeenCalledWith(['note-bg']); + expect(hotService.createConsumeNotesProposal).not.toHaveBeenCalled(); }); - it('Guardian switch-guardian: waits for chain inclusion before finalizing the switch', async () => { + it('Guardian switch-guardian: cold co-signs before hot, waits for chain inclusion, finalizes switch', async () => { const txId = 'switch-guardian-1'; const result = makeResult(); txStore.push({ @@ -351,15 +391,29 @@ describe('generateTransaction — Guardian routing', () => { proposal: { id: 'prop-switch' }, newEndpoint: 'https://new.guardian' })), - signAndCreateTransactionRequest: jest.fn(async () => ({ serialize: () => new Uint8Array([1]) })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), finalizeGuardianSwitch: jest.fn(async () => {}), sync: jest.fn(async () => {}) }; mockGetOrCreateMultisigService.mockResolvedValue(multisigService); + const coldService = { signProposal: jest.fn(async () => {}) }; + mockBuildColdMultisigService.mockResolvedValue(coldService); + + const provider = { + getAccounts: async () => [{ publicKey: 'guardian-acc', coldPublicKey: 'cold-pub', hotPublicKey: 'hot-pub' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig' + }; + mockIsGuardianAccount.mockResolvedValue(true); + const waitForTransactionCommit = jest.fn(async () => {}); mockGetMidenClient.mockResolvedValue({ syncState: jest.fn(async () => {}), + getAccount: jest.fn(async () => ({ id: () => ({ toString: () => 'guardian-acc' }) })), waitForTransactionCommit, client: { transactions: { submit: jest.fn(async () => ({ result })) } } }); @@ -374,16 +428,139 @@ describe('generateTransaction — Guardian routing', () => { } as never, jest.fn(async () => new Uint8Array([1])), false, - makeGuardianProvider(true) + provider as never ); - // The switch-guardian path must build the proposal via createSwitchGuardianProposal, - // wait for on-chain inclusion, and then finalize the switch (registering post-switch state). + // The switch-guardian path must build the proposal, cold co-signs first + // (threshold-2 satisfied on-chain), then hot signs + creates the request, + // then waits for inclusion and finalizes. expect(multisigService.createSwitchGuardianProposal).toHaveBeenCalledWith('https://new.guardian'); + expect(mockBuildColdMultisigService).toHaveBeenCalled(); + expect(coldService.signProposal).toHaveBeenCalledWith('prop-switch'); + // After the multisigService/signingService consolidation, the hot service IS + // the only service for non-replace-hot-key types — it drives the final + // signAndCreateTransactionRequest. + expect(multisigService.signAndCreateTransactionRequest).toHaveBeenCalledWith('prop-switch', undefined); expect(waitForTransactionCommit).toHaveBeenCalledWith('exec-tx-hash'); expect(multisigService.finalizeGuardianSwitch).toHaveBeenCalledWith('https://new.guardian'); }); + it('Guardian replace-hot-key: cold-signs the in-place swap, persists new ciphertext pre-submit, waits for inclusion', async () => { + const txId = 'replace-hot-1'; + const result = makeResult(); + txStore.push({ + id: txId, + type: 'replace-hot-key', + accountId: 'guardian-acc', + status: ITransactionStatus.Queued, + extraInputs: {} + }); + + const multisigService = { + // Hot service unused in replace-hot-key; signingService flips to cold. + sync: jest.fn(async () => {}) + }; + mockGetOrCreateMultisigService.mockResolvedValue(multisigService); + + const coldService = { + createReplaceHotKeyProposal: jest.fn(async () => ({ + proposal: { id: 'prop-replace' }, + newHot: { ciphertext: 'new-cx', publicKeyHex: 'new-hot-pub', commitmentHex: '0xnewcommit' } + })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })) + }; + mockBuildColdMultisigService.mockResolvedValue(coldService); + + const persistNewHotKey = jest.fn(async () => {}); + const provider = { + getAccounts: async () => [{ publicKey: 'guardian-acc', coldPublicKey: 'cold-pub', hotPublicKey: 'old-hot' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + persistNewHotKey, + swapHotKey: jest.fn(async () => {}) + }; + mockIsGuardianAccount.mockResolvedValue(true); + + const waitForTransactionCommit = jest.fn(async () => {}); + mockGetMidenClient.mockResolvedValue({ + syncState: jest.fn(async () => {}), + getAccount: jest.fn(async () => ({ id: () => ({ toString: () => 'guardian-acc' }) })), + waitForTransactionCommit, + client: { transactions: { submit: jest.fn(async () => ({ result })) } } + }); + + const submittedRow = txStore.find(r => r.id === txId)!; + + await generateTransaction( + { id: txId, type: 'replace-hot-key', accountId: 'guardian-acc', delegateTransaction: false } as never, + jest.fn(async () => new Uint8Array([1])), + false, + provider as never + ); + + expect(coldService.createReplaceHotKeyProposal).toHaveBeenCalled(); + // Persist BEFORE submit so the new ciphertext is durable on crash. + expect(persistNewHotKey).toHaveBeenCalledWith('new-hot-pub', 'new-cx'); + // Cold (signingService) drives signAndCreateTransactionRequest, NOT hot. + expect(coldService.signAndCreateTransactionRequest).toHaveBeenCalledWith('prop-replace', undefined); + // Persist newHotPublicKey on the transaction row so complete can find it. + expect((submittedRow.extraInputs as { newHotPublicKey?: string }).newHotPublicKey).toBe('new-hot-pub'); + // Replace-hot-key shares the confirming wait with switch-guardian. + expect(waitForTransactionCommit).toHaveBeenCalledWith('exec-tx-hash'); + }); + + it('Guardian update-procedure-threshold: cold-signs the threshold update', async () => { + const txId = 'upt-1'; + const result = makeResult(); + const coldService = { + createUpdateProcedureThresholdProposal: jest.fn(async () => ({ id: 'prop-upt' })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), + sync: jest.fn(async () => {}) + }; + mockGetOrCreateMultisigService.mockResolvedValue({ sync: jest.fn(async () => {}) }); + mockBuildColdMultisigService.mockResolvedValue(coldService); + mockGetMidenClient.mockResolvedValue({ + syncState: jest.fn(async () => {}), + getAccount: jest.fn(async () => ({ id: () => ({ toString: () => 'guardian-acc' }) })), + client: { transactions: { submit: jest.fn(async () => ({ result })) } } + }); + const provider = { + getAccounts: async () => [{ publicKey: 'guardian-acc', coldPublicKey: 'cold-pub', hotPublicKey: 'hot-pub' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig' + }; + mockIsGuardianAccount.mockResolvedValue(true); + txStore.push({ + id: txId, + type: 'update-procedure-threshold', + accountId: 'guardian-acc', + status: ITransactionStatus.Queued, + extraInputs: { procedure: 'update_guardian', threshold: 2 } + }); + + await generateTransaction( + { + id: txId, + type: 'update-procedure-threshold', + accountId: 'guardian-acc', + extraInputs: { procedure: 'update_guardian', threshold: 2 }, + delegateTransaction: false + } as never, + jest.fn(async () => new Uint8Array([1])), + false, + provider as never + ); + + expect(mockBuildColdMultisigService).toHaveBeenCalled(); + expect(coldService.createUpdateProcedureThresholdProposal).toHaveBeenCalledWith('update_guardian', 2); + }); + it('Guardian: unsupported transaction type cancels the transaction', async () => { const txId = 'unsupported-guardian'; txStore.push({ @@ -410,4 +587,461 @@ describe('generateTransaction — Guardian routing', () => { const row = txStore.find(r => r.id === txId) as Record; expect(row.status).toBe(ITransactionStatus.Failed); }); + + it('replace-hot-key apply-after-submit-failure reconciles the hot pointer instead of cancelling', async () => { + const txId = 'replace-apply-fail'; + const coldService = { + createReplaceHotKeyProposal: jest.fn(async () => ({ + proposal: { id: 'prop-replace' }, + newHot: { ciphertext: 'new-cx', publicKeyHex: 'new-hot-pub', commitmentHex: '0xnewcommit' } + })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })) + }; + mockBuildColdMultisigService.mockResolvedValue(coldService); + // ensureGuardianProcedureThresholds (run inside completeReplaceHotKeyTransaction) + // re-reads via getOrCreateMultisigService; stub it already-hardened so it no-ops. + mockGetOrCreateMultisigService.mockResolvedValue({ getProcedureThreshold: () => 2 }); + + const swapHotKey = jest.fn(async () => {}); + const provider = { + getAccounts: async () => [{ publicKey: 'guardian-acc', coldPublicKey: 'cold-pub', hotPublicKey: 'old-hot' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + persistNewHotKey: jest.fn(async () => {}), + swapHotKey + }; + mockIsGuardianAccount.mockResolvedValue(true); + + // The submit lands on chain but the LOCAL apply throws — the rotation is real. + const applyErr: Error & { errorCode?: string } = new Error('apply failed'); + applyErr.errorCode = 'ApplyTransactionAfterSubmitFailed'; + mockGetMidenClient.mockResolvedValue({ + syncState: jest.fn(async () => {}), + getAccount: jest.fn(async () => ({ id: () => ({ toString: () => 'guardian-acc' }) })), + waitForTransactionCommit: jest.fn(async () => {}), + client: { + transactions: { + submit: jest.fn(async () => { + throw applyErr; + }) + } + } + }); + + txStore.push({ id: txId, type: 'replace-hot-key', accountId: 'guardian-acc', status: ITransactionStatus.Queued }); + + await generateTransaction( + { id: txId, type: 'replace-hot-key', accountId: 'guardian-acc', delegateTransaction: false } as never, + jest.fn(async () => new Uint8Array([1])), + false, + provider as never + ); + + // The reconcile swapped the hot pointer; the tx is Completed, not cancelled/Failed. + expect(swapHotKey).toHaveBeenCalledWith('guardian-acc', 'new-hot-pub'); + const row = txStore.find(r => r.id === txId) as Record; + expect(row.status).toBe(ITransactionStatus.Completed); + }); + + it('switch-guardian apply-after-submit-failure re-registers + persists the endpoint instead of cancelling', async () => { + const txId = 'switch-apply-fail'; + const finalizeGuardianSwitch = jest.fn(async () => {}); + const service = { + createSwitchGuardianProposal: jest.fn(async () => ({ + proposal: { id: 'prop-switch' }, + newEndpoint: 'https://new.guardian' + })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), + finalizeGuardianSwitch, + sync: jest.fn(async () => {}) + }; + // Used for both the main proposal AND rebuilt in the reconcile for completion. + mockGetOrCreateMultisigService.mockResolvedValue(service); + // switch-guardian's cold co-sign uses a transient cold service. + mockBuildColdMultisigService.mockResolvedValue({ signProposal: jest.fn(async () => {}) }); + + const setGuardianEndpoint = jest.fn(async () => {}); + const provider = { + getAccounts: async () => [{ publicKey: 'guardian-acc', coldPublicKey: 'cold-pub', hotPublicKey: 'hot-pub' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + setGuardianEndpoint + }; + mockIsGuardianAccount.mockResolvedValue(true); + + const applyErr: Error & { errorCode?: string } = new Error('apply failed'); + applyErr.errorCode = 'ApplyTransactionAfterSubmitFailed'; + mockGetMidenClient.mockResolvedValue({ + syncState: jest.fn(async () => {}), + getAccount: jest.fn(async () => ({ id: () => ({ toString: () => 'guardian-acc' }) })), + waitForTransactionCommit: jest.fn(async () => {}), + client: { + transactions: { + submit: jest.fn(async () => { + throw applyErr; + }) + } + } + }); + + txStore.push({ + id: txId, + type: 'switch-guardian', + accountId: 'guardian-acc', + status: ITransactionStatus.Queued, + extraInputs: { newGuardianEndpoint: 'https://new.guardian' } + }); + + await generateTransaction( + { + id: txId, + type: 'switch-guardian', + accountId: 'guardian-acc', + extraInputs: { newGuardianEndpoint: 'https://new.guardian' }, + delegateTransaction: false + } as never, + jest.fn(async () => new Uint8Array([1])), + false, + provider as never + ); + + // The reconcile re-registered on the new guardian and persisted the per-account endpoint. + expect(finalizeGuardianSwitch).toHaveBeenCalledWith('https://new.guardian'); + expect(setGuardianEndpoint).toHaveBeenCalledWith('guardian-acc', 'https://new.guardian'); + const row = txStore.find(r => r.id === txId) as Record; + expect(row.status).toBe(ITransactionStatus.Completed); + }); + + it('cancels the tx when the structural apply-failure reconcile itself throws', async () => { + const txId = 'switch-reconcile-throws'; + const service = { + createSwitchGuardianProposal: jest.fn(async () => ({ + proposal: { id: 'prop-switch' }, + newEndpoint: 'https://new.guardian' + })), + signAndCreateTransactionRequest: jest.fn(async () => ({ + serialize: () => new Uint8Array([1]), + authArg: () => undefined + })), + sync: jest.fn(async () => {}) + }; + // First call serves the main proposal; the reconcile's rebuild rejects. + mockGetOrCreateMultisigService.mockResolvedValueOnce(service).mockRejectedValueOnce(new Error('rebuild failed')); + mockBuildColdMultisigService.mockResolvedValue({ signProposal: jest.fn(async () => {}) }); + + const provider = { + getAccounts: async () => [{ publicKey: 'guardian-acc', coldPublicKey: 'cold-pub', hotPublicKey: 'hot-pub' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + setGuardianEndpoint: jest.fn(async () => {}) + }; + mockIsGuardianAccount.mockResolvedValue(true); + + const applyErr: Error & { errorCode?: string } = new Error('apply failed'); + applyErr.errorCode = 'ApplyTransactionAfterSubmitFailed'; + mockGetMidenClient.mockResolvedValue({ + syncState: jest.fn(async () => {}), + getAccount: jest.fn(async () => ({ id: () => ({ toString: () => 'guardian-acc' }) })), + waitForTransactionCommit: jest.fn(async () => {}), + client: { + transactions: { + submit: jest.fn(async () => { + throw applyErr; + }) + } + } + }); + + txStore.push({ + id: txId, + type: 'switch-guardian', + accountId: 'guardian-acc', + status: ITransactionStatus.Queued, + extraInputs: { newGuardianEndpoint: 'https://new.guardian' } + }); + + await generateTransaction( + { + id: txId, + type: 'switch-guardian', + accountId: 'guardian-acc', + extraInputs: { newGuardianEndpoint: 'https://new.guardian' }, + delegateTransaction: false + } as never, + jest.fn(async () => new Uint8Array([1])), + false, + provider as never + ); + + // Reconcile failed → fall through to cancelTransaction → row Failed. + expect(provider.setGuardianEndpoint).not.toHaveBeenCalled(); + const row = txStore.find(r => r.id === txId) as Record; + expect(row.status).toBe(ITransactionStatus.Failed); + }); +}); + +describe('initiateReplaceHotKeyTransaction', () => { + beforeEach(() => { + jest.clearAllMocks(); + txStore.length = 0; + }); + + it('queues a ReplaceHotKeyTransaction row when the account is Guardian', async () => { + const provider = makeGuardianProvider(true); + const id = await initiateReplaceHotKeyTransaction('acc-1', false, provider); + + expect(id).toBeDefined(); + expect(txStore).toHaveLength(1); + const row = txStore[0] as Record; + expect(row.accountId).toBe('acc-1'); + expect(row.type).toBe('replace-hot-key'); + // extraInputs starts empty; populated during generateGuardianTransaction. + expect(row.extraInputs).toEqual({}); + }); + + it('throws when the target account is not a Guardian account', async () => { + const provider = makeGuardianProvider(false); + await expect(initiateReplaceHotKeyTransaction('acc-public', false, provider)).rejects.toThrow( + 'Replace hot key is only supported for Guardian accounts' + ); + expect(txStore).toHaveLength(0); + }); +}); + +describe('completeReplaceHotKeyTransaction', () => { + beforeEach(() => { + jest.clearAllMocks(); + txStore.length = 0; + }); + + it('calls swapHotKey with the new hot pubkey, drops the cached service, and marks the row Completed', async () => { + const tx = new ReplaceHotKeyTransaction('acc-1', false); + tx.extraInputs = { newHotPublicKey: 'new-hot-pub' }; + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + const swapHotKey = jest.fn(async () => {}); + const provider = { + getAccounts: async () => [{ publicKey: 'acc-1', hotPublicKey: 'old-hot-pub', coldPublicKey: 'cold' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + swapHotKey + }; + + await completeReplaceHotKeyTransaction(tx, makeResult() as never, provider as never); + + // The vault resolves the previous hot from the persisted WalletAccount — + // the caller passes only newHotPubKey. Vault.swapHotKey handles the + // idempotent case (old === new) internally. + expect(swapHotKey).toHaveBeenCalledWith('acc-1', 'new-hot-pub'); + expect(mockClearGuardianServiceFor).toHaveBeenCalledWith('acc-1'); + + const row = txStore.find(r => r.id === tx.id) as Record; + expect(row.status).toBe(ITransactionStatus.Completed); + expect(row.displayMessage).toBe('Device key rotated'); + }); + + it('re-registers the rotated state on the guardian BEFORE swapping the hot pointer', async () => { + // The OZ lib doesn't push update_signers state to the guardian; we must, and + // before swapHotKey arms the 3s hot-sync — otherwise the guardian's stale blob + // makes every subsequent sync diverge. + const tx = new ReplaceHotKeyTransaction('acc-1', false); + tx.extraInputs = { newHotPublicKey: 'new-hot-pub' }; + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + const order: string[] = []; + const reRegisterCurrentStateOnGuardian = jest.fn(async () => { + order.push('reregister'); + }); + const swapHotKey = jest.fn(async () => { + order.push('swap'); + }); + const provider = { ...makeGuardianProvider(true), swapHotKey }; + + await completeReplaceHotKeyTransaction( + tx, + makeResult() as never, + provider as never, + { reRegisterCurrentStateOnGuardian } as never + ); + + expect(reRegisterCurrentStateOnGuardian).toHaveBeenCalledTimes(1); + expect(swapHotKey).toHaveBeenCalledWith('acc-1', 'new-hot-pub'); + expect(order).toEqual(['reregister', 'swap']); + }); + + it('still completes the rotation (best-effort) when the guardian re-registration fails', async () => { + const tx = new ReplaceHotKeyTransaction('acc-1', false); + tx.extraInputs = { newHotPublicKey: 'new-hot-pub' }; + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + const swapHotKey = jest.fn(async () => {}); + const provider = { ...makeGuardianProvider(true), swapHotKey }; + const service = { + reRegisterCurrentStateOnGuardian: jest.fn(async () => { + throw new Error('guardian down'); + }) + }; + + await completeReplaceHotKeyTransaction(tx, makeResult() as never, provider as never, service as never); + + expect(swapHotKey).toHaveBeenCalledWith('acc-1', 'new-hot-pub'); + const row = txStore.find(r => r.id === tx.id) as Record; + expect(row.status).toBe(ITransactionStatus.Completed); + }); + + it('marks the row Failed when the provider does not implement swapHotKey', async () => { + const tx = new ReplaceHotKeyTransaction('acc-1', false); + tx.extraInputs = { newHotPublicKey: 'new-hot-pub' }; + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + // A provider without swapHotKey (e.g. the frontend zustand provider) cannot + // finalize a rotation — it must fail loudly rather than half-complete. + const provider = makeGuardianProvider(true); + + await completeReplaceHotKeyTransaction(tx, makeResult() as never, provider as never); + + const row = txStore.find(r => r.id === tx.id) as Record; + expect(row.status).toBe(ITransactionStatus.Failed); + expect(row.error).toContain('swapHotKey not implemented'); + }); + + it('enqueues a procedure-threshold hardening tx after rotation when update_guardian is unhardened', async () => { + const tx = new ReplaceHotKeyTransaction('acc-1', false); + tx.extraInputs = { newHotPublicKey: 'new-hot-pub' }; + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + mockIsGuardianAccount.mockResolvedValue(true); + // Post-rotation service reports no update_guardian threshold → needs hardening. + mockGetOrCreateMultisigService.mockResolvedValue({ + getProcedureThreshold: jest.fn(() => undefined), + sync: jest.fn(async () => {}) + }); + const provider = { + getAccounts: async () => [{ publicKey: 'acc-1', coldPublicKey: 'cold', hotPublicKey: 'old-hot-pub' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + swapHotKey: jest.fn(async () => {}) + }; + + await completeReplaceHotKeyTransaction(tx, makeResult() as never, provider as never); + + const upt = txStore.find(r => r.type === 'update-procedure-threshold') as Record; + expect(upt).toBeDefined(); + expect(upt.extraInputs).toEqual({ procedure: 'update_guardian', threshold: 2 }); + }); + + it('does NOT enqueue hardening when update_guardian is already at threshold 2 (recovered/fresh account)', async () => { + const tx = new ReplaceHotKeyTransaction('acc-1', false); + tx.extraInputs = { newHotPublicKey: 'new-hot-pub' }; + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + mockIsGuardianAccount.mockResolvedValue(true); + mockGetOrCreateMultisigService.mockResolvedValue({ + getProcedureThreshold: jest.fn(() => 2), + sync: jest.fn(async () => {}) + }); + const provider = { + getAccounts: async () => [{ publicKey: 'acc-1', coldPublicKey: 'cold', hotPublicKey: 'old-hot-pub' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + swapHotKey: jest.fn(async () => {}) + }; + + await completeReplaceHotKeyTransaction(tx, makeResult() as never, provider as never); + + expect(txStore.find(r => r.type === 'update-procedure-threshold')).toBeUndefined(); + }); + + it('still calls swapHotKey even when the row already reflects new hot (vault handles idempotency)', async () => { + const tx = new ReplaceHotKeyTransaction('acc-1', false); + tx.extraInputs = { newHotPublicKey: 'already-rotated' }; + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + const swapHotKey = jest.fn(async () => {}); + const provider = { + getAccounts: async () => [{ publicKey: 'acc-1', hotPublicKey: 'already-rotated', coldPublicKey: 'cold' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + swapHotKey + }; + + await completeReplaceHotKeyTransaction(tx, makeResult() as never, provider as never); + + // Idempotency moved into Vault.swapHotKey — the caller always invokes it. + expect(swapHotKey).toHaveBeenCalledWith('acc-1', 'already-rotated'); + const row = txStore.find(r => r.id === tx.id) as Record; + expect(row.status).toBe(ITransactionStatus.Completed); + }); + + it('marks the row Failed when extraInputs.newHotPublicKey is missing', async () => { + const tx = new ReplaceHotKeyTransaction('acc-1', false); + // intentionally leave extraInputs empty to simulate a corrupt/incomplete row + tx.extraInputs = {}; + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + const provider = { + getAccounts: async () => [{ publicKey: 'acc-1', hotPublicKey: 'old-hot-pub', coldPublicKey: 'cold' }], + getPublicKeyForCommitment: async () => 'pk', + signWord: async () => 'sig', + swapHotKey: jest.fn() + }; + + await completeReplaceHotKeyTransaction(tx, makeResult() as never, provider as never); + + const row = txStore.find(r => r.id === tx.id) as Record; + expect(row.status).toBe(ITransactionStatus.Failed); + expect(row.displayMessage).toBe('Failed to rotate device key'); + }); +}); + +describe('completeUpdateProcedureThresholdTransaction', () => { + beforeEach(() => { + jest.clearAllMocks(); + txStore.length = 0; + }); + + it('marks Completed, drops the cached service, and re-registers the new state on the guardian', async () => { + const tx = new UpdateProcedureThresholdTransaction('acc-1', 'update_guardian', 2, false); + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + const reRegisterCurrentStateOnGuardian = jest.fn(async () => {}); + + await completeUpdateProcedureThresholdTransaction( + tx, + makeResult() as never, + { + reRegisterCurrentStateOnGuardian + } as never + ); + + expect(reRegisterCurrentStateOnGuardian).toHaveBeenCalledTimes(1); + expect(mockClearGuardianServiceFor).toHaveBeenCalledWith('acc-1'); + const row = txStore.find(r => r.id === tx.id) as Record; + expect(row.status).toBe(ITransactionStatus.Completed); + expect(row.displayMessage).toBe('Account secured'); + }); + + it('still completes (best-effort) when the guardian re-registration fails', async () => { + const tx = new UpdateProcedureThresholdTransaction('acc-1', 'update_guardian', 2, false); + txStore.push({ id: tx.id, status: ITransactionStatus.GeneratingTransaction }); + + await completeUpdateProcedureThresholdTransaction( + tx, + makeResult() as never, + { + reRegisterCurrentStateOnGuardian: jest.fn(async () => { + throw new Error('guardian down'); + }) + } as never + ); + + const row = txStore.find(r => r.id === tx.id) as Record; + expect(row.status).toBe(ITransactionStatus.Completed); + }); }); diff --git a/src/lib/miden/activity/transactions.test.ts b/src/lib/miden/activity/transactions.test.ts index d559278c5..bc0890198 100644 --- a/src/lib/miden/activity/transactions.test.ts +++ b/src/lib/miden/activity/transactions.test.ts @@ -14,6 +14,7 @@ import { initiateSendTransaction, initiateConsumeTransaction, initiateConsumeTransactionFromId, + initiateUpdateProcedureThresholdTransaction, cancelStuckTransactions, cancelStaleQueuedTransactions, generateTransaction, @@ -609,6 +610,22 @@ describe('transactions utilities', () => { }); }); + describe('initiateUpdateProcedureThresholdTransaction', () => { + it('rejects when the account is not a Guardian account', async () => { + // Empty getAccounts() → isGuardianAccount short-circuits to false, so the + // procedure-threshold hardening tx is rejected up front. + const guardianProvider = { + getAccounts: async () => [], + getPublicKeyForCommitment: async () => '', + signWord: async () => '' + } as never; + + await expect( + initiateUpdateProcedureThresholdTransaction('acc-x', 'update_guardian', 2, false, guardianProvider) + ).rejects.toThrow('only supported for Guardian accounts'); + }); + }); + describe('cancelStuckTransactions', () => { it('cancels transactions that exceed MAX_WAIT_BEFORE_CANCEL', async () => { const nowInSeconds = Math.floor(Date.now() / 1000); diff --git a/src/lib/miden/activity/transactions.ts b/src/lib/miden/activity/transactions.ts index f89adb242..36d64d619 100644 --- a/src/lib/miden/activity/transactions.ts +++ b/src/lib/miden/activity/transactions.ts @@ -12,7 +12,6 @@ import { MultisigService } from 'lib/miden/guardian'; import { withGuardianAccountLock, withGuardianConflictRetry } from 'lib/miden/guardian/serialize'; import * as Repo from 'lib/miden/repo'; import { isExtension, isMobile } from 'lib/platform'; -import { GUARDIAN_URL_STORAGE_KEY } from 'lib/settings/constants'; import { u8ToB64 } from 'lib/shared/helpers'; import { WalletMessageType } from 'lib/shared/types'; import { getIntercom } from 'lib/store'; @@ -23,12 +22,13 @@ import { ITransaction, ITransactionStage, ITransactionStatus, + ReplaceHotKeyTransaction, SendTransaction, SwitchGuardianTransaction, Transaction, - TransactionOutput + TransactionOutput, + UpdateProcedureThresholdTransaction } from '../db/types'; -import { putToStorage } from '../front'; import { toNoteTypeString } from '../helpers'; import { getBech32AddressFromAccountId } from '../sdk/helpers'; import { getMidenClient, withWasmClientLock } from '../sdk/miden-client'; @@ -189,9 +189,12 @@ export const initiateConsumeTransactionFromId = async ( export const initiateConsumeTransaction = async ( accountId: string, note: ConsumableNote, - delegateTransaction?: boolean + delegateTransaction?: boolean, + // Background/auto-consume: routed through the cold key on Guardian accounts so + // a silent claim doesn't trigger a biometric prompt (see generateGuardianTransaction). + background?: boolean ): Promise => { - const dbTransaction = new ConsumeTransaction(accountId, note, delegateTransaction); + const dbTransaction = new ConsumeTransaction(accountId, note, delegateTransaction, background); // Dedup against all non-Failed consume txs for this noteId, including Completed ones. // Reason: getConsumableNotes() can still return a note for a short window after a local // consume completes (chain-sync lag). Without this, auto-consume polling creates a new @@ -387,9 +390,9 @@ export const initiateSendTransaction = async ( }; /** - * Queue a switch-guardian transaction for a Guardian account. The local - * `GUARDIAN_URL_STORAGE_KEY` is NOT updated here — it's written only after - * the on-chain proposal lands, in `completeSwitchGuardianTransaction`. + * Queue a switch-guardian transaction for a Guardian account. The per-account + * `guardianEndpoint` is NOT updated here — it's persisted only after the + * on-chain proposal lands, in `completeSwitchGuardianTransaction`. */ export const initiateSwitchGuardianTransaction = async ( accountId: string, @@ -405,13 +408,191 @@ export const initiateSwitchGuardianTransaction = async ( return dbTransaction.id; }; +/** + * Queue a replace-hot-key transaction for a Guardian account. The new hot key + * is generated lazily inside `generateGuardianTransaction` (so the cold service + * + secureHotKey facade are only touched once we're actually processing the + * tx) and persisted to the vault BEFORE submission. Cold-signed; default + * `update_signers` threshold (1) means cold alone satisfies on-chain. + */ +export const initiateReplaceHotKeyTransaction = async ( + accountId: string, + delegateTransaction: boolean | undefined, + guardianProvider: GuardianAccountProvider +): Promise => { + if (!(await isGuardianAccount(accountId, guardianProvider))) { + throw new Error('Replace hot key is only supported for Guardian accounts'); + } + const dbTransaction = new ReplaceHotKeyTransaction(accountId, delegateTransaction); + await Repo.transactions.add(dbTransaction); + return dbTransaction.id; +}; + +export const completeReplaceHotKeyTransaction = async ( + tx: ReplaceHotKeyTransaction, + result: TransactionResult | undefined, + guardianProvider: GuardianAccountProvider, + // The cold MultisigService used to drive the rotation. Supplied on the normal + // path so we can push the rotated state to the guardian below; absent on the + // apply-after-submit-failed reconcile path (runSync self-heals that case). + service?: MultisigService +) => { + try { + const newHotPublicKey = tx.extraInputs?.newHotPublicKey; + if (!newHotPublicKey) { + throw new Error('Replace-hot-key tx is missing newHotPublicKey in extraInputs'); + } + + if (!guardianProvider.swapHotKey) { + throw new Error('swapHotKey not implemented in this provider'); + } + + // The OZ lib submitted `update_signers` on-chain but did NOT re-register the + // rotated state on the guardian (it only does that for switch_guardian). Push + // it now — BEFORE `swapHotKey`, which sets `hotPublicKey` and thereby arms the + // ~3s guardian hot-sync. If we let the hot-sync start with the guardian's blob + // still pre-rotation, every tick throws on the guardian-vs-on-chain mismatch + // until a reinstall. Best-effort: an on-chain-successful rotation must not be + // failed by a guardian blip — runSync re-registers on a later tick if this slips. + if (service) { + try { + await service.reRegisterCurrentStateOnGuardian(); + } catch (e) { + console.warn('Failed to re-register rotated state on guardian post-replace-hot-key (non-fatal):', e); + } + } + + // Vault.swapHotKey resolves the previous hot pubkey from the persisted + // WalletAccount and is idempotent: if the record already reflects + // `newHotPublicKey` (retry), the cleanup branch is a no-op. + await guardianProvider.swapHotKey(tx.accountId, newHotPublicKey); + // Drop the cached MultisigService — its bound hot signer is now stale. + clearGuardianServiceFor(tx.accountId); + + await updateTransactionStatus(tx.id, ITransactionStatus.Completed, { + displayMessage: 'Device key rotated', + completedAt: Math.floor(Date.now() / 1000), + // `result` is absent on the apply-after-submit-failed reconcile path: the + // rotation is already on chain, we just lack the local TransactionResult. + ...(result && { + transactionId: result.executedTransaction().id().toHex(), + resultBytes: result.serialize() + }) + }); + + // The account now has both signers on-chain, so bring it up to the same + // hardening a freshly-created 3-key account has (update_guardian threshold + // 2 — which the update_signers rotation above can't carry). Best-effort and + // idempotent; never affects the rotation's success. + await ensureGuardianProcedureThresholds(tx.accountId, tx.delegateTransaction, guardianProvider); + } catch (error) { + console.error('Error completing replace-hot-key transaction:', error); + await updateTransactionStatus(tx.id, ITransactionStatus.Failed, { + displayMessage: 'Failed to rotate device key', + completedAt: Math.floor(Date.now() / 1000), + ...(result && { resultBytes: result.serialize() }), + error: error instanceof Error ? error.message : String(error) + }); + } +}; + +// The on-chain hardening a freshly-created 3-key Guardian account gets (see +// createGuardianAccount): changing the guardian requires both device keys. +const GUARDIAN_PROCEDURE_HARDENING = { procedure: 'update_guardian', threshold: 2 } as const; + +/** + * Ensure a Guardian account carries the `update_guardian` threshold-2 hardening + * that fresh 3-key accounts have. Migrated legacy accounts lack it; recovered / + * fresh accounts already have it (so this no-ops). Enqueues a cold-signed + * `update_procedure_threshold` when missing. Best-effort — never throws. + * + * Idempotent (gated on the on-chain threshold already being 2), so besides the + * post-rotation call it's also invoked self-healingly from the guardian sync — + * closing the window where a migrated account is 3-key but `update_guardian` is + * still threshold-1 because the original hardening tx was dropped. + */ +export const ensureGuardianProcedureThresholds = async ( + accountId: string, + delegateTransaction: boolean | undefined, + guardianProvider: GuardianAccountProvider +): Promise => { + try { + // Loading the service fetches the on-chain account config, including its + // procedure thresholds. + const service = await getOrCreateMultisigService(accountId, guardianProvider); + if ( + service.getProcedureThreshold(GUARDIAN_PROCEDURE_HARDENING.procedure) === GUARDIAN_PROCEDURE_HARDENING.threshold + ) { + return; + } + await initiateUpdateProcedureThresholdTransaction( + accountId, + GUARDIAN_PROCEDURE_HARDENING.procedure, + GUARDIAN_PROCEDURE_HARDENING.threshold, + delegateTransaction, + guardianProvider + ); + // Nudge the processor to pick up the freshly-queued tx. Dynamic import to + // avoid a static cycle with the activity barrel. + const { requestSWTransactionProcessing } = await import('lib/miden/activity'); + requestSWTransactionProcessing(); + } catch (e) { + console.warn('[guardian] procedure-threshold hardening skipped (non-fatal):', e); + } +}; + +export const initiateUpdateProcedureThresholdTransaction = async ( + accountId: string, + procedure: string, + threshold: number, + delegateTransaction: boolean | undefined, + guardianProvider: GuardianAccountProvider +): Promise => { + if (!(await isGuardianAccount(accountId, guardianProvider))) { + throw new Error('update-procedure-threshold is only supported for Guardian accounts'); + } + const dbTransaction = new UpdateProcedureThresholdTransaction(accountId, procedure, threshold, delegateTransaction); + await Repo.transactions.add(dbTransaction); + return dbTransaction.id; +}; + +export const completeUpdateProcedureThresholdTransaction = async ( + tx: UpdateProcedureThresholdTransaction, + result: TransactionResult, + // The cold MultisigService used to drive the threshold change, so we can push + // the new state to the guardian (the OZ lib doesn't re-register it). + service?: MultisigService +) => { + const executedTx = result.executedTransaction(); + await updateTransactionStatus(tx.id, ITransactionStatus.Completed, { + displayMessage: 'Account secured', + transactionId: executedTx.id().toHex(), + completedAt: Math.floor(Date.now() / 1000), + resultBytes: result.serialize() + }); + // The cached service's procedureThresholds are now stale — drop it. + clearGuardianServiceFor(tx.accountId); + + // Same gap as replace-hot-key: the OZ lib submitted `update_procedure_threshold` + // on-chain but never re-registered the new state on the guardian. Push it so the + // guardian's blob tracks the new threshold and the next sync doesn't diverge. + // Best-effort; runSync self-heals if this slips. + if (service) { + try { + await service.reRegisterCurrentStateOnGuardian(); + } catch (e) { + console.warn('Failed to re-register state on guardian post-update-procedure-threshold (non-fatal):', e); + } + } +}; + export const completeSwitchGuardianTransaction = async ( tx: SwitchGuardianTransaction, - result: TransactionResult, - multisigService: MultisigService + result: TransactionResult | undefined, + multisigService: MultisigService, + guardianProvider: GuardianAccountProvider ) => { try { - const executedTx = result.executedTransaction(); const { newGuardianEndpoint } = tx.extraInputs; // Mirror upstream `multisig.executeProposal`'s post-submit block for @@ -422,26 +603,57 @@ export const completeSwitchGuardianTransaction = async ( await setTransactionStage(tx.id, 'registering-guardian'); await multisigService.finalizeGuardianSwitch(newGuardianEndpoint); - await putToStorage(GUARDIAN_URL_STORAGE_KEY, newGuardianEndpoint); + // Persist the endpoint PER-ACCOUNT (not the legacy global key) so other + // Guardian accounts on different operators aren't clobbered. Backend + // providers implement setGuardianEndpoint; the optional-call guard keeps a + // frontend provider without it from throwing. + await guardianProvider.setGuardianEndpoint?.(tx.accountId, newGuardianEndpoint); clearGuardianServiceFor(tx.accountId); await updateTransactionStatus(tx.id, ITransactionStatus.Completed, { displayMessage: 'Guardian switched', - transactionId: executedTx.id().toHex(), completedAt: Math.floor(Date.now() / 1000), // seconds - resultBytes: result.serialize() + // `result` is absent on the apply-after-submit-failed reconcile path: the + // switch is already on chain, we just lack the local TransactionResult. + ...(result && { + transactionId: result.executedTransaction().id().toHex(), + resultBytes: result.serialize() + }) }); } catch (error) { console.error('Error completing switch guardian transaction:', error); await updateTransactionStatus(tx.id, ITransactionStatus.Failed, { displayMessage: 'Failed to switch guardian', completedAt: Math.floor(Date.now() / 1000), // seconds - resultBytes: result.serialize(), + ...(result && { resultBytes: result.serialize() }), error: error instanceof Error ? error.message : String(error) }); } }; +/** + * Run the structural side effects a structural Guardian op needs after its + * submit landed on chain but the LOCAL apply failed (`ApplyTransactionAfterSubmitFailed`). + * Without this the generic apply-failure handler would mark the tx Completed and + * skip reconciliation, stranding the account. + * + * replace-hot-key → swap the vault hot pointer (idempotent). + * switch-guardian → rebuild a service to drive `finalizeGuardianSwitch` (which + * re-syncs the post-switch account state itself) + persist the per-account + * endpoint. Both completion handlers tolerate a missing TransactionResult. + */ +async function reconcileStructuralApplyFailure( + tx: ITransaction, + guardianProvider: GuardianAccountProvider +): Promise { + if (tx.type === 'replace-hot-key') { + await completeReplaceHotKeyTransaction(tx as ReplaceHotKeyTransaction, undefined, guardianProvider); + return; + } + const service = await getOrCreateMultisigService(tx.accountId, guardianProvider); + await completeSwitchGuardianTransaction(tx as SwitchGuardianTransaction, undefined, service, guardianProvider); +} + const extractFullNote = (result: TransactionResult): Note | undefined => { try { const outputNotes = result.executedTransaction().outputNotes().notes(); @@ -806,6 +1018,7 @@ export const generateTransaction = async ( type: transaction.type, accountId: transaction.accountId }); + // Route Guardian accounts through Guardian service if (await isGuardianAccount(transaction.accountId, guardianProvider)) { try { @@ -817,6 +1030,24 @@ export const generateTransaction = async ( generateGuardianTransaction(transaction, signCallback, guardianProvider) ); } catch (error) { + // Submit-succeeded-but-local-apply-failed on a structural op (replace-hot-key + // / switch-guardian) is special: the change IS on chain, but the failure + // happened before generateGuardianTransaction's completion handler ran, so + // the vault hot pointer / guardian re-registration are un-reconciled. Cancelling + // would strand the account (signing with a rotated-out key, or talking to the + // old guardian). Run the same finalization the happy path would; only cancel if + // that reconcile itself fails. + if ( + extractSdkErrorCode(error) === 'ApplyTransactionAfterSubmitFailed' && + (transaction.type === 'replace-hot-key' || transaction.type === 'switch-guardian') + ) { + try { + await reconcileStructuralApplyFailure(transaction, guardianProvider); + return; + } catch (reconcileError) { + console.error('Structural-op apply-failure reconcile failed; cancelling', reconcileError); + } + } await cancelTransaction(transaction, error); } return; @@ -874,6 +1105,29 @@ export const generateTransaction = async ( } }; +/** + * Build a transient cold-bound MultisigService for `accountId`. Cold signing + * goes through the SDK keystore (not the SE/StrongBox-wrapped hot key), so it + * never triggers a biometric prompt — used for background/auto-consume. + */ +const buildColdServiceForAccount = async ( + accountId: string, + guardianProvider: GuardianAccountProvider +): Promise => { + const walletAccount = (await guardianProvider.getAccounts()).find(a => a.publicKey === accountId); + if (!walletAccount) { + throw new Error(`Guardian account ${accountId} not found in provider`); + } + const sdkAccount = await withWasmClientLock(async () => { + const midenClient = await getMidenClient(); + return midenClient.getAccount(accountId); + }); + if (!sdkAccount) { + throw new Error(`Guardian account ${accountId} not found in local client`); + } + return MultisigService.buildColdMultisigService(sdkAccount, walletAccount, guardianProvider.signWord); +}; + /** * Generate a transaction for a Guardian account using the MultisigService. * Routes the transaction through MultisigService proposal methods. @@ -889,44 +1143,156 @@ const generateGuardianTransaction = async ( // so surfacing "Creating proposal" immediately is more honest than // leaving the label stuck on "Sending transaction". await setTransactionStage(transaction.id, 'creating-proposal'); - const multisigService = await getOrCreateMultisigService(transaction.accountId, guardianProvider); - // Creating a proposal POSTs to the guardian, which returns 409 while a prior - // delta for this account is still canonicalizing. Wait it out rather than - // failing the transaction — with per-account serialization above, a 409 means - // "the previous delta hasn't finalized yet", not a genuine conflict. - const proposalResult: Proposal = await withGuardianConflictRetry(async () => { - switch (transaction.type) { - case 'send': { - const sendTx = transaction as SendTransaction; - return multisigService.createSendProposal(sendTx.secondaryAccountId, sendTx.faucetId, BigInt(sendTx.amount)); + let proposalResult: Proposal; + // The service that creates the proposal AND issues the final + // signAndCreateTransactionRequest. Hot-bound for routine ops; cold-bound for + // structural ops (replace-hot-key / update-procedure-threshold) and for + // background auto-consume. The hot-bound path is the only one cached by + // guardian-manager; cold services here are transient. + // + // `withGuardianConflictRetry` waits out a transient 409 ConflictPendingDelta + // (a prior delta still canonicalizing) instead of failing the tx. It wraps + // only side-effect-free proposal creation — NOT replace-hot-key, whose + // createReplaceHotKeyProposal mints a fresh hardware hot key, so retrying it + // would orphan SE/StrongBox keys. + let service: MultisigService; + + switch (transaction.type) { + case 'send': { + const sendTx = transaction as SendTransaction; + service = await getOrCreateMultisigService(transaction.accountId, guardianProvider); + proposalResult = await withGuardianConflictRetry(() => + service.createSendProposal(sendTx.secondaryAccountId, sendTx.faucetId, BigInt(sendTx.amount)) + ); + break; + } + case 'consume': { + const consumeTx = transaction as ConsumeTransaction; + // Background/auto-consume signs with the COLD key so it doesn't pop a + // biometric prompt: consuming a note is value-in (claims an incoming note + // into your own vault — it can't move funds out), so it needs no user + // presence, and threshold-1 means cold + guardian satisfies it on-chain. + // User-initiated claims stay hot-bound (tap-to-confirm biometric on mobile). + service = consumeTx.background + ? await buildColdServiceForAccount(transaction.accountId, guardianProvider) + : await getOrCreateMultisigService(transaction.accountId, guardianProvider); + proposalResult = await withGuardianConflictRetry(() => service.createConsumeNotesProposal([consumeTx.noteId])); + break; + } + case 'switch-guardian': { + const sgTx = transaction as SwitchGuardianTransaction; + service = await getOrCreateMultisigService(transaction.accountId, guardianProvider); + const { proposal } = await withGuardianConflictRetry(() => + service.createSwitchGuardianProposal(sgTx.extraInputs.newGuardianEndpoint) + ); + proposalResult = proposal; + break; + } + case 'replace-hot-key': { + const walletAccount = (await guardianProvider.getAccounts()).find(a => a.publicKey === transaction.accountId); + if (!walletAccount) { + throw new Error(`Guardian account ${transaction.accountId} not found in provider`); } - case 'consume': { - const consumeTx = transaction as ConsumeTransaction; - return multisigService.createConsumeNotesProposal([consumeTx.noteId]); + const sdkAccount = await withWasmClientLock(async () => { + const midenClient = await getMidenClient(); + return midenClient.getAccount(transaction.accountId); + }); + if (!sdkAccount) { + throw new Error(`Guardian account ${transaction.accountId} not found in local client`); } - case 'switch-guardian': { - const sgTx = transaction as SwitchGuardianTransaction; - const { proposal } = await multisigService.createSwitchGuardianProposal(sgTx.extraInputs.newGuardianEndpoint); - return proposal; + service = await MultisigService.buildColdMultisigService(sdkAccount, walletAccount, guardianProvider.signWord); + // NOT retry-wrapped — createReplaceHotKeyProposal mints a hot key. + const { proposal, newHot } = await service.createReplaceHotKeyProposal(sdkAccount); + if (!guardianProvider.persistNewHotKey) { + throw new Error('persistNewHotKey not implemented in this provider'); } - case 'execute': - default: { - // For custom transactions, get TransactionSummary and create a custom proposal - if (!transaction.requestBytes) { - throw new Error('Request Bytes not availalbe for custom transaction'); - } - return multisigService.createCustomProposal(transaction.requestBytes); + // Persist the new hot ciphertext BEFORE submitting. Old hot stays valid + // until the on-chain rotation lands so this is idempotent. If the app + // dies between submit and complete, the new ciphertext is on disk and + // complete reconciles against the on-chain state. + // KNOWN LEAK: if this rotation terminally fails (submit error → tx + // cancelled, never reconciled) and the user re-initiates, a fresh hardware + // key is minted while this one's SE/Keystore entry + ciphertext blob are + // left orphaned (inert). A blind delete-on-failure here is unsafe — the + // persist-before-submit design relies on this blob surviving for the + // reconcile path — so reaping orphaned pending keys belongs in a dedicated + // cleanup, not this hot path. + await guardianProvider.persistNewHotKey(newHot.publicKeyHex, newHot.ciphertext); + // Stash the new pubkey on the in-memory transaction AND in dexie so + // complete (which may run after a process restart) can find it. + const rTx = transaction as ReplaceHotKeyTransaction; + rTx.extraInputs = { ...(rTx.extraInputs ?? {}), newHotPublicKey: newHot.publicKeyHex }; + await Repo.transactions.where({ id: transaction.id }).modify(t => { + t.extraInputs = rTx.extraInputs; + }); + proposalResult = proposal; + break; + } + case 'update-procedure-threshold': { + // Cold-routed structural change (same class as switch-guardian / + // replace-hot-key): cold + guardian satisfies it on-chain. + const uptTx = transaction as UpdateProcedureThresholdTransaction; + service = await buildColdServiceForAccount(transaction.accountId, guardianProvider); + proposalResult = await withGuardianConflictRetry(() => + service.createUpdateProcedureThresholdProposal(uptTx.extraInputs.procedure, uptTx.extraInputs.threshold) + ); + break; + } + case 'execute': + default: { + // For custom transactions, build a custom proposal from the serialized + // request bytes. Hot-routed (threshold-1). A custom proposal that embeds a + // structural op (e.g. update_guardian / add_signer) cannot bypass the + // hardening: the on-chain `procedureThresholds` map enforces per-procedure + // thresholds (update_guardian = 2 → needs cold + guardian) during proof + // verification regardless of which key signed the proposal here. + const requestBytes = transaction.requestBytes; + if (!requestBytes) { + throw new Error('Request Bytes not available for custom transaction'); } + service = await getOrCreateMultisigService(transaction.accountId, guardianProvider); + proposalResult = await withGuardianConflictRetry(() => service.createCustomProposal(requestBytes)); + break; } - }); + } // Sign and execute the proposal await setTransactionStage(transaction.id, 'signing-proposal'); - const tr = await multisigService.signAndCreateTransactionRequest(proposalResult.id, transaction.requestBytes); + + // switch_guardian is on-chain threshold-2 (set at create time via + // procedureThresholds). Hot's signAndCreateTransactionRequest below + // contributes one sig; we add the cold sig here. Sigs accumulate on the + // Guardian server keyed by proposal id so order doesn't matter, and the + // transient cold service is dropped at scope exit. + if (transaction.type === 'switch-guardian') { + const walletAccount = (await guardianProvider.getAccounts()).find(a => a.publicKey === transaction.accountId); + if (!walletAccount) { + throw new Error(`Guardian account ${transaction.accountId} not found in provider`); + } + const sdkAccount = await withWasmClientLock(async () => { + const midenClient = await getMidenClient(); + return midenClient.getAccount(transaction.accountId); + }); + if (!sdkAccount) { + throw new Error(`Guardian account ${transaction.accountId} not found in local client`); + } + const coldService = await MultisigService.buildColdMultisigService( + sdkAccount, + walletAccount, + guardianProvider.signWord + ); + // Wait out a transient 409 ConflictPendingDelta on the cold co-sign too — + // otherwise a prior delta mid-canonicalization fails the whole switch even + // though the hot proposal already landed. + await withGuardianConflictRetry(() => coldService.signProposal(proposalResult.id)); + } + + const tr = await service.signAndCreateTransactionRequest(proposalResult.id, transaction.requestBytes); console.log('Created transaction request from proposal, submitting to Miden client'); const options: MidenClientCreateOptions = { signCallback: async (publicKey: Uint8Array, signingInputs: Uint8Array) => { + console.log('Signing transaction request with external callback'); const keyString = Buffer.from(publicKey).toString('hex'); const signingInputsString = Buffer.from(signingInputs).toString('hex'); return await signCallback(keyString, signingInputsString); @@ -935,20 +1301,34 @@ const generateGuardianTransaction = async ( await setTransactionStage(transaction.id, 'submitting'); const transactionResult = await withWasmClientLock(async () => { - const midenClient = await getMidenClient(options); - console.log('Submitting transaction request to Miden client'); - const { result } = await midenClient.client.transactions.submit(transaction.accountId, tr, { - prover: !transaction.delegateTransaction ? TransactionProver.newLocalProver() : undefined - }); - console.log('Transaction request submitted, waiting for result'); - return result; + try { + const midenClient = await getMidenClient(options); + const { result } = await midenClient.client.transactions.submit(transaction.accountId, tr, { + prover: !transaction.delegateTransaction ? TransactionProver.newLocalProver() : undefined + }); + return result; + } catch (error) { + console.error('Error during transaction submission or execution', { error }); + throw error; + } }); // For switch-guardian, the new guardian must be seeded with the POST-switch // account state. submit() returns after submission, not after inclusion, so // without this wait finalizeGuardianSwitch would serialize the pre-switch // account and register that stale state with the new guardian. - if (transaction.type === 'switch-guardian') { + // For replace-hot-key, we wait so the WalletAccount.hotPublicKey swap in + // complete only happens once the on-chain rotation is final — otherwise a + // resync could race with stale on-chain state and pick the wrong canonical + // hot pubkey. + // For update-procedure-threshold, we wait so the post-completion guardian + // re-registration serializes the COMMITTED post-threshold state — otherwise it + // could push a pre-threshold blob and leave the guardian diverged again. + if ( + transaction.type === 'switch-guardian' || + transaction.type === 'replace-hot-key' || + transaction.type === 'update-procedure-threshold' + ) { await setTransactionStage(transaction.id, 'confirming'); await withWasmClientLock(async () => { const midenClient = await getMidenClient(); @@ -968,7 +1348,25 @@ const generateGuardianTransaction = async ( await completeSwitchGuardianTransaction( transaction as SwitchGuardianTransaction, transactionResult, - multisigService + service, + guardianProvider + ); + break; + case 'replace-hot-key': + console.log('Completing replace-hot-key transaction'); + await completeReplaceHotKeyTransaction( + transaction as ReplaceHotKeyTransaction, + transactionResult, + guardianProvider, + service + ); + break; + case 'update-procedure-threshold': + console.log('Completing update-procedure-threshold transaction'); + await completeUpdateProcedureThresholdTransaction( + transaction as UpdateProcedureThresholdTransaction, + transactionResult, + service ); break; case 'execute': @@ -976,15 +1374,25 @@ const generateGuardianTransaction = async ( await completeCustomTransaction(transaction, transactionResult); break; } - // Post-completion bookkeeping only. The transaction is already marked - // Completed above, and the on-chain submit succeeded — a failure to refresh - // multisig state here (e.g. nonce-too-low retries exhausted, or a network - // blip) must NOT propagate, or `generateTransaction`'s catch would flip a - // genuinely-successful transaction to Failed. The next sync tick reconciles. - try { - await multisigService.sync(); - } catch (error) { - console.warn('[Guardian] post-completion sync failed; will reconcile on next tick', error); + // Sync the cached hot service so the next consumer sees post-tx state. + // Skip for replace-hot-key: that path's service is a transient cold one, + // and the cached hot service was invalidated in completeReplaceHotKeyTransaction + // via clearGuardianServiceFor — next access re-inits with the new hot pubkey. + // + // Post-completion bookkeeping only: the transaction is already marked Completed + // and the on-chain submit succeeded, so a sync failure here must NOT propagate + // (it would flip a genuinely-successful transaction to Failed). The next sync + // tick reconciles. + // replace-hot-key and update-procedure-threshold both run on a transient cold + // service and invalidate the cached hot service in their completion handlers, + // so there's nothing useful to sync here. + if (transaction.type !== 'replace-hot-key' && transaction.type !== 'update-procedure-threshold') { + try { + console.log('Transaction generation complete, syncing multisig service'); + await service.sync(); + } catch (error) { + console.warn('[Guardian] post-completion sync failed; will reconcile on next tick', error); + } } }; @@ -1070,6 +1478,11 @@ export const generateTransactionsLoop = async ( logger.warning('Transaction submitted but local apply failed; marking Completed, sync will reconcile'); const tx = await Repo.transactions.where({ id: nextTransaction.id }).first(); if (tx && tx.status !== ITransactionStatus.Completed) { + // Structural Guardian ops never reach here — they're routed through the + // guardian branch of `generateTransaction`, whose own catch handles the + // apply-after-submit-failed reconcile (see `reconcileStructuralApplyFailure`). + // This generic path covers send/consume, whose note states the next sync + // reconciles via ConsumedExternal. await updateTransactionStatus(tx.id, ITransactionStatus.Completed, { displayMessage: 'Completed', completedAt: Math.floor(Date.now() / 1000) diff --git a/src/lib/miden/back/actions.test.ts b/src/lib/miden/back/actions.test.ts index 637609ea8..605b8a8d6 100644 --- a/src/lib/miden/back/actions.test.ts +++ b/src/lib/miden/back/actions.test.ts @@ -234,6 +234,7 @@ describe('actions', () => { it('calls Vault.setup and unlocked with password', async () => { const { Vault } = jest.requireMock('lib/miden/back/vault'); const mockVaultInstance = { + migrateLegacyGuardianAccounts: jest.fn().mockResolvedValue(undefined), fetchAccounts: jest.fn().mockResolvedValue([]), fetchSettings: jest.fn().mockResolvedValue({}), getCurrentAccount: jest.fn().mockResolvedValue(null), @@ -244,6 +245,7 @@ describe('actions', () => { await unlock('password123'); expect(Vault.setup).toHaveBeenCalledWith('password123'); + expect(mockVaultInstance.migrateLegacyGuardianAccounts).toHaveBeenCalled(); expect(mockVaultInstance.fetchAccounts).toHaveBeenCalled(); expect(mockVaultInstance.fetchSettings).toHaveBeenCalled(); expect(mockUnlocked).toHaveBeenCalled(); diff --git a/src/lib/miden/back/actions.ts b/src/lib/miden/back/actions.ts index 9aa7979ea..41174bc51 100644 --- a/src/lib/miden/back/actions.ts +++ b/src/lib/miden/back/actions.ts @@ -187,6 +187,10 @@ export function unlock(password?: string) { return withInited(() => getUnlockQueue().add(async () => { const vault = await Vault.setup(password); + // Bring any pre-3-key Guardian accounts into the 3-key model in place + // (best-effort, never throws) so they surface the Activate Device Key + // banner instead of being unreachable. See Vault.migrateLegacyGuardianAccounts. + await vault.migrateLegacyGuardianAccounts(); const accounts = await vault.fetchAccounts(); const settings = await vault.fetchSettings(); const currentAccount = await vault.getCurrentAccount(); @@ -239,8 +243,22 @@ export function revealPrivateKey(accPubKeyCommitment: string, password?: string) return withInited(() => Vault.revealPrivateKey(accPubKeyCommitment, password)); } +export function revealHotKey(accountPublicKey: string, password?: string) { + return withInited(() => Vault.revealHotKey(accountPublicKey, password)); +} + +export function revealGuardianKeys(accountPublicKey: string, password?: string) { + return withInited(() => Vault.revealGuardianKeys(accountPublicKey, password)); +} + export function revealPublicKey(_accPublicKey: string) {} +// NOTE: account removal is not implemented (no-op since the aleo port). The +// "Remove Account" UI therefore currently does nothing. When this is wired up, +// it MUST, for Guardian accounts, release the hardware-backed hot key via +// `secureHotKey.deleteHotKey()` and remove the cold-key blob +// (`accColdSecretKeyStrgKey`) in addition to the account record/keys — otherwise +// the SE/Keystore entry and cold key material outlive the deleted account. export function removeAccount(_accPublicKey: string, _password: string) {} export function editAccount(accPublicKey: string, name: string) { @@ -304,6 +322,24 @@ export function signWord(publicKey: string, wordHex: string) { }); } +export function persistNewHotKey(newHotPubKey: string, newHotCiphertext: string) { + return withUnlocked(async ({ vault }) => { + await vault.persistNewHotKey(newHotPubKey, newHotCiphertext); + }); +} + +export function swapHotKey(accountPublicKey: string, newHotPubKey: string) { + return withUnlocked(async ({ vault }) => { + const updated = await vault.swapHotKey(accountPublicKey, newHotPubKey); + // Push the updated WalletAccount[] into the Effector store so the + // frontStore mapping fires StateUpdated. Without this, the popup's Zustand + // `accounts[i].hotPublicKey` stays at the pre-rotation value, the next + // sync cycle reads the stale pubkey, and `getOrCreateMultisigService` + // re-binds against the old hot key. + accountsUpdated(updated); + }); +} + export function getPublicKeyForCommitment(commitment: string) { return withUnlocked(async ({ vault }) => { return await vault.getPublicKeyForCommitment(commitment); diff --git a/src/lib/miden/back/main.ts b/src/lib/miden/back/main.ts index 0fc04dda7..f188289e7 100644 --- a/src/lib/miden/back/main.ts +++ b/src/lib/miden/back/main.ts @@ -195,6 +195,22 @@ async function processRequest(req: WalletRequest, _port: Runtime.Port): Promise< type: WalletMessageType.RevealPrivateKeyResponse, privateKey: privateKey ?? '' }; + case WalletMessageType.RevealHotKeyRequest: { + const hotPrivateKey = await Actions.revealHotKey(req.accountPublicKey, req.password); + return { + type: WalletMessageType.RevealHotKeyResponse, + hotPrivateKey: hotPrivateKey ?? '' + }; + } + case WalletMessageType.RevealGuardianKeysRequest: { + const keys = await Actions.revealGuardianKeys(req.accountPublicKey, req.password); + return { + type: WalletMessageType.RevealGuardianKeysResponse, + coldPrivateKey: keys?.coldPrivateKey ?? '', + coldPublicKey: keys?.coldPublicKey ?? '', + hotPublicKey: keys?.hotPublicKey + }; + } case WalletMessageType.RevealMnemonicRequest: const mnemonic = await Actions.revealMnemonic(req.password); return { @@ -244,6 +260,16 @@ async function processRequest(req: WalletRequest, _port: Runtime.Port): Promise< type: WalletMessageType.SignWordResponse, signature: wordSignature }; + case WalletMessageType.PersistNewHotKeyRequest: + await Actions.persistNewHotKey(req.newHotPubKey, req.newHotCiphertext); + return { + type: WalletMessageType.PersistNewHotKeyResponse + }; + case WalletMessageType.SwapHotKeyRequest: + await Actions.swapHotKey(req.accountPublicKey, req.newHotPubKey); + return { + type: WalletMessageType.SwapHotKeyResponse + }; case WalletMessageType.GetPublicKeyForCommitmentRequest: const commitmentPublicKey = await Actions.getPublicKeyForCommitment(req.commitment); return { diff --git a/src/lib/miden/back/transaction-processor.ts b/src/lib/miden/back/transaction-processor.ts index dbaa4d57d..06e365efe 100644 --- a/src/lib/miden/back/transaction-processor.ts +++ b/src/lib/miden/back/transaction-processor.ts @@ -12,7 +12,7 @@ import { type GuardianAccountProvider } from 'lib/miden/front/guardian-manager'; import { WalletMessageType } from 'lib/shared/types'; import { getIntercom } from './defaults'; -import { withUnlocked } from './store'; +import { accountsUpdated, withUnlocked } from './store'; // NOTE: `webextension-polyfill` throws at module load time when // `globalThis.chrome?.runtime?.id` is undefined (non-extension @@ -77,6 +77,37 @@ const vaultGuardianProvider: GuardianAccountProvider = { return withUnlocked(async ({ vault }) => { return await vault.signWord(publicKey, wordHex); }); + }, + persistNewHotKey: async (newHotPubKey: string, newHotCiphertext: string) => { + return withUnlocked(async ({ vault }) => { + await vault.persistNewHotKey(newHotPubKey, newHotCiphertext); + }); + }, + swapHotKey: async (accountPublicKey: string, newHotPubKey: string) => { + // Fire `accountsUpdated` after the vault swap so the SW's Effector store + // reflects the new hotPublicKey. Without this, storage is correct but the + // Effector snapshot served via frontStore stays at the pre-rotation + // accounts array — every popup that pulls state then sees the OLD + // hotPublicKey, builds a MultisigService bound to it, and signWord trips + // "Some storage item not found" against the now-removed old ciphertext. + // SW reload masks the bug because the Effector store reinitializes from + // storage on boot. Mirrors what Actions.swapHotKey does for the + // intercom-driven path; we don't route through Actions.swapHotKey here + // because importing actions.ts drags webextension-polyfill into the + // transaction-processor's init chain. + return withUnlocked(async ({ vault }) => { + const updated = await vault.swapHotKey(accountPublicKey, newHotPubKey); + accountsUpdated(updated); + }); + }, + setGuardianEndpoint: async (accountPublicKey: string, guardianEndpoint: string) => { + // Mirror swapHotKey: persist then `accountsUpdated` so the Effector store + // (and every popup pulling from it) reflects the new per-account endpoint. + // Otherwise the popup keeps resolving the old guardian for this account. + return withUnlocked(async ({ vault }) => { + const updated = await vault.setGuardianEndpoint(accountPublicKey, guardianEndpoint); + accountsUpdated(updated); + }); } }; diff --git a/src/lib/miden/back/vault.gaps.test.ts b/src/lib/miden/back/vault.gaps.test.ts index 131626d4d..5503781b5 100644 --- a/src/lib/miden/back/vault.gaps.test.ts +++ b/src/lib/miden/back/vault.gaps.test.ts @@ -99,7 +99,7 @@ jest.mock('@miden-sdk/miden-sdk/lazy', () => { ...base, AuthSecretKey: { deserialize: (bytes: Uint8Array) => mockAuthSecretKeyDeserialize(bytes), - rpoFalconWithRNG: jest.fn(() => ({ __marker: 'rpo-falcon-secret' })) + ecdsaWithRNG: jest.fn(() => ({ __marker: 'ecdsa-secret' })) }, SigningInputs: { deserialize: jest.fn(() => ({})) }, Word: { deserialize: jest.fn(() => ({})), fromHex: (h: string) => mockWordFromHex(h) }, @@ -284,29 +284,33 @@ describe('Vault.spawn: preserved guardian URL', () => { }); }); -describe('Vault.spawn: Guardian import helpers', () => { - it('exercises signWordFn / getPublicKeyForCommitment when ownMnemonic + Guardian path runs', async () => { - // Wire the mocked client so its importAccountBySeed actually invokes the - // helpers we need to cover. Helpers read from the (post-callback) keystore, - // so we also have to make insertKeyCallback fire before invocation. +describe('Vault.spawn: Guardian recovery (lookup + adopt)', () => { + it('persists every account returned by recoverGuardianAccountsBySeed with requiresHotKeyRotation=true', async () => { + // recoverGuardianAccountsBySeed adopts each on-chain account locally + // (no rotation — the user activates the hot key explicitly via the + // post-recovery banner). Vault.spawn must round-trip the array, persist + // only the cold mirror per account, and flag each WalletAccount with + // requiresHotKeyRotation so the banner picks it up. const sdk = require('../sdk/miden-client'); const origGetClient = sdk.getMidenClient; - sdk.getMidenClient = jest.fn(async (options: any) => ({ - createMidenWallet: async (_t: any, _s: Uint8Array) => 'guardian-pk', - importAccountBySeed: async ( - _walletType: any, - _seed: Uint8Array, - signWordFn: (pk: string, hex: string) => Promise, - getPubKeyFn: (pkc: string) => Promise - ) => { - // Simulate the WASM client persisting an auth-secret first, then asking - // the wallet to sign + look up its commitment public key — the two - // helper closures defined inside Vault.spawn. - await options.insertKeyCallback(new Uint8Array([0xaa, 0xbb]), new Uint8Array([0x01, 0x02, 0x03, 0x04])); - await signWordFn('aabb', '0xdeadbeef'); - await getPubKeyFn('aabb'); - return 'guardian-pk'; - }, + sdk.getMidenClient = jest.fn(async (_options: any) => ({ + recoverGuardianAccountsBySeed: async (_deriveColdSeed: any, _endpoint: string) => [ + { + accountId: 'guardian-pk', + hdIndex: 0, + coldPublicKey: 'bb'.repeat(33), + coldSecretKeyHex: 'dd'.repeat(32) + } + ], + createGuardianMidenWallet: async (_seed: Uint8Array) => ({ + accountId: 'guardian-pk', + keys: { + hotPublicKey: 'aa'.repeat(33), + hotCiphertext: 'cf'.repeat(64), + coldPublicKey: 'bb'.repeat(33), + coldSecretKeyHex: 'dd'.repeat(32) + } + }), getAccounts: async () => [], getAccount: async () => null, syncState: async () => {}, diff --git a/src/lib/miden/back/vault.test.ts b/src/lib/miden/back/vault.test.ts index b06382f27..fd5cee954 100644 --- a/src/lib/miden/back/vault.test.ts +++ b/src/lib/miden/back/vault.test.ts @@ -3,10 +3,11 @@ // the real `safe-storage` code runs but writes/reads go to `memoryStore`. // --------------------------------------------------------------------------- import * as Passworder from 'lib/miden/passworder'; +import { WalletAccount } from 'lib/shared/types'; import { WalletType } from 'screens/onboarding/types'; import { PublicError } from './defaults'; -import { encryptAndSaveMany, savePlain } from './safe-storage'; +import { encryptAndSaveMany, fetchAndDecryptOneWithLegacyFallBack, savePlain } from './safe-storage'; import { Vault } from './vault'; const memoryStore: Record = {}; @@ -34,6 +35,24 @@ jest.mock('lib/platform/storage-adapter', () => ({ // --------------------------------------------------------------------------- const mockCreateMidenWallet = jest.fn(async (_type: any, _seed: Uint8Array) => 'acc-pub-key-1'); const mockImportPublicMidenWalletFromSeed = jest.fn(async (_seed: Uint8Array) => 'acc-pub-key-imported'); +const GUARDIAN_KEYS_FIXTURE = { + hotPublicKey: 'hot-pub', + coldPublicKey: 'cold-pub', + hotCiphertext: 'hot-ct', + coldSecretKeyHex: 'cold-sk' +}; +const mockCreateGuardianMidenWallet = jest.fn(async (_seed: Uint8Array) => ({ + accountId: 'guardian-acc-1', + keys: GUARDIAN_KEYS_FIXTURE +})); +const mockRecoverGuardianAccountsBySeed = jest.fn(async (_deriveColdSeed: any, _endpoint: string) => [ + { + accountId: 'guardian-acc-imported', + hdIndex: 0, + coldPublicKey: GUARDIAN_KEYS_FIXTURE.coldPublicKey, + coldSecretKeyHex: GUARDIAN_KEYS_FIXTURE.coldSecretKeyHex + } +]); const mockGetAccounts = jest.fn(async () => [] as any[]); const mockGetAccount = jest.fn(async (_id: string) => null as any); const mockSyncState = jest.fn(async () => {}); @@ -46,9 +65,11 @@ const mockGetMidenClient = jest.fn(async (_options?: any) => ({ createMidenWallet: (...args: unknown[]) => mockCreateMidenWallet(...(args as [any, Uint8Array])), importPublicMidenWalletFromSeed: (...args: unknown[]) => mockImportPublicMidenWalletFromSeed(...(args as [Uint8Array])), - // Mirror the production dispatch: for non-Guardian types, delegate to - // importPublicMidenWalletFromSeed so existing tests keep asserting on it. + // Non-Guardian: delegate to importPublicMidenWalletFromSeed so existing + // tests keep asserting on it. importAccountBySeed: async (_walletType: any, seed: Uint8Array) => mockImportPublicMidenWalletFromSeed(seed), + createGuardianMidenWallet: (...args: unknown[]) => mockCreateGuardianMidenWallet(...(args as [Uint8Array])), + recoverGuardianAccountsBySeed: (...args: unknown[]) => mockRecoverGuardianAccountsBySeed(...(args as [any, string])), getAccounts: () => mockGetAccounts(), getAccount: (id: string) => mockGetAccount(id), syncState: () => mockSyncState(), @@ -64,10 +85,32 @@ jest.mock('../sdk/miden-client', () => ({ runWhenClientIdle: () => {} })); +// Mock the secure-hot-key facade so reveal/swap paths don't try to deserialize +// real AuthSecretKey blobs out of fake ciphertexts. Tests set the resolved +// value per case via the captured mock fns. +const mockRevealHotKey = jest.fn(async (_ciphertext: string) => 'reveal-stub'); +const mockDeleteHotKey = jest.fn(async (_ciphertext: string) => {}); +jest.mock('lib/secure-hot-key', () => ({ + revealHotKey: (...a: unknown[]) => mockRevealHotKey(...(a as [string])), + deleteHotKey: (...a: unknown[]) => mockDeleteHotKey(...(a as [string])), + generateHotKey: jest.fn(), + signHotDigest: jest.fn() +})); + +// migrateLegacyGuardianAccounts verifies the derived cold key against the +// on-chain index-0 signer via getSignerDetailsFromAccount. Mock it so tests can +// drive the match / mismatch branches. +const mockGetSignerDetailsFromAccount = jest.fn(); +jest.mock('../guardian/account', () => ({ + getSignerDetailsFromAccount: (...a: unknown[]) => mockGetSignerDetailsFromAccount(...a) +})); + // Unified handle used by tests — matches the old mockMidenClient API. const mockMidenClient = { createMidenWallet: mockCreateMidenWallet, importPublicMidenWalletFromSeed: mockImportPublicMidenWalletFromSeed, + createGuardianMidenWallet: mockCreateGuardianMidenWallet, + recoverGuardianAccountsBySeed: mockRecoverGuardianAccountsBySeed, getAccounts: mockGetAccounts, getAccount: mockGetAccount, syncState: mockSyncState, @@ -184,6 +227,9 @@ const keys = { accPubKey: (pk: string) => `${ck('accpubkey')}_${pk}`, accAuthSecretKey: (pk: string) => `${ck('accauthsecretkey')}_${pk}`, accAuthPubKey: (pk: string) => `${ck('accauthpubkey')}_${pk}`, + // NOTE: the vault's StorageEntity.AccColdSecretKey value is 'accouldsecretkey' + // (typo preserved for storage compatibility with existing wallets). + accColdSecretKey: (pk: string) => `${ck('accouldsecretkey')}_${pk}`, currentAccPubKey: ck('curraccpubkey'), accounts: ck('accounts'), ownMnemonic: ck('ownmnemonic'), @@ -242,6 +288,18 @@ beforeEach(() => { (isDesktop as jest.Mock).mockReturnValue(false); (isMobile as jest.Mock).mockReturnValue(false); mockMidenClient.createMidenWallet.mockResolvedValue('acc-pub-key-1'); + mockMidenClient.createGuardianMidenWallet.mockResolvedValue({ + accountId: 'guardian-acc-1', + keys: GUARDIAN_KEYS_FIXTURE + }); + mockMidenClient.recoverGuardianAccountsBySeed.mockResolvedValue([ + { + accountId: 'guardian-acc-imported', + hdIndex: 0, + coldPublicKey: GUARDIAN_KEYS_FIXTURE.coldPublicKey, + coldSecretKeyHex: GUARDIAN_KEYS_FIXTURE.coldSecretKeyHex + } + ]); mockMidenClient.getAccounts.mockResolvedValue([]); mockMidenClient.getAccount.mockResolvedValue(null); mockMidenClient.syncState.mockResolvedValue(undefined); @@ -561,6 +619,142 @@ describe('Vault.revealPrivateKey', () => { }); }); +describe('Vault.revealHotKey', () => { + it('unwraps the hot ciphertext via the secure-hot-key facade and returns plaintext hex', async () => { + const vault = await seedVault('pw'); + const vaultKey = (vault as any).vaultKey as CryptoKey; + // Persist a Guardian WalletAccount with hotPublicKey + the wrapped ciphertext + // exactly the way Vault.spawn's createGuardianMidenWallet path would. + const account: WalletAccount = { + publicKey: 'guardian-acc-1', + name: 'Guardian 1', + isPublic: false, + type: WalletType.Guardian, + hdIndex: 0, + hotPublicKey: 'hot-pub-hex', + coldPublicKey: 'cold-pub-hex' + }; + await encryptAndSaveMany( + [ + [keys.accounts, [account]], + [keys.accAuthSecretKey('hot-pub-hex'), 'OPAQUE_CIPHERTEXT'] + ], + vaultKey + ); + mockRevealHotKey.mockResolvedValueOnce('deadbeef'); + + const secret = await Vault.revealHotKey('guardian-acc-1', 'pw'); + + expect(mockRevealHotKey).toHaveBeenCalledWith('OPAQUE_CIPHERTEXT'); + expect(secret).toBe('deadbeef'); + }); + + it('rejects when the account is not a Guardian account', async () => { + const vault = await seedVault('pw'); + const vaultKey = (vault as any).vaultKey as CryptoKey; + const account: WalletAccount = { + publicKey: 'acc-1', + name: 'OnChain 1', + isPublic: true, + type: WalletType.OnChain, + hdIndex: 0 + }; + await encryptAndSaveMany([[keys.accounts, [account]]], vaultKey); + + await expect(Vault.revealHotKey('acc-1', 'pw')).rejects.toThrow(PublicError); + }); + + it('rejects when the Guardian account has no activated hot key (post-recovery, pre-banner)', async () => { + const vault = await seedVault('pw'); + const vaultKey = (vault as any).vaultKey as CryptoKey; + const account: WalletAccount = { + publicKey: 'guardian-recovered', + name: 'Guardian Recovered', + isPublic: false, + type: WalletType.Guardian, + hdIndex: 0, + coldPublicKey: 'cold-pub-hex', + requiresHotKeyRotation: true + }; + await encryptAndSaveMany([[keys.accounts, [account]]], vaultKey); + + await expect(Vault.revealHotKey('guardian-recovered', 'pw')).rejects.toThrow(PublicError); + }); +}); + +describe('Vault.revealGuardianKeys', () => { + it('returns coldPrivateKey + coldPublicKey + hotPublicKey for an activated Guardian account', async () => { + const vault = await seedVault('pw'); + const vaultKey = (vault as any).vaultKey as CryptoKey; + const account: WalletAccount = { + publicKey: 'guardian-acc-1', + name: 'Guardian 1', + isPublic: false, + type: WalletType.Guardian, + hdIndex: 0, + hotPublicKey: 'hot-pub-hex', + coldPublicKey: 'cold-pub-hex' + }; + await encryptAndSaveMany( + [ + [keys.accounts, [account]], + [keys.accColdSecretKey('cold-pub-hex'), 'COLD_SECRET_HEX'] + ], + vaultKey + ); + + const result = await Vault.revealGuardianKeys('guardian-acc-1', 'pw'); + + expect(result).toEqual({ + coldPrivateKey: 'COLD_SECRET_HEX', + coldPublicKey: 'cold-pub-hex', + hotPublicKey: 'hot-pub-hex' + }); + }); + + it('returns hotPublicKey undefined for a recovered Guardian account whose hot key is not yet activated', async () => { + const vault = await seedVault('pw'); + const vaultKey = (vault as any).vaultKey as CryptoKey; + const account: WalletAccount = { + publicKey: 'guardian-recovered', + name: 'Guardian Recovered', + isPublic: false, + type: WalletType.Guardian, + hdIndex: 0, + coldPublicKey: 'cold-pub-hex', + requiresHotKeyRotation: true + }; + await encryptAndSaveMany( + [ + [keys.accounts, [account]], + [keys.accColdSecretKey('cold-pub-hex'), 'COLD_SECRET_HEX'] + ], + vaultKey + ); + + const result = await Vault.revealGuardianKeys('guardian-recovered', 'pw'); + + expect(result.coldPrivateKey).toBe('COLD_SECRET_HEX'); + expect(result.coldPublicKey).toBe('cold-pub-hex'); + expect(result.hotPublicKey).toBeUndefined(); + }); + + it('rejects when called on a non-Guardian account', async () => { + const vault = await seedVault('pw'); + const vaultKey = (vault as any).vaultKey as CryptoKey; + const account: WalletAccount = { + publicKey: 'acc-1', + name: 'OnChain 1', + isPublic: true, + type: WalletType.OnChain, + hdIndex: 0 + }; + await encryptAndSaveMany([[keys.accounts, [account]]], vaultKey); + + await expect(Vault.revealGuardianKeys('acc-1', 'pw')).rejects.toThrow(PublicError); + }); +}); + describe('Vault.createHDAccount', () => { it('appends a new on-chain account with a derived default name', async () => { const vault = await seedVault('pw'); @@ -1146,20 +1340,21 @@ describe('Vault hardware branches', () => { await expect(vlt.createHDAccount('invalid' as any)).rejects.toThrow(); }); - it('Vault.spawn propagates importAccountBySeed failures for Guardian imports (no silent fallback)', async () => { - // The Guardian import path must NOT fall back to createMidenWallet — otherwise - // a silently-recreated account would leave the user with an empty balance - // under their seed. The branch rethrows, wrapped as a PublicError by withError. + it('Vault.spawn propagates recoverGuardianAccountsBySeed failures (no silent fallback)', async () => { + // The Guardian recovery path must NOT fall back to createGuardianMidenWallet — + // otherwise a silently-recreated account would leave the user with an empty + // balance under their seed. The branch rethrows, wrapped as a PublicError by + // withError. (isDesktop as jest.Mock).mockReturnValue(false); (isMobile as jest.Mock).mockReturnValue(false); - mockMidenClient.importPublicMidenWalletFromSeed.mockRejectedValueOnce(new Error('guardian lookup failed')); + mockMidenClient.recoverGuardianAccountsBySeed.mockRejectedValueOnce(new Error('guardian lookup failed')); await expect(Vault.spawn(WalletType.Guardian, 'pw-guardian-fail', VALID_MNEMONIC, true)).rejects.toThrow( PublicError ); - // createMidenWallet must NOT be called as a fallback — Guardian rethrows. - expect(mockMidenClient.createMidenWallet).not.toHaveBeenCalledWith(WalletType.Guardian, expect.anything()); + // createGuardianMidenWallet must NOT be called as a fallback — Guardian rethrows. + expect(mockMidenClient.createGuardianMidenWallet).not.toHaveBeenCalled(); }); it('createHDAccount supports WalletType.Guardian (derivation index 2)', async () => { @@ -1167,8 +1362,11 @@ describe('Vault hardware branches', () => { (isMobile as jest.Mock).mockReturnValue(false); const vlt = await Vault.spawn(WalletType.OnChain, 'pw-guardian-test'); // Guardian resolves to the third branch inside getMainDerivationPath (walletTypeIndex=2); - // createMidenWallet is stubbed to return a predictable account id. - mockMidenClient.createMidenWallet.mockResolvedValueOnce('guardian-acc-1'); + // createGuardianMidenWallet is stubbed to return a predictable account id + keys. + mockMidenClient.createGuardianMidenWallet.mockResolvedValueOnce({ + accountId: 'guardian-acc-1', + keys: GUARDIAN_KEYS_FIXTURE + }); await expect(vlt.createHDAccount(WalletType.Guardian, 'Guardian 1')).resolves.toBeTruthy(); }); @@ -1215,3 +1413,133 @@ describe('Vault hardware branches', () => { expect(true).toBe(true); // assert no-throw }); }); + +describe('Vault.migrateLegacyGuardianAccounts', () => { + const sdk = jest.requireMock('@miden-sdk/miden-sdk/lazy'); + + beforeEach(() => { + // Cold-key derivation is mocked to a fixed key; `deriveClientSeed` still runs + // real BIP-39 over VALID_MNEMONIC but the seed it produces is ignored here. + // The derived key's commitment is `0x020304` (the verification compares this + // against the on-chain index-0 signer below). + sdk.AuthSecretKey.ecdsaWithRNG.mockImplementation(() => ({ + publicKey: () => ({ + serialize: () => new Uint8Array([0x00, 0x02, 0x03, 0x04]), + toCommitment: () => ({ toHex: () => '0x020304' }) + }), + serialize: () => new Uint8Array([0xab, 0xcd]) + })); + // By default the on-chain account is present and its index-0 signer matches + // the derived cold commitment, so the legacy account migrates (verified). + mockGetAccount.mockResolvedValue({ id: () => ({ toString: () => 'guardian-legacy' }) }); + mockGetSignerDetailsFromAccount.mockReset(); + mockGetSignerDetailsFromAccount.mockResolvedValue({ commitment: '020304' }); + }); + + const legacyGuardian = { + publicKey: 'guardian-legacy', + name: 'Guardian 1', + isPublic: true, + type: WalletType.Guardian, + hdIndex: 0 + }; + const normalAcc = { publicKey: 'normal-1', name: 'Acc', isPublic: true, type: WalletType.OnChain, hdIndex: 0 }; + const already3Key = { + publicKey: 'guardian-3key', + name: 'Guardian 2', + isPublic: true, + type: WalletType.Guardian, + hdIndex: 1, + coldPublicKey: 'existing-cold', + hotPublicKey: 'existing-hot' + }; + + it('migrates a legacy single-key Guardian account to the 3-key model in place', async () => { + const vault = await seedVault('pw', { accounts: [legacyGuardian, normalAcc, already3Key] as any }); + await vault.migrateLegacyGuardianAccounts(); + + const accounts = await vault.fetchAccounts(); + const migrated = accounts.find(a => a.publicKey === 'guardian-legacy')!; + expect(migrated.coldPublicKey).toBe('020304'); // serialize().slice(1) of [00,02,03,04] + expect(migrated.requiresHotKeyRotation).toBe(true); + // The derived cold key is persisted into the cold slot. + const coldHex = await fetchAndDecryptOneWithLegacyFallBack( + keys.accColdSecretKey('020304'), + (vault as any).vaultKey + ); + expect(coldHex).toBe('abcd'); + }); + + it('leaves non-Guardian and already-3-key accounts untouched', async () => { + const vault = await seedVault('pw', { accounts: [legacyGuardian, normalAcc, already3Key] as any }); + await vault.migrateLegacyGuardianAccounts(); + + const accounts = await vault.fetchAccounts(); + const normal = accounts.find(a => a.publicKey === 'normal-1')!; + const threeKey = accounts.find(a => a.publicKey === 'guardian-3key')!; + expect(normal.coldPublicKey).toBeUndefined(); + expect(normal.requiresHotKeyRotation).toBeUndefined(); + expect(threeKey.coldPublicKey).toBe('existing-cold'); + expect(threeKey.requiresHotKeyRotation).toBeUndefined(); + }); + + it('skips imported Guardian accounts (hdIndex < 0) — they cannot be re-derived', async () => { + // Imported Guardian accounts are tagged hdIndex = -1; deriving a cold key + // from the mnemonic at a negative index would be wrong, so they're excluded. + const importedGuardian = { + publicKey: 'guardian-imported', + name: 'Guardian Imported', + isPublic: true, + type: WalletType.Guardian, + hdIndex: -1 + }; + const vault = await seedVault('pw', { accounts: [importedGuardian] as any }); + sdk.AuthSecretKey.ecdsaWithRNG.mockClear(); + await vault.migrateLegacyGuardianAccounts(); + + expect(sdk.AuthSecretKey.ecdsaWithRNG).not.toHaveBeenCalled(); + const imported = (await vault.fetchAccounts()).find(a => a.publicKey === 'guardian-imported')!; + expect(imported.coldPublicKey).toBeUndefined(); + expect(imported.requiresHotKeyRotation).toBeUndefined(); + }); + + it('is idempotent — a second run derives nothing', async () => { + const vault = await seedVault('pw', { accounts: [legacyGuardian] as any }); + await vault.migrateLegacyGuardianAccounts(); + sdk.AuthSecretKey.ecdsaWithRNG.mockClear(); + await vault.migrateLegacyGuardianAccounts(); + expect(sdk.AuthSecretKey.ecdsaWithRNG).not.toHaveBeenCalled(); + }); + + it('skips a legacy account whose derived cold key does NOT match the on-chain signer', async () => { + // The on-chain index-0 signer is some other commitment — installing the + // re-derived key + flagging rotation would arm an activation that can never + // authorize on-chain, so the account is left untouched. + mockGetSignerDetailsFromAccount.mockResolvedValue({ commitment: 'deadbeef' }); + const vault = await seedVault('pw', { accounts: [legacyGuardian] as any }); + await vault.migrateLegacyGuardianAccounts(); + + const acc = (await vault.fetchAccounts()).find(a => a.publicKey === 'guardian-legacy')!; + expect(acc.coldPublicKey).toBeUndefined(); + expect(acc.requiresHotKeyRotation).toBeUndefined(); + }); + + it('migrates unverified when the on-chain account is unavailable to verify against', async () => { + // Can't load the account (e.g. not synced yet) → can't confirm a mismatch → + // fall back to migrating so the account isn't permanently stuck. No regression. + mockGetAccount.mockResolvedValue(null); + const vault = await seedVault('pw', { accounts: [legacyGuardian] as any }); + await vault.migrateLegacyGuardianAccounts(); + + const acc = (await vault.fetchAccounts()).find(a => a.publicKey === 'guardian-legacy')!; + expect(acc.coldPublicKey).toBe('020304'); + expect(acc.requiresHotKeyRotation).toBe(true); + expect(mockGetSignerDetailsFromAccount).not.toHaveBeenCalled(); + }); + + it('never throws (best-effort) — a failure cannot block unlock', async () => { + const vault = await seedVault('pw', { accounts: [legacyGuardian] as any }); + jest.spyOn(vault as any, 'fetchAccounts').mockRejectedValueOnce(new Error('boom')); + await expect(vault.migrateLegacyGuardianAccounts()).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/miden/back/vault.ts b/src/lib/miden/back/vault.ts index 13d173460..a495f2dce 100644 --- a/src/lib/miden/back/vault.ts +++ b/src/lib/miden/back/vault.ts @@ -17,11 +17,14 @@ import { fetchAndDecryptOneWithLegacyFallBack, getPlain, isStored, + removeMany, savePlain } from 'lib/miden/back/safe-storage'; import * as Passworder from 'lib/miden/passworder'; import { clearStorage } from 'lib/miden/reset'; +import { DEFAULT_GUARDIAN_ENDPOINT } from 'lib/miden-chain/constants'; import { isDesktop, isMobile } from 'lib/platform'; +import * as secureHotKey from 'lib/secure-hot-key'; import { GUARDIAN_URL_STORAGE_KEY } from 'lib/settings/constants'; import { b64ToU8, u8ToB64 } from 'lib/shared/helpers'; import { AuthScheme, WalletAccount, WalletSettings } from 'lib/shared/types'; @@ -29,6 +32,8 @@ import { WalletType } from 'screens/onboarding/types'; import { compareAccountIds } from '../activity/utils'; import { fetchFromStorage, putToStorage } from '../front/storage'; +import type { CreatedGuardianKeys } from '../guardian/account'; +import { getSignerDetailsFromAccount } from '../guardian/account'; import { getBech32AddressFromAccountId } from '../sdk/helpers'; import { getMidenClient, withWasmClientLock } from '../sdk/miden-client'; import { MidenClientCreateOptions } from '../sdk/miden-client-interface'; @@ -101,6 +106,7 @@ enum StorageEntity { MigrationLevel = 'migration', Mnemonic = 'mnemonic', AccAuthSecretKey = 'accauthsecretkey', + AccColdSecretKey = 'accouldsecretkey', AccAuthPubKey = 'accauthpubkey', AccPubKey = 'accpubkey', AccViewKey = 'accviewkey', @@ -115,6 +121,9 @@ const checkStrgKey = createStorageKey(StorageEntity.Check); const mnemonicStrgKey = createStorageKey(StorageEntity.Mnemonic); const accPubKeyStrgKey = createDynamicStorageKey(StorageEntity.AccPubKey); const accAuthSecretKeyStrgKey = createDynamicStorageKey(StorageEntity.AccAuthSecretKey); +// Mirror of cold-key blobs keyed by cold pubkey, separate from accAuthSecretKey +// so role-aware signWord (Phase 3) can route hot vs cold by storage entity. +const accColdSecretKeyStrgKey = createDynamicStorageKey(StorageEntity.AccColdSecretKey); const accAuthPubKeyStrgKey = createDynamicStorageKey(StorageEntity.AccAuthPubKey); const currentAccPubKeyStrgKey = createStorageKey(StorageEntity.CurrentAccPubKey); const accountsStrgKey = createStorageKey(StorageEntity.Accounts); @@ -134,6 +143,36 @@ const insertKeyCallbackWrapper = (passKey: CryptoKey) => { ); }; }; + +/** + * Persist the hot ciphertext + cold mirror produced by createGuardianAccount, + * wrapping each blob under the vault key. Cold is also already in the SDK + * keystore via the standard insertKeyCallback path; the mirror under + * accColdSecretKeyStrgKey lets role-aware signWord (Phase 3) route hot vs + * cold by storage entity without touching the WASM keystore. + * + * Storage keys use the unprefixed signer-commitment hex (matching the + * WalletSigner convention everywhere else in the codebase). + */ +async function persistGuardianKeys(vaultKey: CryptoKey, keys: CreatedGuardianKeys) { + await encryptAndSaveMany( + [ + [accAuthPubKeyStrgKey(keys.hotPublicKey), keys.hotPublicKey], + [accAuthSecretKeyStrgKey(keys.hotPublicKey), keys.hotCiphertext], + [accColdSecretKeyStrgKey(keys.coldPublicKey), keys.coldSecretKeyHex] + ], + vaultKey + ); +} + +/** + * Persist only the cold mirror for a recovered Guardian account. No hot key + * is generated at recovery time — the user activates one explicitly via the + * post-recovery banner, which fires `initiateReplaceHotKeyTransaction`. + */ +async function persistRecoveredGuardianColdKey(vaultKey: CryptoKey, coldPublicKey: string, coldSecretKeyHex: string) { + await encryptAndSaveMany([[accColdSecretKeyStrgKey(coldPublicKey), coldSecretKeyHex]], vaultKey); +} export class Vault { constructor(private vaultKey: CryptoKey) {} @@ -286,7 +325,7 @@ export class Vault { // clearStorage wipes the entire platform key-value store, which would also // erase the guardian URL that the onboarding flow just wrote for a Guardian import. // Snapshot it and restore after the wipe so downstream reads - // (createGuardianAccount / importAccountFromGuardian) see the caller's choice. + // (createGuardianAccount / recoverGuardianAccountsBySeed) see the caller's choice. // TODO: thread guardianEndpoint as an explicit arg through registerWallet → spawn // instead of round-tripping through storage. console.log('[Vault.spawn] Step 3: clearing storage...'); @@ -323,102 +362,144 @@ export class Vault { const hdAccIndex = 0; const walletSeed = deriveClientSeed(walletType, mnemonic, 0); - // Helper to sign words using the vault key (needed for Guardian import) - const signWordFn = async (pk: string, wordHex: string) => { - const word = Word.fromHex(wordHex); - const secretKey = await fetchAndDecryptOneWithLegacyFallBack(accAuthSecretKeyStrgKey(pk), vaultKey); - const wasmSecretKey = AuthSecretKey.deserialize(new Uint8Array(Buffer.from(secretKey, 'hex'))); - const signature = wasmSecretKey.sign(word); - return `0x${Buffer.from(signature.serialize().slice(1)).toString('hex')}`; + console.log('[Vault.spawn] Step 5: getting miden client...'); + const midenClient = await getMidenClient(options); + console.log('[Vault.spawn] Step 6: client ready, network:', midenClient.network, 'ownMnemonic:', ownMnemonic); + + // Guardian recovery (lookup + adopt per HD index) runs OUTSIDE the WASM + // client mutex because the orchestrator acquires the lock granularly per + // WASM op. Wrapping it under the outer lock would deadlock (the inner + // withWasmClientLock calls hit the non-reentrant mutex). + const isGuardianRecovery = walletType === WalletType.Guardian && ownMnemonic && midenClient.network !== 'mock'; + + type SpawnedAccount = { + accountId: string; + hdIndex: number; + authScheme: AuthScheme; + guardianKeys?: CreatedGuardianKeys; + guardianEndpoint?: string; + recoveredCold?: { coldPublicKey: string; coldSecretKeyHex: string }; }; - // Helper to get public key from commitment (needed for Guardian import) - const getPublicKeyForCommitment = async (pkc: string) => { - const sk = await fetchAndDecryptOneWithLegacyFallBack(accAuthSecretKeyStrgKey(pkc), vaultKey); - const wasmSecretKey = AuthSecretKey.deserialize(new Uint8Array(Buffer.from(sk, 'hex'))); - return Buffer.from(wasmSecretKey.publicKey().serialize().slice(1)).toString('hex'); - }; + let createdAccounts: SpawnedAccount[]; - // Wrap WASM client operations in a lock to prevent concurrent access - console.log('[Vault.spawn] Step 5: acquiring WASM client lock...'); - const { accPublicKey, accAuthScheme } = await withWasmClientLock(async () => { - console.log('[Vault.spawn] Step 6: getting miden client...'); - const midenClient = await getMidenClient(options); - console.log('[Vault.spawn] Step 7: client ready, network:', midenClient.network, 'ownMnemonic:', ownMnemonic); - if (ownMnemonic && midenClient.network !== 'mock') { - if (walletType === WalletType.Guardian) { - // Guardian restore goes through the multisig import path and must - // NOT silently fall back: a failure means the guardian lookup - // failed, and creating a fresh local account would leave the user - // with the wrong balance under their seed. Propagate (importAccountBySeed - // rethrows) so the UI can let them fix the URL or switch to - // public-account import. - console.log('[Vault.spawn] Step 8a: importing Guardian wallet from seed...'); - const id = await midenClient.importAccountBySeed( - walletType, - walletSeed, - signWordFn, - getPublicKeyForCommitment - ); - return { accPublicKey: id, accAuthScheme: NEW_ACCOUNT_AUTH_SCHEME }; - } - // Non-guardian mnemonic restore. Probe each known auth scheme — the - // user's real on-chain account at hdIndex=0 was created under exactly - // one of them, but we have no metadata to tell us which. Falcon first - // because every wallet shipped before this migration used Falcon as - // the default; ECDSA second so post-migration restorers work too. If - // neither probe finds an on-chain match the user's mnemonic is - // "fresh" — fall through to a brand-new create with the new ECDSA - // default. - for (const scheme of RESTORE_PROBE_SCHEMES) { - try { - console.log(`[Vault.spawn] Step 8a: probing ${scheme} import...`); - const id = await midenClient.importPublicMidenWalletFromSeed(walletSeed, scheme); - return { accPublicKey: id, accAuthScheme: scheme }; - } catch { - // probe miss; try next scheme + if (isGuardianRecovery) { + console.log('[Vault.spawn] Step 7a: recovering Guardian accounts (adopt only — rotation deferred)...'); + const guardianEndpoint = + (await fetchFromStorage(GUARDIAN_URL_STORAGE_KEY)) || DEFAULT_GUARDIAN_ENDPOINT; + const recovered = await midenClient.recoverGuardianAccountsBySeed( + (idx: number) => deriveClientSeed(WalletType.Guardian, mnemonic!, idx), + guardianEndpoint + ); + createdAccounts = recovered.map(r => ({ + accountId: r.accountId, + hdIndex: r.hdIndex, + // Guardian accounts are always ECDSA under the 3-key model. + authScheme: NEW_ACCOUNT_AUTH_SCHEME, + // Recovery is scoped to a single operator endpoint, so every adopted + // account is registered with the same `guardianEndpoint` we looked up against. + guardianEndpoint, + recoveredCold: { coldPublicKey: r.coldPublicKey, coldSecretKeyHex: r.coldSecretKeyHex } + })); + } else { + console.log('[Vault.spawn] Step 7b: acquiring WASM client lock for create/import path...'); + const created = await withWasmClientLock( + async (): Promise<{ + accountId: string; + accAuthScheme: AuthScheme; + guardianKeys?: CreatedGuardianKeys; + guardianEndpoint?: string; + }> => { + if (walletType === WalletType.Guardian) { + console.log('[Vault.spawn] Step 8: syncing state then creating Guardian account...'); + await midenClient.syncState(); + const result = await midenClient.createGuardianMidenWallet(walletSeed); + // Guardian accounts are always ECDSA under the 3-key model. + return { + accountId: result.accountId, + accAuthScheme: NEW_ACCOUNT_AUTH_SCHEME, + guardianKeys: result.keys, + guardianEndpoint: result.guardianEndpoint + }; } + + if (ownMnemonic && midenClient.network !== 'mock') { + // Non-guardian mnemonic restore. Probe each known auth scheme — the + // user's real on-chain account at hdIndex=0 was created under exactly + // one of them, but we have no metadata to tell us which. Falcon first + // (the pre-migration default); ECDSA second so post-migration + // restorers work too. If neither probe finds an on-chain match the + // mnemonic is "fresh" — fall through to a brand-new ECDSA create. + for (const scheme of RESTORE_PROBE_SCHEMES) { + try { + console.log(`[Vault.spawn] Step 8a: probing ${scheme} import...`); + const id = await midenClient.importPublicMidenWalletFromSeed(walletSeed, scheme); + return { accountId: id, accAuthScheme: scheme }; + } catch { + // probe miss; try next scheme + } + } + console.warn('[Vault.spawn] no on-chain account at hdIndex=0 under any scheme; creating fresh'); + } + // Sync to chain tip BEFORE creating first account (no accounts = no tags = fast sync) + console.log('[Vault.spawn] Step 8b: syncing state...'); + await midenClient.syncState(); + console.log('[Vault.spawn] Step 9: creating miden wallet...'); + const id = await midenClient.createMidenWallet(walletType, walletSeed, NEW_ACCOUNT_AUTH_SCHEME); + return { accountId: id, accAuthScheme: NEW_ACCOUNT_AUTH_SCHEME }; } - console.warn('[Vault.spawn] no on-chain account at hdIndex=0 under any scheme; creating fresh'); - const id = await midenClient.createMidenWallet(walletType, walletSeed, NEW_ACCOUNT_AUTH_SCHEME); - return { accPublicKey: id, accAuthScheme: NEW_ACCOUNT_AUTH_SCHEME }; - } else { - // Sync to chain tip BEFORE creating first account (no accounts = no tags = fast sync) - console.log('[Vault.spawn] Step 8b: syncing state...'); - await midenClient.syncState(); - console.log('[Vault.spawn] Step 9: creating miden wallet...'); - if (walletType === WalletType.Guardian) { - // Fresh Guardian create — createMidenWallet routes to the guardian - // path internally. Stamp the new default scheme like every other - // new account. - const id = await midenClient.createMidenWallet(walletType, walletSeed); - return { accPublicKey: id, accAuthScheme: NEW_ACCOUNT_AUTH_SCHEME }; + ); + createdAccounts = [ + { + accountId: created.accountId, + hdIndex: hdAccIndex, + authScheme: created.accAuthScheme, + guardianKeys: created.guardianKeys, + guardianEndpoint: created.guardianEndpoint } - const id = await midenClient.createMidenWallet(walletType, walletSeed, NEW_ACCOUNT_AUTH_SCHEME); - return { accPublicKey: id, accAuthScheme: NEW_ACCOUNT_AUTH_SCHEME }; - } - }); + ]; + } - const initialAccount: WalletAccount = { - publicKey: accPublicKey, - name: 'Miden Account 1', + const initialAccounts: WalletAccount[] = createdAccounts.map((c, idx) => ({ + publicKey: c.accountId, + name: getMessage('defaultAccountName', { accountNumber: String(idx + 1) }), isPublic: walletType === WalletType.OnChain, type: walletType, - hdIndex: hdAccIndex, - authScheme: accAuthScheme - }; - const newAccounts = [initialAccount]; + hdIndex: c.hdIndex, + authScheme: c.authScheme, + ...(c.guardianEndpoint && { guardianEndpoint: c.guardianEndpoint }), + ...(c.guardianKeys && { + hotPublicKey: c.guardianKeys.hotPublicKey, + coldPublicKey: c.guardianKeys.coldPublicKey + }), + ...(c.recoveredCold && { + coldPublicKey: c.recoveredCold.coldPublicKey, + requiresHotKeyRotation: true + }) + })); await encryptAndSaveMany( [ [checkStrgKey, generateCheck()], [mnemonicStrgKey, mnemonic], - [accPubKeyStrgKey(accPublicKey), accPublicKey], - [accountsStrgKey, newAccounts] + ...createdAccounts.map(c => [accPubKeyStrgKey(c.accountId), c.accountId] as [string, string]), + [accountsStrgKey, initialAccounts] ], vaultKey ); - await savePlain(currentAccPubKeyStrgKey, accPublicKey); + for (const c of createdAccounts) { + if (c.guardianKeys) { + await persistGuardianKeys(vaultKey, c.guardianKeys); + } + if (c.recoveredCold) { + await persistRecoveredGuardianColdKey( + vaultKey, + c.recoveredCold.coldPublicKey, + c.recoveredCold.coldSecretKeyHex + ); + } + } + await savePlain(currentAccPubKeyStrgKey, initialAccounts[0]!.publicKey); await savePlain(ownMnemonicStrgKey, ownMnemonic ?? false); // Return the vault instance so caller doesn't need to call unlock() separately @@ -568,35 +649,51 @@ export class Vault { console.log('[Vault.createHDAccount] Step 5: seed derived, acquiring WASM lock'); // Wrap WASM client operations in a lock to prevent concurrent access. - // We create the new account under NEW_ACCOUNT_AUTH_SCHEME (ECDSA - // post-migration). On the import-from-seed retry path we also pass - // the new scheme — that path only fires for own-mnemonic wallets - // re-deriving an account that was ALREADY created elsewhere by this - // wallet, so the scheme has to match what we'd produce on a fresh - // create. Wallets that pre-date this migration won't hit the import - // path here because their mnemonic restore goes through `Vault.spawn` - // (which probes both schemes); this `createHDAccount` path is for - // creating NEW accounts in an already-restored wallet. + // New accounts are created under NEW_ACCOUNT_AUTH_SCHEME (ECDSA + // post-migration). The import-from-seed retry path passes the same + // scheme — it only fires for own-mnemonic wallets re-deriving an + // account this wallet already created, so the scheme has to match what + // a fresh create would produce. Pre-migration mnemonic restores go + // through `Vault.spawn` (which probes both schemes); this + // `createHDAccount` path only creates NEW accounts in an already-restored + // wallet. const newScheme: AuthScheme = NEW_ACCOUNT_AUTH_SCHEME; - const walletId = await withWasmClientLock(async () => { - console.log('[Vault.createHDAccount] Step 6: WASM lock acquired, getting client'); - const midenClient = await getMidenClient(options); - console.log('[Vault.createHDAccount] Step 7: client ready, network =', midenClient.network); - if (isOwnMnemonic && walletType === WalletType.OnChain) { - try { - console.log('[Vault.createHDAccount] Step 8a: importPublicMidenWalletFromSeed'); - return await midenClient.importPublicMidenWalletFromSeed(walletSeed, newScheme); - } catch (e) { - console.warn('Failed to import wallet from seed, creating new wallet instead', e); - return await midenClient.createMidenWallet(walletType, walletSeed, newScheme); + const created = await withWasmClientLock( + async (): Promise<{ + accountId: string; + guardianKeys?: CreatedGuardianKeys; + guardianEndpoint?: string; + }> => { + console.log('[Vault.createHDAccount] Step 6: WASM lock acquired, getting client'); + const midenClient = await getMidenClient(options); + console.log('[Vault.createHDAccount] Step 7: client ready, network =', midenClient.network); + + if (walletType === WalletType.Guardian) { + console.log('[Vault.createHDAccount] Step 8: createGuardianMidenWallet'); + const result = await midenClient.createGuardianMidenWallet(walletSeed); + return { + accountId: result.accountId, + guardianKeys: result.keys, + guardianEndpoint: result.guardianEndpoint + }; + } + + if (isOwnMnemonic && walletType === WalletType.OnChain) { + try { + console.log('[Vault.createHDAccount] Step 8a: importPublicMidenWalletFromSeed'); + return { accountId: await midenClient.importPublicMidenWalletFromSeed(walletSeed, newScheme) }; + } catch (e) { + console.warn('Failed to import wallet from seed, creating new wallet instead', e); + return { accountId: await midenClient.createMidenWallet(walletType, walletSeed, newScheme) }; + } } - } else { console.log('[Vault.createHDAccount] Step 8b: createMidenWallet'); const id = await midenClient.createMidenWallet(walletType, walletSeed, newScheme); console.log('[Vault.createHDAccount] Step 9: createMidenWallet returned', id); - return id; + return { accountId: id }; } - }); + ); + const walletId = created.accountId; console.log('[Vault.createHDAccount] Step 10: walletId =', walletId); const accName = name || getNewAccountName(allAccounts); @@ -607,7 +704,12 @@ export class Vault { publicKey: walletId, isPublic: walletType === WalletType.OnChain, hdIndex: hdAccIndex, - authScheme: newScheme + authScheme: newScheme, + ...(created.guardianEndpoint && { guardianEndpoint: created.guardianEndpoint }), + ...(created.guardianKeys && { + hotPublicKey: created.guardianKeys.hotPublicKey, + coldPublicKey: created.guardianKeys.coldPublicKey + }) }; const newAllAcounts = concatAccount(allAccounts, newAccount); @@ -620,6 +722,9 @@ export class Vault { ], this.vaultKey ); + if (created.guardianKeys) { + await persistGuardianKeys(this.vaultKey, created.guardianKeys); + } return newAllAcounts; }); @@ -747,6 +852,201 @@ export class Vault { }); } + /** + * Persist a freshly-minted hot key blob produced by createReplaceHotKeyProposal. + * Called BEFORE the rotation tx is submitted so the new ciphertext is durable + * even if the app dies after submit but before complete — the on-chain account + * state determines which hotPublicKey is canonical, and `swapHotKey` (called + * from completeReplaceHotKeyTransaction) reconciles the WalletAccount pointer + * against it. Old hot stays valid until rotation lands so this is idempotent. + */ + async persistNewHotKey(newHotPubKey: string, newHotCiphertext: string) { + return withError('Failed to persist new hot key', async () => { + await encryptAndSaveMany( + [ + [accAuthPubKeyStrgKey(newHotPubKey), newHotPubKey], + [accAuthSecretKeyStrgKey(newHotPubKey), newHotCiphertext] + ], + this.vaultKey + ); + }); + } + + /** + * Finalize a hot-key rotation: update WalletAccount.hotPublicKey to + * `newHotPubKey`, clear the `requiresHotKeyRotation` flag (set during + * recovery), and release any previously-persisted hot ciphertext. The old + * hot pubkey is read from the persisted WalletAccount — the caller doesn't + * pass it. For the post-recovery initial-activation case the record has no + * prior `hotPublicKey`, so the cleanup steps are skipped. + * + * On mobile we release the SE/StrongBox wrapper key for the old ciphertext + * via secureHotKey.deleteHotKey — best-effort, not fatal if it fails (the + * JS fallback's deleteHotKey is a no-op anyway). + */ + async swapHotKey(accountPublicKey: string, newHotPubKey: string) { + return withError('Failed to swap hot key', async () => { + const allAccounts = await this.fetchAccounts(); + const account = allAccounts.find(acc => acc.publicKey === accountPublicKey); + if (!account) { + throw new PublicError('Account not found'); + } + + const oldHotPubKey = account.hotPublicKey; + + // Update the account pointer FIRST, so the new hot key is durable before we + // release the old one. The inverse order has a lockout window: if removing + // the old blobs succeeded but this write then threw, `hotPublicKey` would + // point at a deleted key and the account could never sign again. + const newAllAccounts = allAccounts.map(acc => + acc.publicKey === accountPublicKey ? { ...acc, hotPublicKey: newHotPubKey, requiresHotKeyRotation: false } : acc + ); + await encryptAndSaveMany([[accountsStrgKey, newAllAccounts]], this.vaultKey); + + // Now best-effort release the old hot key. A crash here only orphans the + // old blobs (inert) — never a broken pointer. + if (oldHotPubKey && oldHotPubKey !== newHotPubKey) { + try { + const oldCiphertext = await fetchAndDecryptOneWithLegacyFallBack( + accAuthSecretKeyStrgKey(oldHotPubKey), + this.vaultKey + ); + await secureHotKey.deleteHotKey(oldCiphertext); + } catch (e) { + console.warn('swapHotKey: failed to release old native key (non-fatal):', e); + } + await removeMany([accAuthPubKeyStrgKey(oldHotPubKey), accAuthSecretKeyStrgKey(oldHotPubKey)]); + } + + const currentAccount = await this.getCurrentAccount(); + return { accounts: newAllAccounts, currentAccount }; + }); + } + + /** + * Persist a per-account guardian endpoint after a switch-guardian lands, so + * runtime endpoint resolution (and the next service init) point at the new + * operator. Returns the updated accounts so the caller can broadcast + * `accountsUpdated` — without that the Effector snapshot keeps the stale + * endpoint and the popup rebuilds a service against the old guardian. + */ + async setGuardianEndpoint(accountPublicKey: string, guardianEndpoint: string) { + return withError('Failed to set guardian endpoint', async () => { + const allAccounts = await this.fetchAccounts(); + const account = allAccounts.find(acc => acc.publicKey === accountPublicKey); + if (!account) { + throw new PublicError('Account not found'); + } + const newAllAccounts = allAccounts.map(acc => + acc.publicKey === accountPublicKey ? { ...acc, guardianEndpoint } : acc + ); + await encryptAndSaveMany([[accountsStrgKey, newAllAccounts]], this.vaultKey); + const currentAccount = await this.getCurrentAccount(); + return { accounts: newAllAccounts, currentAccount }; + }); + } + + /** + * One-time, in-place migration of legacy single-signer Guardian accounts to + * the 3-key model. Called on every unlock; idempotent (a no-op once an + * account carries `coldPublicKey` or `requiresHotKeyRotation`). + * + * A pre-3-key Guardian account's on-chain signer is the HD key derived at its + * index — which is exactly what the 3-key model calls the *cold* key (both are + * `AuthSecretKey.ecdsaWithRNG(deriveClientSeed(Guardian, mnemonic, hdIndex))`). + * So migrating is purely local + offline: re-derive that key into the cold + * slot and flag the account `requiresHotKeyRotation`. The account then surfaces + * the Activate Device Key banner, and a single cold-signed `update_signers` + * installs the hardware-backed hot key — the same path a seed-recovered account + * takes. No funds move and nothing is destructive; routine use simply waits on + * that one activation. + * + * Best-effort by design: any failure is swallowed so a migration hiccup can + * never block unlock. + */ + async migrateLegacyGuardianAccounts(): Promise { + try { + const allAccounts = await this.fetchAccounts(); + // Legacy = a Guardian record with neither the cold key nor the + // pending-rotation flag, i.e. created before the 3-key model. Require a + // real HD index: imported Guardian accounts are tagged hdIndex = -1, and + // deriveClientSeed(..., -1) would derive the wrong cold key (or throw), so + // they can't be migrated by re-deriving from the mnemonic. + const legacy = allAccounts.filter( + acc => acc.type === WalletType.Guardian && !acc.coldPublicKey && !acc.requiresHotKeyRotation && acc.hdIndex >= 0 + ); + if (legacy.length === 0) return; + + const mnemonic = await fetchAndDecryptOneWithLegacyFallBack(mnemonicStrgKey, this.vaultKey); + if (!mnemonic) return; // can't derive the cold key without the seed — leave untouched + + // accountId -> derived cold public key, for the records we successfully migrated. + // Strip an optional `0x` and lower-case so commitments compare regardless + // of how each side formats its hex. + const normalizeCommitmentHex = (hex: string): string => (hex.startsWith('0x') ? hex.slice(2) : hex).toLowerCase(); + + const migrated = new Map(); + for (const acc of legacy) { + try { + const coldSeed = deriveClientSeed(WalletType.Guardian, mnemonic, acc.hdIndex); + const coldSk = AuthSecretKey.ecdsaWithRNG(coldSeed); + const coldPublicKey = Buffer.from(coldSk.publicKey().serialize().slice(1)).toString('hex'); + const coldSecretKeyHex = Buffer.from(coldSk.serialize()).toString('hex'); + + // Verify the derived cold key actually matches the account's on-chain + // signer BEFORE installing it. The derivation assumes the legacy signer + // was `ecdsaWithRNG(deriveClientSeed(Guardian, mnemonic, hdIndex))`; if + // that assumption is wrong for this account (a differently-derived or + // Falcon signer), installing the derived key + flagging rotation would + // let the user start an activation that can never authorize on-chain. + // Best-effort: only BLOCK on a confirmed mismatch; if the on-chain + // account can't be loaded/read, migrate unverified (no regression). + const coldCommitment = normalizeCommitmentHex(coldSk.publicKey().toCommitment().toHex()); + try { + const sdkAccount = await withWasmClientLock(async () => (await getMidenClient()).getAccount(acc.publicKey)); + if (sdkAccount) { + const { commitment: onChainSigner } = await getSignerDetailsFromAccount(sdkAccount, false); + if (normalizeCommitmentHex(onChainSigner) !== coldCommitment) { + console.warn( + `[Vault.migrateLegacyGuardianAccounts] derived cold key does not match on-chain signer for ${acc.publicKey}; skipping (needs manual recovery)` + ); + continue; + } + } else { + console.warn( + `[Vault.migrateLegacyGuardianAccounts] on-chain account unavailable to verify cold key for ${acc.publicKey}; migrating unverified` + ); + } + } catch (verifyErr) { + console.warn( + `[Vault.migrateLegacyGuardianAccounts] cold-key verification failed for ${acc.publicKey} (migrating unverified):`, + verifyErr + ); + } + + await persistRecoveredGuardianColdKey(this.vaultKey, coldPublicKey, coldSecretKeyHex); + migrated.set(acc.publicKey, coldPublicKey); + } catch (e) { + console.warn('[Vault.migrateLegacyGuardianAccounts] skipped one account (non-fatal):', acc.publicKey, e); + } + } + if (migrated.size === 0) return; + + const nextAccounts = allAccounts.map(acc => + migrated.has(acc.publicKey) + ? { ...acc, coldPublicKey: migrated.get(acc.publicKey)!, requiresHotKeyRotation: true } + : acc + ); + await encryptAndSaveMany([[accountsStrgKey, nextAccounts]], this.vaultKey); + console.log( + `[Vault.migrateLegacyGuardianAccounts] migrated ${migrated.size} legacy Guardian account(s) to 3-key (rotation pending)` + ); + } catch (e) { + // Migration is best-effort — a failure must never block unlock. + console.warn('[Vault.migrateLegacyGuardianAccounts] failed (non-fatal):', e); + } + } + async updateSettings(settings: Partial) { return withError('Failed to update settings', async () => { const current = await this.fetchSettings(); @@ -796,16 +1096,34 @@ export class Vault { return Buffer.from(signature.serialize()).toString('hex'); } + /** + * Sign a word with the key matching `publicKey`. Routes by storage entity: + * a Guardian account's `coldPublicKey` decrypts the cold blob from + * `accColdSecretKeyStrgKey` and signs in WASM; everything else loads the + * hot ciphertext from `accAuthSecretKeyStrgKey` and dispatches through the + * secure-hot-key facade — JS fallback today, SE/StrongBox unwrap on mobile + * once Phase 4 lands. Non-Guardian accounts that still keep a single key + * under `accAuthSecretKeyStrgKey` fall through the hot path: the JS + * fallback's deserialize+sign is identical to the previous implementation. + */ async signWord(publicKey: string, wordHex: string): Promise { - const word = Word.fromHex(wordHex); - const secretKey = await fetchAndDecryptOneWithLegacyFallBack( + const accounts = await this.fetchAccounts(); + const isCold = accounts.some(acc => acc.coldPublicKey === publicKey); + if (isCold) { + const coldHex = await fetchAndDecryptOneWithLegacyFallBack( + accColdSecretKeyStrgKey(publicKey), + this.vaultKey + ); + const wasmSecretKey = AuthSecretKey.deserialize(new Uint8Array(Buffer.from(coldHex, 'hex'))); + const signature = wasmSecretKey.sign(Word.fromHex(wordHex)); + return `0x${Buffer.from(signature.serialize().slice(1)).toString('hex')}`; + } + + const hotCiphertext = await fetchAndDecryptOneWithLegacyFallBack( accAuthSecretKeyStrgKey(publicKey), this.vaultKey ); - let secretKeyBytes = new Uint8Array(Buffer.from(secretKey, 'hex')); - const wasmSecretKey = AuthSecretKey.deserialize(secretKeyBytes); - const signature = wasmSecretKey.sign(word); - return `0x${Buffer.from(signature.serialize().slice(1)).toString('hex')}`; + return secureHotKey.signHotDigest(hotCiphertext, wordHex); } async getPublicKeyForCommitment(pkc: string): Promise { @@ -881,6 +1199,74 @@ export class Vault { }); } + /** + * Reveal the raw secp256k1 hot secret for a 3-key Guardian account. Unwraps + * the platform-specific ciphertext via the secure-hot-key facade — on mobile + * this triggers a biometric prompt (the password arg authenticates the vault + * BEFORE the SE/StrongBox unwrap fires). Returns 64-char hex. + * + * Looks up the account by bech32 publicKey (the WalletAccount.publicKey + * field). Throws on non-Guardian accounts and on Guardian accounts whose + * `hotPublicKey` is not yet set (post-recovery, pre-banner-activation). + */ + static async revealHotKey(accountPublicKey: string, password?: string): Promise { + const vaultKey = password ? await Vault.unlockWithPassword(password) : await Vault.getHardwareVaultKey(); + return withError('Failed to reveal hot key', async () => { + const allAccounts = await fetchAndDecryptOneWithLegacyFallBack(accountsStrgKey, vaultKey); + const account = allAccounts?.find(a => a.publicKey === accountPublicKey); + if (!account) { + throw new PublicError('Account not found'); + } + if (account.type !== WalletType.Guardian || !account.hotPublicKey) { + throw new PublicError('Hot key is only available for activated Guardian accounts'); + } + const ciphertext = await fetchAndDecryptOneWithLegacyFallBack( + accAuthSecretKeyStrgKey(account.hotPublicKey), + vaultKey + ); + if (!ciphertext) { + throw new PublicError('Hot key ciphertext not found'); + } + return await secureHotKey.revealHotKey(ciphertext); + }); + } + + /** + * Reveal the cold private key + both public keys for a 3-key Guardian + * account. Cold is the recovery material (HD-derived from the mnemonic and + * mirrored under `accColdSecretKey` by `persistGuardianKeys` + * / `persistRecoveredGuardianColdKey`). The hot private is NOT included — + * use `revealHotKey` for that. + */ + static async revealGuardianKeys( + accountPublicKey: string, + password?: string + ): Promise<{ coldPrivateKey: string; coldPublicKey: string; hotPublicKey?: string }> { + const vaultKey = password ? await Vault.unlockWithPassword(password) : await Vault.getHardwareVaultKey(); + return withError('Failed to reveal guardian keys', async () => { + const allAccounts = await fetchAndDecryptOneWithLegacyFallBack(accountsStrgKey, vaultKey); + const account = allAccounts?.find(a => a.publicKey === accountPublicKey); + if (!account) { + throw new PublicError('Account not found'); + } + if (account.type !== WalletType.Guardian || !account.coldPublicKey) { + throw new PublicError('Not a Guardian account'); + } + const coldPrivateKey = await fetchAndDecryptOneWithLegacyFallBack( + accColdSecretKeyStrgKey(account.coldPublicKey), + vaultKey + ); + if (!coldPrivateKey) { + throw new PublicError('Cold key not found'); + } + return { + coldPrivateKey, + coldPublicKey: account.coldPublicKey, + hotPublicKey: account.hotPublicKey + }; + }); + } + async getCurrentAccount() { const currAccountPubkey = await getPlain(currentAccPubKeyStrgKey); const allAccounts = await this.fetchAccounts(); diff --git a/src/lib/miden/db/types.ts b/src/lib/miden/db/types.ts index 783f20803..3e90afeeb 100644 --- a/src/lib/miden/db/types.ts +++ b/src/lib/miden/db/types.ts @@ -15,7 +15,13 @@ export enum ITransactionStatus { } export type ITransactionIcon = 'SEND' | 'RECEIVE' | 'SWAP' | 'FAILED' | 'MINT' | 'DEFAULT'; -export type ITransactionType = 'send' | 'consume' | 'execute' | 'switch-guardian'; +export type ITransactionType = + | 'send' + | 'consume' + | 'execute' + | 'switch-guardian' + | 'replace-hot-key' + | 'update-procedure-threshold'; /** * Sub-phase of a transaction while `status === GeneratingTransaction` (or @@ -183,8 +189,12 @@ export class ConsumeTransaction implements ITransaction { displayMessage?: string; displayIcon: ITransactionIcon; delegateTransaction?: boolean; + // Background/auto-consume (vs. a user-initiated claim). Guardian accounts use + // this to route the signature through the cold key, avoiding a biometric + // prompt for a silent background claim — see generateGuardianTransaction. + background?: boolean; - constructor(accountId: string, note: ConsumableNote, delegateTransaction?: boolean) { + constructor(accountId: string, note: ConsumableNote, delegateTransaction?: boolean, background?: boolean) { this.id = uuid(); this.type = 'consume'; this.accountId = accountId; @@ -197,6 +207,7 @@ export class ConsumeTransaction implements ITransaction { this.displayIcon = 'RECEIVE'; this.displayMessage = 'Consuming'; this.delegateTransaction = delegateTransaction; + this.background = background; } } @@ -227,6 +238,73 @@ export class SwitchGuardianTransaction implements ITransaction { } } +/** + * Proactive hot-key rotation for a Guardian account. Cold-signed (recovery key); + * the on-chain proposal swaps the hot signer commitment in-place via + * `update_signers`. extraInputs.newHotPublicKey is filled in during + * `generateGuardianTransaction` once the new key is minted, and consumed by + * `completeReplaceHotKeyTransaction` to swap the WalletAccount pointer. + */ +export class ReplaceHotKeyTransaction implements ITransaction { + id: string; + type: ITransactionType; + accountId: string; + transactionId?: string; + status: ITransactionStatus; + initiatedAt: number; + processingStartedAt?: number; + completedAt?: number; + displayMessage?: string; + displayIcon: ITransactionIcon; + extraInputs: { newHotPublicKey?: string }; + delegateTransaction?: boolean | undefined; + + constructor(accountId: string, delegateTransaction?: boolean) { + this.id = uuid(); + this.type = 'replace-hot-key'; + this.accountId = accountId; + this.status = ITransactionStatus.Queued; + this.initiatedAt = Math.floor(Date.now() / 1000); + this.displayIcon = 'DEFAULT'; + this.displayMessage = 'Rotating device key'; + this.extraInputs = {}; + this.delegateTransaction = delegateTransaction; + } +} + +/** + * Sets an on-chain procedure threshold on a Guardian account (cold-signed). + * Used to bring migrated legacy accounts up to the same hardening a freshly + * created 3-key account gets — notably `update_guardian` at threshold 2 — which + * `update_signers` (the hot-key activation) cannot carry in the same tx. + */ +export class UpdateProcedureThresholdTransaction implements ITransaction { + id: string; + type: ITransactionType; + accountId: string; + transactionId?: string; + status: ITransactionStatus; + initiatedAt: number; + processingStartedAt?: number; + completedAt?: number; + displayMessage?: string; + displayIcon: ITransactionIcon; + extraInputs: { procedure: string; threshold: number }; + delegateTransaction?: boolean | undefined; + + constructor(accountId: string, procedure: string, threshold: number, delegateTransaction?: boolean) { + this.id = uuid(); + this.type = 'update-procedure-threshold'; + this.accountId = accountId; + this.status = ITransactionStatus.Queued; + this.initiatedAt = Math.floor(Date.now() / 1000); + this.displayIcon = 'DEFAULT'; + this.displayMessage = 'Securing account'; + this.extraInputs = { procedure, threshold }; + this.delegateTransaction = delegateTransaction; + } +} + export function formatTransactionStatus(status: ITransactionStatus): string { const words = ITransactionStatus[status].split(/(?=[A-Z])/); return words.join(' '); diff --git a/src/lib/miden/front/client.ts b/src/lib/miden/front/client.ts index 0bbba3cdf..f18dd20d5 100644 --- a/src/lib/miden/front/client.ts +++ b/src/lib/miden/front/client.ts @@ -48,6 +48,8 @@ export const [MidenContextProvider, useMidenContext] = constate(() => { const storeEditAccountName = useWalletStore(s => s.editAccountName); const storeRevealMnemonic = useWalletStore(s => s.revealMnemonic); const storeRevealPrivateKey = useWalletStore(s => s.revealPrivateKey); + const storeRevealHotKey = useWalletStore(s => s.revealHotKey); + const storeRevealGuardianKeys = useWalletStore(s => s.revealGuardianKeys); const storeImportAccount = useWalletStore(s => s.importAccount); const storeUpdateSettings = useWalletStore(s => s.updateSettings); const storeSignData = useWalletStore(s => s.signData); @@ -146,6 +148,20 @@ export const [MidenContextProvider, useMidenContext] = constate(() => { [storeRevealPrivateKey] ); + const revealHotKey = useCallback( + async (accountPublicKey: string, password?: string) => { + return storeRevealHotKey(accountPublicKey, password); + }, + [storeRevealHotKey] + ); + + const revealGuardianKeys = useCallback( + async (accountPublicKey: string, password?: string) => { + return storeRevealGuardianKeys(accountPublicKey, password); + }, + [storeRevealGuardianKeys] + ); + const importAccount = useCallback( async (privateKey: string, name?: string) => { return storeImportAccount(privateKey, name); @@ -304,6 +320,8 @@ export const [MidenContextProvider, useMidenContext] = constate(() => { updateCurrentAccount, revealViewKey, revealPrivateKey, + revealHotKey, + revealGuardianKeys, revealMnemonic, removeAccount, editAccountName, diff --git a/src/lib/miden/front/guardian-manager.test.ts b/src/lib/miden/front/guardian-manager.test.ts index b88e9fb2e..950cca436 100644 --- a/src/lib/miden/front/guardian-manager.test.ts +++ b/src/lib/miden/front/guardian-manager.test.ts @@ -21,7 +21,11 @@ jest.mock('./storage', () => ({ const mockGetSignerDetailsFromAccount = jest.fn(); jest.mock('../guardian/account', () => ({ - getSignerDetailsFromAccount: (...args: unknown[]) => mockGetSignerDetailsFromAccount(...args) + getSignerDetailsFromAccount: (...args: unknown[]) => mockGetSignerDetailsFromAccount(...args), + // Mirror the real resolver: prefer the per-account endpoint, else the stored + // global key (driven by mockFetchFromStorage), else the default. + resolveGuardianEndpoint: async (acc: { guardianEndpoint?: string }) => + acc.guardianEndpoint ?? (await mockFetchFromStorage('guardian_url_setting')) ?? 'https://default.guardian.test' })); const mockGetAccount = jest.fn(); @@ -48,8 +52,17 @@ jest.mock('lib/settings/constants', () => ({ const GUARDIAN_PK = 'guardian-pk'; const OTHER_PK = 'other-pk'; - -const guardianAccount = { publicKey: GUARDIAN_PK, type: WalletType.Guardian, name: 'Guardian', hdIndex: 0 }; +const HOT_PK = 'hot-pk-hex'; + +const guardianAccount = { + publicKey: GUARDIAN_PK, + type: WalletType.Guardian, + name: 'Guardian', + hdIndex: 0, + // Phase 4: WalletAccount carries the hot pubkey directly; getOrCreateMultisigService + // reads it and throws if missing. + hotPublicKey: HOT_PK +}; const onChainAccount = { publicKey: OTHER_PK, type: WalletType.OnChain, name: 'Public', hdIndex: 1 }; const makeProvider = (accounts: unknown[]): GuardianAccountProvider => ({ @@ -63,7 +76,7 @@ describe('guardian-manager', () => { jest.clearAllMocks(); clearGuardianCache(); mockFetchFromStorage.mockResolvedValue('https://default.guardian.test'); - mockGetSignerDetailsFromAccount.mockResolvedValue({ commitment: 'abc', publicKey: 'def' }); + mockGetSignerDetailsFromAccount.mockResolvedValue({ commitment: 'abc' }); mockGetAccount.mockResolvedValue({ id: () => ({ toString: () => 'acc-id' }) }); }); @@ -76,7 +89,16 @@ describe('guardian-manager', () => { const result = await getOrCreateMultisigService(GUARDIAN_PK, provider); expect(result).toBe(service); - expect(mockMultisigServiceInit).toHaveBeenCalledWith(expect.anything(), '0xdef', '0xabc', provider.signWord); + // The publicKey arg comes from WalletAccount.hotPublicKey (not from + // getSignerDetailsFromAccount anymore), prefixed with `0x`. + expect(mockMultisigServiceInit).toHaveBeenCalledWith( + expect.anything(), + `0x${HOT_PK}`, + '0xabc', + provider.signWord, + // The resolved per-account endpoint is now passed through to init. + 'https://default.guardian.test' + ); // Second call for the same account returns the cached instance without // re-initializing the service. mockMultisigServiceInit.mockClear(); @@ -120,6 +142,27 @@ describe('guardian-manager', () => { expect(mockMultisigServiceInit).toHaveBeenCalledTimes(2); }); + it('uses the per-account guardianEndpoint over the global key (multi-account isolation)', async () => { + // Two Guardian accounts on different operators must not collide: the one + // carrying its own endpoint binds to it regardless of the global key. + const service = { guardianEndpoint: 'https://per-account.guardian', tag: 'isolated' }; + mockMultisigServiceInit.mockResolvedValueOnce(service); + const provider = makeProvider([{ ...guardianAccount, guardianEndpoint: 'https://per-account.guardian' }]); + + const result = await getOrCreateMultisigService(GUARDIAN_PK, provider); + + expect(result).toBe(service); + expect(mockMultisigServiceInit).toHaveBeenCalledWith( + expect.anything(), + `0x${HOT_PK}`, + '0xabc', + provider.signWord, + 'https://per-account.guardian' + ); + // The per-account field short-circuits the global-key lookup. + expect(mockFetchFromStorage).not.toHaveBeenCalled(); + }); + it('coalesces concurrent service initialization for the same account', async () => { const service = { guardianEndpoint: 'https://default.guardian.test', tag: 'shared' }; let resolveInit!: (value: unknown) => void; @@ -148,6 +191,16 @@ describe('guardian-manager', () => { await expect(getOrCreateMultisigService(OTHER_PK, provider)).rejects.toThrow('Account is not a Guardian account'); }); + it('throws loudly when a Guardian account is missing its hot pubkey', async () => { + // A Guardian record without hotPublicKey is a pre-migration/half-written + // state — fail rather than silently bind to a missing signer. + const { hotPublicKey, ...noHotKey } = guardianAccount; + void hotPublicKey; + const provider = makeProvider([noHotKey]); + + await expect(getOrCreateMultisigService(GUARDIAN_PK, provider)).rejects.toThrow('missing hotPublicKey'); + }); + it('throws when the public key is unknown to the provider', async () => { const provider = makeProvider([guardianAccount]); diff --git a/src/lib/miden/front/guardian-manager.ts b/src/lib/miden/front/guardian-manager.ts index 98e425d7e..e767f4932 100644 --- a/src/lib/miden/front/guardian-manager.ts +++ b/src/lib/miden/front/guardian-manager.ts @@ -1,16 +1,18 @@ import { MultisigService } from 'lib/miden/guardian'; import { clearGuardianAccountLocks } from 'lib/miden/guardian/serialize'; -import { DEFAULT_GUARDIAN_ENDPOINT } from 'lib/miden-chain/constants'; -import { GUARDIAN_URL_STORAGE_KEY } from 'lib/settings/constants'; import { WalletAccount } from 'lib/shared/types'; import { WalletType } from 'screens/onboarding/types'; -import { fetchFromStorage } from './storage'; -import { getSignerDetailsFromAccount } from '../guardian/account'; +import { getSignerDetailsFromAccount, resolveGuardianEndpoint } from '../guardian/account'; import { getMidenClient, withWasmClientLock } from '../sdk/miden-client'; // Cache MultisigService instances to avoid re-initialization on every sync cycle. -const guardianServiceCache = new Map(); +// `hotPublicKey` is recorded alongside so rotations are detected on next access: +// the cached service is bound to a specific WalletSigner pubkey, and after a +// replace-hot-key tx the WalletAccount.hotPublicKey changes — without the +// drift check, the popup sync keeps signing with the rotated-out key. +type CacheEntry = { service: MultisigService; hotPublicKey: string }; +const guardianServiceCache = new Map(); // In-flight MultisigService.init promises, keyed by accountPublicKey. The // guardian sync runs every 3s and does not await previous ticks; without this, @@ -28,6 +30,16 @@ export interface GuardianAccountProvider { getAccounts: () => Promise; getPublicKeyForCommitment: (commitment: string) => Promise; signWord: (publicKey: string, wordHex: string) => Promise; + // Optional SW-only callbacks used by the proactive replace-hot-key flow. + // Frontend providers (zustandProvider) leave these undefined; the rotation + // path runs only inside the SW-side transaction processor where the + // vault-backed provider implements them. + persistNewHotKey?: (newHotPubKey: string, newHotCiphertext: string) => Promise; + swapHotKey?: (accountPublicKey: string, newHotPubKey: string) => Promise; + // Persist a per-account guardian endpoint after a switch-guardian lands. + // SW-only (vault-backed); the frontend zustand provider leaves it undefined + // because guardian-switch completion runs exclusively in the backend processor. + setGuardianEndpoint?: (accountPublicKey: string, guardianEndpoint: string) => Promise; } /** @@ -38,18 +50,17 @@ export async function getOrCreateMultisigService( accountPublicKey: string, provider: GuardianAccountProvider ): Promise { - // Return cached instance if its endpoint still matches storage. In the - // extension build, `clearGuardianServiceFor` from the SW realm doesn't reach - // the frontend's own copy of this Map, so a guardian switch would leave - // the popup syncing against the old guardian indefinitely. Re-check - // GUARDIAN_URL_STORAGE_KEY here and evict on drift. - const cached = guardianServiceCache.get(accountPublicKey); - if (cached) { - const currentEndpoint = (await fetchFromStorage(GUARDIAN_URL_STORAGE_KEY)) || DEFAULT_GUARDIAN_ENDPOINT; - if (cached.guardianEndpoint === currentEndpoint) return cached; - guardianServiceCache.delete(accountPublicKey); - } - + // NOTE: no endpoint-only fast-path here. The cache hit is served by the inner + // check below (after we resolve the account's current hotPublicKey), which + // compares BOTH the guardian endpoint AND the bound hot pubkey. An outer + // endpoint-only check returned the stale service after a replace_hot_key + // rotation (which doesn't touch the endpoint), so the popup kept signing with + // the rotated-out hot key — `clearGuardianServiceFor` runs in the SW realm and + // never evicts the popup's Map. getAccounts() is an in-memory store read, so + // routing cache hits through init is cheap. + // Coalesce concurrent inits: the guardian sync runs every 3s and does not + // await previous ticks, so without this an in-flight init can start again + // before its resolved service reaches the cache. const inflight = guardianServiceInflight.get(accountPublicKey); if (inflight) { return inflight; @@ -62,8 +73,34 @@ export async function getOrCreateMultisigService( if (!account || account.type !== WalletType.Guardian) { throw new Error('Account is not a Guardian account'); } + // Hot pubkey lives on the WalletAccount record (set at create time). A + // Guardian account without it is either a legacy single-Falcon-key record + // (pre-migration) or an in-flight write that crashed mid-create — both are + // unsigned states that should fail loudly rather than silently fall back. + if (!account.hotPublicKey) { + throw new Error(`Guardian account ${accountPublicKey} is missing hotPublicKey — re-create the wallet`); + } + const hotPublicKey = account.hotPublicKey; + // Per-account guardian endpoint (falls back to the legacy global key for + // records created before the field existed). Resolved once and reused for + // both the cache drift-check and the init binding below. + const currentEndpoint = await resolveGuardianEndpoint(account); + + // Return cached instance if its endpoint AND bound hot pubkey still match. + // Two separate drift sources: + // - guardian endpoint: switch_guardian rotates the URL; clearGuardianServiceFor + // in the SW realm doesn't reach the popup's Map, so re-check here. + // - hot pubkey: replace_hot_key rotates account.hotPublicKey; the cached + // service is still bound to the previous WalletSigner.publicKey. + const cached = guardianServiceCache.get(accountPublicKey); + if (cached) { + if (cached.service.guardianEndpoint === currentEndpoint && cached.hotPublicKey === hotPublicKey) { + return cached.service; + } + guardianServiceCache.delete(accountPublicKey); + } - // Get the Account object from Miden client + // Get the Account object from the Miden client. const { sdkAccount } = await withWasmClientLock(async () => { const midenClient = await getMidenClient(); const sdkAccount = await midenClient.getAccount(accountPublicKey); @@ -74,12 +111,21 @@ export async function getOrCreateMultisigService( throw new Error('Account not found in local storage'); } - const { commitment, publicKey } = await getSignerDetailsFromAccount(sdkAccount, provider.getPublicKeyForCommitment); - // Initialize MultisigService with the account, public key, commitment, and signWord function - const service = await MultisigService.init(sdkAccount, `0x${publicKey}`, `0x${commitment}`, provider.signWord); - - // Cache for future use - guardianServiceCache.set(accountPublicKey, service); + // Hot signer commitment lives at signer index 0 (order is [hot, cold]). + const { commitment } = await getSignerDetailsFromAccount(sdkAccount); + // Bind the service to the hot signer — the popup signs with the hot key. + console.log('creating guardian service', sdkAccount.id().toString()); + const service = await MultisigService.init( + sdkAccount, + `0x${hotPublicKey}`, + `0x${commitment}`, + provider.signWord, + currentEndpoint + ); + + // Cache for future use, tagged with the hot pubkey it was bound to so the + // next access can detect rotation and force a re-init. + guardianServiceCache.set(accountPublicKey, { service, hotPublicKey }); return service; })(); diff --git a/src/lib/miden/front/guardian-sync.test.ts b/src/lib/miden/front/guardian-sync.test.ts index c3089b021..24411eb7e 100644 --- a/src/lib/miden/front/guardian-sync.test.ts +++ b/src/lib/miden/front/guardian-sync.test.ts @@ -10,13 +10,17 @@ import { WalletType } from 'screens/onboarding/types'; import { syncGuardianAccounts, zustandProvider } from './guardian-sync'; const storeState: { - accounts: Array<{ publicKey: string; type: WalletType }>; + accounts: Array<{ publicKey: string; type: WalletType; requiresHotKeyRotation?: boolean; hotPublicKey?: string }>; getPublicKeyForCommitment: jest.Mock; signWord: jest.Mock; + persistNewHotKey: jest.Mock; + swapHotKey: jest.Mock; } = { accounts: [], getPublicKeyForCommitment: jest.fn(), - signWord: jest.fn() + signWord: jest.fn(), + persistNewHotKey: jest.fn(), + swapHotKey: jest.fn() }; jest.mock('lib/store', () => ({ @@ -30,12 +34,21 @@ jest.mock('./guardian-manager', () => ({ getOrCreateMultisigService: (...args: unknown[]) => mockGetOrCreateMultisigService(...args) })); +// The self-heal hook dynamic-imports this; stub it so the sync test stays focused +// on sync behavior (the hardening itself is covered in the transactions suite). +const mockEnsureGuardianProcedureThresholds = jest.fn(); +jest.mock('lib/miden/activity/transactions', () => ({ + ensureGuardianProcedureThresholds: (...args: unknown[]) => mockEnsureGuardianProcedureThresholds(...args) +})); + describe('zustandProvider', () => { beforeEach(() => { jest.clearAllMocks(); storeState.accounts = []; storeState.getPublicKeyForCommitment.mockResolvedValue('pk'); storeState.signWord.mockResolvedValue('sig'); + storeState.persistNewHotKey.mockResolvedValue(undefined); + storeState.swapHotKey.mockResolvedValue(undefined); }); it('getAccounts returns the current store accounts', async () => { @@ -55,6 +68,17 @@ describe('zustandProvider', () => { await zustandProvider.signWord('pub', '0xhex'); expect(storeState.signWord).toHaveBeenCalledWith('pub', '0xhex'); }); + + it('persistNewHotKey delegates to the store', async () => { + // Optional on the interface; the assertion below fails if it's missing. + await zustandProvider.persistNewHotKey?.('new-pub', 'new-ciphertext'); + expect(storeState.persistNewHotKey).toHaveBeenCalledWith('new-pub', 'new-ciphertext'); + }); + + it('swapHotKey delegates to the store', async () => { + await zustandProvider.swapHotKey?.('account-pub', 'new-hot-pub'); + expect(storeState.swapHotKey).toHaveBeenCalledWith('account-pub', 'new-hot-pub'); + }); }); describe('syncGuardianAccounts', () => { @@ -73,9 +97,9 @@ describe('syncGuardianAccounts', () => { it('calls service.sync for every Guardian account', async () => { storeState.accounts = [ - { publicKey: 'guardian-1', type: WalletType.Guardian }, + { publicKey: 'guardian-1', type: WalletType.Guardian, hotPublicKey: 'hot-1' }, { publicKey: 'public-1', type: WalletType.OnChain }, - { publicKey: 'guardian-2', type: WalletType.Guardian } + { publicKey: 'guardian-2', type: WalletType.Guardian, hotPublicKey: 'hot-2' } ]; const sync = jest.fn(async () => {}); mockGetOrCreateMultisigService.mockResolvedValue({ sync }); @@ -88,10 +112,21 @@ describe('syncGuardianAccounts', () => { expect(sync).toHaveBeenCalledTimes(2); }); + it('self-heals the update_guardian hardening once per account per session', async () => { + storeState.accounts = [{ publicKey: 'guardian-heal', type: WalletType.Guardian, hotPublicKey: 'hot-heal' }]; + mockGetOrCreateMultisigService.mockResolvedValue({ sync: jest.fn(async () => {}) }); + + await syncGuardianAccounts(); + await syncGuardianAccounts(); // second pass — the session guard suppresses a re-check + + expect(mockEnsureGuardianProcedureThresholds).toHaveBeenCalledTimes(1); + expect(mockEnsureGuardianProcedureThresholds).toHaveBeenCalledWith('guardian-heal', undefined, zustandProvider); + }); + it('continues syncing remaining accounts when one throws', async () => { storeState.accounts = [ - { publicKey: 'guardian-bad', type: WalletType.Guardian }, - { publicKey: 'guardian-good', type: WalletType.Guardian } + { publicKey: 'guardian-bad', type: WalletType.Guardian, hotPublicKey: 'hot-bad' }, + { publicKey: 'guardian-good', type: WalletType.Guardian, hotPublicKey: 'hot-good' } ]; const goodSync = jest.fn(async () => {}); mockGetOrCreateMultisigService.mockRejectedValueOnce(new Error('boom')).mockResolvedValueOnce({ sync: goodSync }); @@ -99,4 +134,43 @@ describe('syncGuardianAccounts', () => { await expect(syncGuardianAccounts()).resolves.toBeUndefined(); expect(goodSync).toHaveBeenCalledTimes(1); }); + + it('skips Guardian accounts that still require hot-key rotation (post-recovery, pre-activation)', async () => { + // Recovered accounts have requiresHotKeyRotation=true and no hotPublicKey + // until the Activate Device Key banner runs the cold-signed update_signers + // rotation. Sync would throw on the missing hotPublicKey gate inside + // getOrCreateMultisigService — skip them upstream so AutoSync stays quiet. + storeState.accounts = [ + { publicKey: 'guardian-pending', type: WalletType.Guardian, requiresHotKeyRotation: true }, + { publicKey: 'guardian-active', type: WalletType.Guardian, hotPublicKey: 'hot-active' } + ]; + const sync = jest.fn(async () => {}); + mockGetOrCreateMultisigService.mockResolvedValue({ sync }); + + await syncGuardianAccounts(); + + expect(mockGetOrCreateMultisigService).toHaveBeenCalledTimes(1); + expect(mockGetOrCreateMultisigService).toHaveBeenCalledWith('guardian-active', zustandProvider); + expect(sync).toHaveBeenCalledTimes(1); + }); + + it('skips legacy Guardian accounts with no hot key (un-migrated / upgrade window)', async () => { + // A pre-3-key Guardian record carries neither hotPublicKey nor the + // requiresHotKeyRotation flag — e.g. right after a wallet upgrade and before + // the forced re-unlock runs migrateLegacyGuardianAccounts. getOrCreateMultisigService + // would throw "missing hotPublicKey" on it every cycle; skip it instead. The + // account is recovered by migration → Activate Device Key banner, not here. + storeState.accounts = [ + { publicKey: 'guardian-legacy', type: WalletType.Guardian }, // no hotPublicKey, no rotation flag + { publicKey: 'guardian-active', type: WalletType.Guardian, hotPublicKey: 'hot-active' } + ]; + const sync = jest.fn(async () => {}); + mockGetOrCreateMultisigService.mockResolvedValue({ sync }); + + await expect(syncGuardianAccounts()).resolves.toBeUndefined(); + + // Only the active account is synced; the legacy one is skipped, no throw. + expect(mockGetOrCreateMultisigService).toHaveBeenCalledTimes(1); + expect(mockGetOrCreateMultisigService).toHaveBeenCalledWith('guardian-active', zustandProvider); + }); }); diff --git a/src/lib/miden/front/guardian-sync.ts b/src/lib/miden/front/guardian-sync.ts index dd1814939..ee927476d 100644 --- a/src/lib/miden/front/guardian-sync.ts +++ b/src/lib/miden/front/guardian-sync.ts @@ -12,16 +12,46 @@ import { getOrCreateMultisigService, type GuardianAccountProvider } from './guar export const zustandProvider: GuardianAccountProvider = { getAccounts: async () => useWalletStore.getState().accounts, getPublicKeyForCommitment: (commitment: string) => useWalletStore.getState().getPublicKeyForCommitment(commitment), - signWord: (publicKey: string, wordHex: string) => useWalletStore.getState().signWord(publicKey, wordHex) + signWord: (publicKey: string, wordHex: string) => useWalletStore.getState().signWord(publicKey, wordHex), + persistNewHotKey: (newHotPubKey: string, newHotCiphertext: string) => + useWalletStore.getState().persistNewHotKey(newHotPubKey, newHotCiphertext), + swapHotKey: (accountPublicKey: string, newHotPubKey: string) => + useWalletStore.getState().swapHotKey(accountPublicKey, newHotPubKey) }; /** * Sync Guardian state for all Guardian accounts. Called from AutoSync after chain * state sync (frontend context only — uses the Zustand-backed provider). + * + * Only Guardian accounts that actually carry a `hotPublicKey` are synced: + * `getOrCreateMultisigService` binds a service against the hot signer and throws + * without one. Every account lacking a hot key is skipped, which covers: + * - rotation-pending accounts (`requiresHotKeyRotation`, adopted via recovery + * or flagged by the legacy-Guardian migration) awaiting the Activate Device + * Key banner, and + * - legacy single-signer Guardian records that haven't been migrated yet — + * e.g. the brief window after a wallet UPGRADE (new code, old storage) and + * before the forced re-unlock runs `migrateLegacyGuardianAccounts`. Without + * this guard those records made the frontend AutoSync throw "missing + * hotPublicKey" every ~3s. Skipping them is correct, not a silence: there is + * genuinely no hot-bound service to build, and recovery happens via the + * migration → banner → activation path, not here. + * Once a hot key lands (`swapHotKey`), the next sync cycle picks the account up. + * + * This also means the `update_guardian` threshold-2 hardening is intentionally + * NOT applied to hot-key-less accounts here, and that's correct: a pre-activation + * account has a single on-chain signer (cold), so a 2-of-N procedure threshold + * is unsatisfiable and would brick guardian changes. The hardening is applied + * at activation (`completeReplaceHotKeyTransaction`), once the hot signer makes + * the account 2-of-N. Don't "fix" this filter to harden pre-activation accounts. */ +// Accounts whose update_guardian hardening we've already verified this session, +// so the self-heal check below runs at most once per account per session. +const hardeningChecked = new Set(); + export async function syncGuardianAccounts(): Promise { const accounts = await zustandProvider.getAccounts(); - const guardianAccounts = accounts.filter(acc => acc.type === WalletType.Guardian); + const guardianAccounts = accounts.filter(acc => acc.type === WalletType.Guardian && Boolean(acc.hotPublicKey)); if (guardianAccounts.length === 0) return; @@ -29,6 +59,15 @@ export async function syncGuardianAccounts(): Promise { try { const service = await getOrCreateMultisigService(account.publicKey, zustandProvider); await service.sync(); + + // Self-heal the update_guardian threshold-2 hardening: if a migrated + // account's original hardening tx was dropped, it would otherwise sit at + // threshold-1 indefinitely. Idempotent + best-effort; once per session. + if (!hardeningChecked.has(account.publicKey)) { + hardeningChecked.add(account.publicKey); + const { ensureGuardianProcedureThresholds } = await import('lib/miden/activity/transactions'); + await ensureGuardianProcedureThresholds(account.publicKey, undefined, zustandProvider); + } } catch (error) { console.error(`[Guardian Sync] Error syncing Guardian account ${account.publicKey}:`, error); } diff --git a/src/lib/miden/guardian/account.test.ts b/src/lib/miden/guardian/account.test.ts index db984a84f..93e1a7595 100644 --- a/src/lib/miden/guardian/account.test.ts +++ b/src/lib/miden/guardian/account.test.ts @@ -1,12 +1,18 @@ /** * guardian/account — getSignerDetailsFromAccount reads the first signer * commitment out of the multisig storage slot; createGuardianAccount drives - * MultisigClient.create + guardian registration + keystore insertion. + * MultisigClient.create + guardian registration + keystore insertion for + * the 3-key (hot + cold + guardian) layout. * * All external collaborators are stubbed; we don't exec any real WASM. */ -import { createGuardianAccount, getSignerDetailsFromAccount, MULTISIG_SLOT_NAMES } from './account'; +import { + createGuardianAccount, + getSignerDetailsFromAccount, + MULTISIG_SLOT_NAMES, + resolveGuardianEndpoint +} from './account'; const mockFetchFromStorage = jest.fn(); jest.mock('../front/storage', () => ({ @@ -14,30 +20,75 @@ jest.mock('../front/storage', () => ({ })); jest.mock('lib/miden-chain/constants', () => ({ - getDefaultGuardianEndpoint: () => 'https://default.guardian.test' + DEFAULT_GUARDIAN_ENDPOINT: 'https://default.guardian.test' })); jest.mock('lib/settings/constants', () => ({ GUARDIAN_URL_STORAGE_KEY: 'guardian_url_setting' })); -// AuthSecretKey.ecdsaWithRNG + commitment calls need a predictable stub. -const mockAuthSecretKeyEcdsa = jest.fn(); +// AuthSecretKey.ecdsaWithRNG returns a deterministic stub keyed by the seed +// so we can distinguish hot vs cold material. Each call mints a new "key" +// object whose serialize/publicKey/etc are jest mocks the assertions can read. +type StubKey = { + serialize: jest.Mock; + publicKey: jest.Mock; + __seedTag: string; +}; +const stubKeyByTag: Record = {}; +const buildStubKey = (tag: string): StubKey => { + const key: StubKey = { + __seedTag: tag, + serialize: jest.fn(() => new Uint8Array([0xaa, ...Buffer.from(tag, 'utf-8')])), + publicKey: jest.fn(() => ({ + serialize: jest.fn(() => new Uint8Array([0x01, ...Buffer.from(`pub-${tag}`, 'utf-8')])), + toCommitment: jest.fn(() => ({ toHex: () => `0xcommit-${tag}` })) + })) + }; + stubKeyByTag[tag] = key; + return key; +}; jest.mock('@miden-sdk/miden-sdk/lazy', () => { const actual = jest.requireActual('../../../../__mocks__/wasmMock.js'); return { ...actual, - AuthSecretKey: { ecdsaWithRNG: (seed: unknown) => mockAuthSecretKeyEcdsa(seed) } + AuthSecretKey: { + ecdsaWithRNG: jest.fn((seed: Uint8Array) => buildStubKey(`s${Array.from(seed).join('-')}`)) + }, + // getSignerDetailsFromAccount builds `new Word(new BigUint64Array([i,0,0,0]))` + // as the signer map key; expose the index so the getMapItem mock can resolve it. + Word: class { + idx: number; + constructor(arr: BigUint64Array) { + this.idx = Number(arr?.[0] ?? 0n); + } + } }; }); jest.mock('@miden-sdk/miden-sdk', () => { const actual = jest.requireActual('../../../../__mocks__/wasmMock.js'); return { ...actual, - AuthSecretKey: { ecdsaWithRNG: (seed: unknown) => mockAuthSecretKeyEcdsa(seed) } + AuthSecretKey: { + ecdsaWithRNG: jest.fn((seed: Uint8Array) => buildStubKey(`s${Array.from(seed).join('-')}`)) + }, + // getSignerDetailsFromAccount builds `new Word(new BigUint64Array([i,0,0,0]))` + // as the signer map key; expose the index so the getMapItem mock can resolve it. + Word: class { + idx: number; + constructor(arr: BigUint64Array) { + this.idx = Number(arr?.[0] ?? 0n); + } + } }; }); +// secure-hot-key facade — generateHotKey is the only entry createGuardianAccount uses. +const mockGenerateHotKey = jest.fn(); +jest.mock('lib/secure-hot-key', () => ({ + generateHotKey: (...a: unknown[]) => mockGenerateHotKey(...a) +})); + // Guardian SDK stubs — keep per-test knobs for getPubkey + client.create. const multisigClientConfig: { create: jest.Mock; @@ -46,6 +97,7 @@ const multisigClientConfig: { create: jest.fn(), getPubkey: jest.fn() }; +const ecdsaSignerCtor = jest.fn(); jest.mock('@openzeppelin/miden-multisig-client', () => ({ MultisigClient: jest.fn().mockImplementation(() => ({ @@ -54,55 +106,81 @@ jest.mock('@openzeppelin/miden-multisig-client', () => ({ getPubkey: (...a: unknown[]) => multisigClientConfig.getPubkey(...a) } })), - EcdsaSigner: jest.fn().mockImplementation((sk: unknown) => ({ sk })) + EcdsaSigner: jest.fn().mockImplementation((sk: unknown) => { + ecdsaSignerCtor(sk); + return { sk }; + }) })); describe('getSignerDetailsFromAccount', () => { - const getPublicKeyForCommitment = jest.fn(); - beforeEach(() => { jest.clearAllMocks(); - getPublicKeyForCommitment.mockResolvedValue('derived-pubkey'); }); - const makeAccount = (entries: unknown) => ({ - storage: () => ({ getMapEntries: jest.fn(() => entries) }) + // Mock account whose storage().getMapItem resolves a signer commitment by the + // index encoded in the key word (the Word mock above sets `{ idx }`). This + // mirrors the real by-key read; positional order is irrelevant. + const makeAccount = (signersByIndex: Record) => ({ + storage: () => ({ + getMapItem: jest.fn((_slot: string, key: { idx: number }) => { + const hex = signersByIndex[key.idx]; + return hex === undefined ? undefined : { toHex: () => hex }; + }) + }) }); - it('reads the first signer commitment and resolves the matching public key', async () => { - const account = makeAccount([{ value: '0xcommit-first' }, { value: '0xcommit-second' }]); + it('reads the hot signer commitment from index 0', async () => { + const account = makeAccount({ 0: '0xcommit-hot', 1: '0xcommit-cold' }); - const result = await getSignerDetailsFromAccount(account as never, getPublicKeyForCommitment); + expect(await getSignerDetailsFromAccount(account as never)).toEqual({ commitment: 'commit-hot' }); + }); - expect(result).toEqual({ commitment: 'commit-first', publicKey: 'derived-pubkey' }); - expect(getPublicKeyForCommitment).toHaveBeenCalledWith('commit-first'); + it('reads the cold signer commitment from index 1 on a 3-key account', async () => { + const account = makeAccount({ 0: '0xcommit-hot', 1: '0xcommit-cold' }); + + expect(await getSignerDetailsFromAccount(account as never, true)).toEqual({ commitment: 'commit-cold' }); }); - it('throws when the signer-public-keys slot is missing', async () => { - const account = makeAccount(undefined); + it('reads the cold signer commitment from index 0 on a legacy single-signer account', async () => { + // Legacy Guardian accounts (feature #153) have a single on-chain signer — + // the cold/HD key — at index 0. The cold lookup falls back to it (index 1 is + // absent) rather than throwing, which would brick activation of a migrated + // account. + const account = makeAccount({ 0: '0xcommit-legacy-cold' }); - await expect(getSignerDetailsFromAccount(account as never, getPublicKeyForCommitment)).rejects.toThrow( - 'No signer public keys found in account storage' - ); + expect(await getSignerDetailsFromAccount(account as never, true)).toEqual({ commitment: 'commit-legacy-cold' }); + }); + + it('reads commitments by signer-index key, independent of storage iteration order', async () => { + // Regression guard for the SMT-order bug: getMapItem(signerMapKey(i)) resolves + // hot=0 / cold=1 correctly regardless of getMapEntries iteration order. A + // positional read would bind the wrong signer for ~half of accounts. + const account = makeAccount({ 0: '0xhotC', 1: '0xcoldC' }); + + expect(await getSignerDetailsFromAccount(account as never)).toEqual({ commitment: 'hotC' }); + expect(await getSignerDetailsFromAccount(account as never, true)).toEqual({ commitment: 'coldC' }); }); - it('throws when the slot is present but empty', async () => { - const account = makeAccount([]); + it('throws when there is no signer at index 0', async () => { + const account = makeAccount({}); - await expect(getSignerDetailsFromAccount(account as never, getPublicKeyForCommitment)).rejects.toThrow( - 'No signer commitments found in account storage' + await expect(getSignerDetailsFromAccount(account as never)).rejects.toThrow( + 'No signer commitment found in account storage' ); }); - it('throws when the stored value has no bytes after the 0x prefix', async () => { - // `.slice(2)` on '0x' yields an empty string — the `if (!commitment)` guard - // rejects instead of handing an empty hash to getPublicKeyForCommitment. - const account = makeAccount([{ value: '0x' }]); + it('treats an empty-word entry (0x / all-zeros) as no signer', async () => { + const account = makeAccount({ 0: '0x' }); - await expect(getSignerDetailsFromAccount(account as never, getPublicKeyForCommitment)).rejects.toThrow( - 'Commitment not found in account storage' + await expect(getSignerDetailsFromAccount(account as never)).rejects.toThrow( + 'No signer commitment found in account storage' ); - expect(getPublicKeyForCommitment).not.toHaveBeenCalled(); + }); + + it('accepts a commitment hex without a 0x prefix', async () => { + const account = makeAccount({ 0: 'beefcafe' }); + + expect(await getSignerDetailsFromAccount(account as never)).toEqual({ commitment: 'beefcafe' }); }); it('exposes the multisig storage slot names', () => { @@ -123,26 +201,30 @@ describe('createGuardianAccount', () => { beforeEach(() => { jest.clearAllMocks(); - mockAuthSecretKeyEcdsa.mockReturnValue({ - publicKey: () => ({ toCommitment: () => ({ toHex: () => '0xsigner-commit' }) }) - }); multisigClientConfig.getPubkey.mockResolvedValue({ commitment: 'g-commit', pubkey: 'g-pubkey' }); mockFetchFromStorage.mockResolvedValue(undefined); + mockGenerateHotKey.mockResolvedValue({ + ciphertext: 'hot-ciphertext-hex', + publicKeyHex: 'hot-pubkey-hex', + commitmentHex: '0xhot-commit' + }); }); - it('creates a 1-of-1 multisig, registers with the guardian, syncs, and persists the signer key', async () => { + it('creates a 2-of-N multisig with [hot, cold] commitments, registers, syncs, persists cold to keystore', async () => { const webClient = makeWebClient(); const multisig = makeMultisig(); multisigClientConfig.create.mockResolvedValueOnce(multisig); const seed = new Uint8Array([1, 2, 3, 4]); - const account = await createGuardianAccount(webClient as never, seed); + const result = await createGuardianAccount(webClient as never, seed); - expect(mockAuthSecretKeyEcdsa).toHaveBeenCalledWith(seed); + // Hot is generated via the secure-hot-key facade; cold is HD-derived from seed. + expect(mockGenerateHotKey).toHaveBeenCalledTimes(1); expect(multisigClientConfig.create).toHaveBeenCalledWith( expect.objectContaining({ threshold: 1, - signerCommitments: ['0xsigner-commit'], + // Hot first, cold second — order is load-bearing for downstream role routing. + signerCommitments: ['0xhot-commit', '0xcommit-s1-2-3-4'], guardianCommitment: 'g-commit', guardianPublicKey: 'g-pubkey', guardianEnabled: true, @@ -152,10 +234,25 @@ describe('createGuardianAccount', () => { }), expect.anything() ); + // The deploy proposal is signed by cold (we hand the cold AuthSecretKey to EcdsaSigner). + expect(ecdsaSignerCtor).toHaveBeenCalledWith(stubKeyByTag['s1-2-3-4']); expect(multisig.registerOnGuardian).toHaveBeenCalled(); expect(webClient.sync).toHaveBeenCalled(); - expect(webClient.keystore.insert).toHaveBeenCalled(); - expect(account).toBe(multisig.account); + // Only the cold key is inserted into the SDK keystore — hot lives outside. + expect(webClient.keystore.insert).toHaveBeenCalledTimes(1); + expect(webClient.keystore.insert).toHaveBeenCalledWith(expect.anything(), stubKeyByTag['s1-2-3-4']); + + // The rich return shape exposes everything vault.ts needs to persist. + expect(result.account).toBe(multisig.account); + expect(result.keys).toEqual({ + hotPublicKey: 'hot-pubkey-hex', + coldPublicKey: expect.any(String), + hotCiphertext: 'hot-ciphertext-hex', + coldSecretKeyHex: expect.any(String) + }); + // Endpoint is returned so vault can persist it per-account. No stored URL + // here (beforeEach stubs undefined), so it falls back to the default. + expect(result.guardianEndpoint).toBe('https://default.guardian.test'); }); it('generates a random seed when none is provided', async () => { @@ -164,8 +261,9 @@ describe('createGuardianAccount', () => { await createGuardianAccount(webClient as never); - // ecdsaWithRNG was still called with a 32-byte Uint8Array. - const seedArg = mockAuthSecretKeyEcdsa.mock.calls[0]?.[0]; + // ecdsaWithRNG was still called with a 32-byte Uint8Array (cold-seed fallback). + const ecdsaCall = jest.requireMock('@miden-sdk/miden-sdk/lazy').AuthSecretKey.ecdsaWithRNG; + const seedArg = ecdsaCall.mock.calls[0]?.[0]; expect(seedArg).toBeInstanceOf(Uint8Array); expect((seedArg as Uint8Array).length).toBe(32); }); @@ -185,12 +283,14 @@ describe('createGuardianAccount', () => { const webClient = makeWebClient(); multisigClientConfig.create.mockResolvedValueOnce(makeMultisig()); - await createGuardianAccount(webClient as never, new Uint8Array(32)); + const result = await createGuardianAccount(webClient as never, new Uint8Array(32)); // When storage yields a URL, create still succeeds — the URL propagation // goes through MultisigClient's constructor which we stubbed, so the // useful signal is that fetchFromStorage was consulted. expect(mockFetchFromStorage).toHaveBeenCalledWith('guardian_url_setting'); + // And the stored URL is returned for per-account persistence. + expect(result.guardianEndpoint).toBe('https://stored.guardian'); }); it('prefers the explicit override over storage and default', async () => { @@ -198,10 +298,16 @@ describe('createGuardianAccount', () => { const webClient = makeWebClient(); multisigClientConfig.create.mockResolvedValueOnce(makeMultisig()); - await createGuardianAccount(webClient as never, new Uint8Array(32), false, 'https://override.guardian'); + const result = await createGuardianAccount( + webClient as never, + new Uint8Array(32), + false, + 'https://override.guardian' + ); // Override short-circuits the storage lookup entirely. expect(mockFetchFromStorage).not.toHaveBeenCalled(); + expect(result.guardianEndpoint).toBe('https://override.guardian'); }); it('wraps underlying errors in a readable message', async () => { @@ -213,3 +319,29 @@ describe('createGuardianAccount', () => { ); }); }); + +describe('resolveGuardianEndpoint', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('prefers the per-account guardianEndpoint when present', async () => { + const endpoint = await resolveGuardianEndpoint({ guardianEndpoint: 'https://per-account.guardian' } as never); + expect(endpoint).toBe('https://per-account.guardian'); + // The per-account field short-circuits the global-key lookup. + expect(mockFetchFromStorage).not.toHaveBeenCalled(); + }); + + it('falls back to the legacy global key when the account has no endpoint', async () => { + mockFetchFromStorage.mockResolvedValueOnce('https://global.guardian'); + const endpoint = await resolveGuardianEndpoint({} as never); + expect(mockFetchFromStorage).toHaveBeenCalledWith('guardian_url_setting'); + expect(endpoint).toBe('https://global.guardian'); + }); + + it('falls back to DEFAULT_GUARDIAN_ENDPOINT when neither field nor global key is set', async () => { + mockFetchFromStorage.mockResolvedValueOnce(undefined); + const endpoint = await resolveGuardianEndpoint({} as never); + expect(endpoint).toBe('https://default.guardian.test'); + }); +}); diff --git a/src/lib/miden/guardian/account.ts b/src/lib/miden/guardian/account.ts index efff5d2ca..8d885f69c 100644 --- a/src/lib/miden/guardian/account.ts +++ b/src/lib/miden/guardian/account.ts @@ -1,11 +1,27 @@ -import { Account, AuthSecretKey, MidenClient } from '@miden-sdk/miden-sdk/lazy'; +import { Account, AuthSecretKey, MidenClient, Word } from '@miden-sdk/miden-sdk/lazy'; import { EcdsaSigner, MultisigClient } from '@openzeppelin/miden-multisig-client'; +import { Buffer } from 'buffer'; -import { getDefaultGuardianEndpoint } from 'lib/miden-chain/constants'; +import { DEFAULT_GUARDIAN_ENDPOINT } from 'lib/miden-chain/constants'; +import * as secureHotKey from 'lib/secure-hot-key'; import { GUARDIAN_URL_STORAGE_KEY } from 'lib/settings/constants'; +import { WalletAccount } from 'lib/shared/types'; import { fetchFromStorage } from '../front/storage'; +/** + * Resolve the guardian operator endpoint for a Guardian account. + * + * Prefers the per-account `guardianEndpoint` (set at create/recovery time and + * on switch-guardian) so accounts on different operators don't collide. Falls + * back to the legacy global `GUARDIAN_URL_STORAGE_KEY` for records created + * before the field existed, then to `DEFAULT_GUARDIAN_ENDPOINT`. + */ +export async function resolveGuardianEndpoint(account: WalletAccount): Promise { + if (account.guardianEndpoint) return account.guardianEndpoint; + return (await fetchFromStorage(GUARDIAN_URL_STORAGE_KEY)) || DEFAULT_GUARDIAN_ENDPOINT; +} + // Re-export the slot names from the package for reading account state export const MULTISIG_SLOT_NAMES = { THRESHOLD_CONFIG: 'openzeppelin::multisig::threshold_config', @@ -15,100 +31,171 @@ export const MULTISIG_SLOT_NAMES = { } as const; /** - * Extract signer commitment and public key from a Guardian account's storage. + * Material the wallet must persist after a Guardian account is created. + * Hot is held outside the SDK keystore (secure-hot-key facade); cold lives + * inside the SDK keystore *and* is mirrored to a separate vault entry so + * role-aware signWord (Phase 3) can route by storage entity. */ -export async function getSignerDetailsFromAccount( - account: Account, - getPublicKeyForCommitment: (commitment: string) => Promise -): Promise<{ commitment: string; publicKey: string }> { - const mapEntries = account.storage().getMapEntries(MULTISIG_SLOT_NAMES.SIGNER_PUBLIC_KEYS); - if (!mapEntries) { - throw new Error('No signer public keys found in account storage'); - } +export interface CreatedGuardianKeys { + hotPublicKey: string; // serialize().slice(1) hex + coldPublicKey: string; // serialize().slice(1) hex + hotCiphertext: string; // opaque blob from the secure-hot-key facade + coldSecretKeyHex: string; // serialized AuthSecretKey hex (for cold-mirror storage) +} - if (!mapEntries[0]) { - throw new Error('No signer commitments found in account storage'); - } +export interface CreatedGuardianAccount { + account: Account; + keys: CreatedGuardianKeys; + // The guardian operator endpoint this account was registered with — persisted + // onto the WalletAccount so runtime reads resolve per-account, not globally. + guardianEndpoint: string; +} - const rawValue = mapEntries[0].value; - const commitment = rawValue.startsWith('0x') ? rawValue.slice(2) : rawValue; +/** + * Signers live in the SIGNER_PUBLIC_KEYS storage map keyed by their index word + * (matching @openzeppelin/miden-multisig-client's `signerMapKey`). The hot + * signer is at index 0, the cold signer at index 1. + */ +const signerMapKey = (index: number): Word => new Word(new BigUint64Array([BigInt(index), 0n, 0n, 0n])); + +/** + * Read a signer's commitment from a Guardian account's storage. + * + * 3-key accounts store `[hot@0, cold@1]`; legacy single-key Guardian accounts + * (feature #153) keep the cold/HD key alone at index 0. So for the cold lookup + * we read index 1 and fall back to index 0 — otherwise activating a migrated + * legacy account would read a non-existent index 1 and brick it. + * + * Commitments MUST be read BY KEY (`getMapItem(signerMapKey(i))`), not by + * `getMapEntries()[i]` array position: getMapEntries returns the storage SMT's + * iteration order (key-hash order), which is NOT the signer-index order, so a + * positional read binds the wrong signer for roughly half of all accounts. The + * SDK's own reader (multisig-client `AccountInspector.fromAccount`) reads by key + * for the same reason. + */ +export async function getSignerDetailsFromAccount(account: Account, getCold = false): Promise<{ commitment: string }> { + const storage = account.storage(); + + const readSigner = (index: number): string | undefined => { + const value = storage.getMapItem(MULTISIG_SLOT_NAMES.SIGNER_PUBLIC_KEYS, signerMapKey(index)); + if (!value) return undefined; + const hex = value.toHex(); + const unprefixed = hex.startsWith('0x') ? hex.slice(2) : hex; + // An absent map entry reads back as the empty word (all zeros) in some SDK + // builds — treat that as "no signer at this index". + return /^0*$/.test(unprefixed) ? undefined : unprefixed; + }; + + const commitment = getCold ? (readSigner(1) ?? readSigner(0)) : readSigner(0); if (!commitment) { - throw new Error('Commitment not found in account storage'); + throw new Error('No signer commitment found in account storage'); } - const publicKey = await getPublicKeyForCommitment(commitment); - return { commitment, publicKey }; + return { commitment }; } /** - * Create a Guardian (Private State Manager) account using the MultisigClient. - * - * This creates a 1-of-1 multisig account with Guardian signature verification enabled. - * The account is registered with the Guardian backend and the secret key is stored locally. + * Create a 3-key Guardian account: a random hot ECDSA key (held outside the + * WASM keystore, behind the secure-hot-key facade), an HD-derived cold ECDSA + * key (held inside the keystore, used for rotation/recovery), and the external + * guardian co-signer. Default threshold 1 — hot OR cold + guardian satisfies + * routine operations; cold-only routing for rotation procedures is enforced + * client-side (see Phase 0 in the migration plan). * - * @param webClient - The Miden WebClient instance - * @param seed - Optional seed for key derivation (random if not provided) - * @param skipRegistration - Skip guardian registration (used by the import path) - * @param guardianEndpointOverride - Force a specific guardian URL for pubkey derivation. - * Account ID is a content hash that includes the guardian pubkey baked into storage, - * so the import flow passes `DEFAULT_GUARDIAN_ENDPOINT` to reproduce the ID the account - * originally had; the user's custom URL is used by `importAccountFromGuardian` for the - * live state fetch only. - * @returns The created Account + * @param webClient - The Miden WebClient instance. + * @param coldSeed - HD-derived seed for the cold key. Random if absent (only + * appropriate for tests / non-recoverable flows). + * @param skipRegistration - Skip guardian registration (used by the import path). + * @param guardianEndpointOverride - Force a specific guardian URL for pubkey + * derivation. Account ID is a content hash that includes the guardian pubkey + * baked into storage, so the import flow passes `DEFAULT_GUARDIAN_ENDPOINT` + * to reproduce the ID the account originally had. */ export async function createGuardianAccount( webClient: MidenClient, - seed?: Uint8Array, + coldSeed?: Uint8Array, skipRegistration: boolean = false, guardianEndpointOverride?: string -): Promise { - if (!seed) { - seed = crypto.getRandomValues(new Uint8Array(32)); +): Promise { + if (!coldSeed) { + coldSeed = crypto.getRandomValues(new Uint8Array(32)); } try { - // Generate the signer secret key from seed - const sk = AuthSecretKey.ecdsaWithRNG(seed); - const signerCommitment = sk.publicKey().toCommitment(); + // Cold key — HD-derived, lives in SDK keystore, used for cold-routed flows + // (rotation, recovery). EcdsaSigner gets the cold AuthSecretKey directly so + // the create-time deploy proposal is signed by cold; the on-chain account + // therefore binds to the cold commitment via the deploy signature in + // addition to the storage-slot binding. + const coldSk = AuthSecretKey.ecdsaWithRNG(coldSeed); + const coldPublicKeyObj = coldSk.publicKey(); + const coldCommitmentHex = coldPublicKeyObj.toCommitment().toHex(); + const coldPublicKey = Buffer.from(coldPublicKeyObj.serialize().slice(1)).toString('hex'); + const coldSecretKeyHex = Buffer.from(coldSk.serialize()).toString('hex'); + + // Hot key — random, held outside the SDK keystore. On extension/desktop + // this is the JS fallback (serialized AuthSecretKey hex); on mobile it is + // wrapped under SE (iOS) or Keystore/StrongBox (Android) inside the native + // plugin and surfaces here only as opaque ciphertext. + const hot = await secureHotKey.generateHotKey(); // Get Guardian endpoint and initialize client const guardianEndpoint = guardianEndpointOverride ?? (await fetchFromStorage(GUARDIAN_URL_STORAGE_KEY)) ?? - getDefaultGuardianEndpoint(); + DEFAULT_GUARDIAN_ENDPOINT; const client = new MultisigClient(webClient, { guardianEndpoint }); - const { commitment, pubkey } = await client.guardianClient.getPubkey('ecdsa'); - // Create the multisig account using the package utility + const { commitment: guardianCommitment, pubkey: guardianPubkey } = await client.guardianClient.getPubkey('ecdsa'); + // Signer order is [hot, cold] by convention — the migration plan diagrams + // and downstream role-routing code assume this layout. const multisig = await client.create( { threshold: 1, - signerCommitments: [signerCommitment.toHex()], - guardianCommitment: commitment, - guardianPublicKey: pubkey, + signerCommitments: [hot.commitmentHex, coldCommitmentHex], + guardianCommitment, + guardianPublicKey: guardianPubkey, guardianEnabled: true, storageMode: 'private', signatureScheme: 'ecdsa', - seed + seed: coldSeed, + procedureThresholds: [ + { + procedure: 'update_guardian', + threshold: 2 + } + ] }, - new EcdsaSigner(sk) + new EcdsaSigner(coldSk) ); if (!skipRegistration) { await multisig.registerOnGuardian(); } - // Sync state with the node await webClient.sync(); - // Store the secret key in WebStore for signing - await webClient.keystore.insert(multisig.account.id(), sk); + // Cold goes through the standard SDK keystore so the WASM client can sign + // with it on demand; the existing insertKeyCallback wraps it under the + // vault key and stores it at accAuthSecretKeyStrgKey(coldPublicKey). + // Hot is intentionally NOT inserted here — vault.ts persists the + // returned hot ciphertext separately under its own envelope. + await webClient.keystore.insert(multisig.account.id(), coldSk); console.log('Guardian account created:', multisig.account.id().toString()); - return multisig.account; + return { + account: multisig.account, + keys: { + hotPublicKey: hot.publicKeyHex, + coldPublicKey, + hotCiphertext: hot.ciphertext, + coldSecretKeyHex + }, + guardianEndpoint + }; } catch (e) { console.error('Error creating Guardian account:', e); // Preserve the original cause so callers can distinguish guardian-unreachable - // from node/registration failures and surface actionable guidance. + // from node/registration/WASM failures. throw new Error('Failed to create Guardian account', { cause: e }); } } diff --git a/src/lib/miden/guardian/index.test.ts b/src/lib/miden/guardian/index.test.ts index 6be79c0c2..785d07029 100644 --- a/src/lib/miden/guardian/index.test.ts +++ b/src/lib/miden/guardian/index.test.ts @@ -40,9 +40,11 @@ jest.mock('../sdk/helpers', () => ({ const mockGetAccount = jest.fn(); const mockSyncState = jest.fn(async () => {}); +const mockRawWebClient = { kind: 'raw-web-client' }; const mockMidenClient = { getAccount: (...args: unknown[]) => mockGetAccount(...args), - syncState: () => mockSyncState() + syncState: () => mockSyncState(), + client: mockRawWebClient }; jest.mock('../sdk/miden-client', () => ({ getMidenClient: async () => mockMidenClient, @@ -72,6 +74,14 @@ const multisigClientConfig: { load: jest.Mock } = { load: jest.fn() }; +const mockBuildUpdateSignersTransactionRequest = jest.fn(async (..._args: unknown[]) => ({ + request: { kind: 'request' }, + salt: { toHex: () => 'salt-hex' } +})); +const mockExecuteForSummary = jest.fn(async (..._args: unknown[]) => ({ + serialize: () => new Uint8Array([0xab]) +})); + jest.mock('@openzeppelin/miden-multisig-client', () => ({ GuardianHttpClient: jest.fn().mockImplementation(() => ({ getPubkey: (...a: unknown[]) => guardianConfig.getPubkey(...a), @@ -80,7 +90,27 @@ jest.mock('@openzeppelin/miden-multisig-client', () => ({ })), MultisigClient: jest.fn().mockImplementation(() => ({ load: (...a: unknown[]) => multisigClientConfig.load(...a) - })) + })), + buildUpdateSignersTransactionRequest: (...a: unknown[]) => mockBuildUpdateSignersTransactionRequest(...a), + executeForSummary: (...a: unknown[]) => mockExecuteForSummary(...a) +})); + +const mockGenerateHotKey = jest.fn(); +const mockSignHotDigest = jest.fn(); +const mockDeleteHotKey = jest.fn(); +jest.mock('lib/secure-hot-key', () => ({ + generateHotKey: (...a: unknown[]) => mockGenerateHotKey(...a), + signHotDigest: (...a: unknown[]) => mockSignHotDigest(...a), + deleteHotKey: (...a: unknown[]) => mockDeleteHotKey(...a) +})); + +const mockGetSignerDetailsFromAccount = jest.fn(); +jest.mock('./account', () => ({ + getSignerDetailsFromAccount: (...a: unknown[]) => mockGetSignerDetailsFromAccount(...a), + // Resolve to the per-account endpoint, falling back to the stored value the + // fetchFromStorage mock returns — mirrors the real resolveGuardianEndpoint. + resolveGuardianEndpoint: async (acc: { guardianEndpoint?: string }) => + acc.guardianEndpoint ?? 'https://stored.guardian.test' })); // atob is globally available on Node 16+ but jsdom stubs can vary — provide @@ -117,11 +147,13 @@ const makeMultisig = (overrides: Partial> = {}) => ({ account: { nonce: () => ({ asInt: () => 5n }) }, + threshold: 1, + getEffectiveThreshold: jest.fn(() => 1), createP2idProposal: jest.fn(async () => ({ kind: 'p2id' })), createConsumeNotesProposal: jest.fn(async () => ({ kind: 'consume' })), - createProposal: jest.fn(async () => ({ kind: 'custom' })), + createProposal: jest.fn(async () => ({ kind: 'custom', id: 'proposal-id' })), createTransactionProposalRequest: jest.fn(async () => 'tx-req'), - signProposal: jest.fn(async () => {}), + signProposal: jest.fn(async () => ({ signatures: [] })), executeProposal: jest.fn(async () => {}), syncState: jest.fn(async () => {}), getConsumableNotes: jest.fn(async () => ['note-a']), @@ -149,6 +181,34 @@ describe('MultisigService', () => { expect(service.accountId).toBe('acc-id'); expect(service.guardianEndpoint).toBe('https://x'); }); + + it('getAuthInfo reports threshold, signer set, and procedure thresholds', () => { + const multisig = makeMultisig({ + threshold: 1, + signerCommitments: ['0xhot', '0xcold'], + procedureThresholds: new Map([['update_guardian', 2]]) + }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + const auth = service.getAuthInfo(); + expect(auth.threshold).toBe(1); + expect(auth.signerCommitments).toEqual(['0xhot', '0xcold']); + expect(auth.procedureThresholds).toEqual({ update_guardian: 2 }); + }); + + it('getAuthInfo degrades gracefully when the multisig lacks fields', () => { + const multisig = makeMultisig(); // no signerCommitments / procedureThresholds + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + const auth = service.getAuthInfo(); + expect(auth.signerCommitments).toEqual([]); + expect(auth.procedureThresholds).toEqual({}); + }); + + it('getProcedureThreshold reads the procedure map', () => { + const multisig = makeMultisig({ procedureThresholds: new Map([['update_guardian', 2]]) }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + expect(service.getProcedureThreshold('update_guardian')).toBe(2); + expect(service.getProcedureThreshold('nope')).toBeUndefined(); + }); }); describe('proposal builders', () => { @@ -171,6 +231,29 @@ describe('MultisigService', () => { expect(multisig.createConsumeNotesProposal).toHaveBeenCalledWith(['n1', 'n2']); expect(proposal).toEqual({ kind: 'consume' }); }); + + it('createUpdateProcedureThresholdProposal forwards the procedure and threshold', async () => { + const createUpdateFn = jest.fn(async () => ({ kind: 'update-threshold' })); + const multisig = makeMultisig({ createUpdateProcedureThresholdProposal: createUpdateFn }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + + const proposal = await service.createUpdateProcedureThresholdProposal('update_guardian', 2); + + expect(createUpdateFn).toHaveBeenCalledWith('update_guardian', 2); + expect(proposal).toEqual({ kind: 'update-threshold' }); + }); + + it('createCustomProposal forwards request bytes and proposal type', async () => { + const createCustomFn = jest.fn(async () => ({ kind: 'custom' })); + const multisig = makeMultisig({ createCustomProposal: createCustomFn }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + const bytes = new Uint8Array([1, 2, 3]); + + const proposal = await service.createCustomProposal(bytes, 'my-type'); + + expect(createCustomFn).toHaveBeenCalledWith(bytes, 'my-type'); + expect(proposal).toEqual({ kind: 'custom' }); + }); }); describe('signing helpers', () => { @@ -198,6 +281,17 @@ describe('MultisigService', () => { expect(tx).toBe('tx-req'); }); + it('signAndCreateTransactionRequest rejects a custom proposal with no request bytes', async () => { + const multisig = makeMultisig({ + signProposal: jest.fn(async () => ({ metadata: { proposalType: 'custom' } })) + }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + + await expect(service.signAndCreateTransactionRequest('p-custom')).rejects.toThrow( + 'Request Bytes are required for custom execution' + ); + }); + it('getConsumableNotes forwards to the wrapped Multisig', async () => { const multisig = makeMultisig(); const service = new MultisigService(multisig as never, {} as never, 'https://x'); @@ -269,6 +363,60 @@ describe('MultisigService', () => { (global as unknown as { setTimeout: typeof setTimeout }).setTimeout = origSetTimeout; } }); + + it('self-heals a lagging guardian: re-registers current state, then the retried sync succeeds', async () => { + // The OZ lib refuses to overwrite local state when the guardian's blob lags + // on-chain; once we push the current state to the guardian, the retry passes. + const syncState = jest + .fn() + .mockRejectedValueOnce( + new Error('Refusing to overwrite local state: incoming commitment does not match on-chain commitment') + ) + .mockResolvedValueOnce(undefined); + const registerOnGuardian = jest.fn(async () => {}); + const multisig = makeMultisig({ syncState, registerOnGuardian }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + mockGetAccount.mockResolvedValue({ serialize: () => new Uint8Array([1, 2, 3]) }); + + await service.sync(); + + expect(registerOnGuardian).toHaveBeenCalledTimes(1); + expect(syncState).toHaveBeenCalledTimes(2); + }); + + it('attempts the guardian realign only once per run, then propagates a persistent failure', async () => { + const syncState = jest.fn(async () => Promise.reject(new Error('Refusing to overwrite local state'))); + const registerOnGuardian = jest.fn(async () => {}); + const multisig = makeMultisig({ syncState, registerOnGuardian }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + mockGetAccount.mockResolvedValue({ serialize: () => new Uint8Array([1, 2, 3]) }); + + await expect(service.sync()).rejects.toThrow('Refusing to overwrite local state'); + expect(registerOnGuardian).toHaveBeenCalledTimes(1); // not looped + expect(syncState).toHaveBeenCalledTimes(2); // initial + one retry after realign + }); + }); + + describe('reRegisterCurrentStateOnGuardian', () => { + it('syncs, serializes the current account, and re-registers it on the guardian', async () => { + const registerOnGuardian = jest.fn(async () => {}); + const multisig = makeMultisig({ registerOnGuardian }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + mockGetAccount.mockResolvedValue({ serialize: () => new Uint8Array([0xaa, 0xbb]) }); + + await service.reRegisterCurrentStateOnGuardian(); + + expect(mockSyncState).toHaveBeenCalled(); + expect(registerOnGuardian).toHaveBeenCalledTimes(1); + }); + + it('throws when the account is missing from the local client', async () => { + const multisig = makeMultisig(); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + mockGetAccount.mockResolvedValue(null); + + await expect(service.reRegisterCurrentStateOnGuardian()).rejects.toThrow('missing from local client'); + }); }); describe('importAccountFromGuardian', () => { @@ -332,34 +480,26 @@ describe('MultisigService', () => { }); describe('init', () => { - it('loads the Multisig for an existing account and returns a configured service', async () => { + it('loads the Multisig for an existing account and binds the passed endpoint', async () => { const account = { id: () => ({ toString: () => 'acc-id' }) } as never; const loaded = makeMultisig(); multisigClientConfig.load.mockResolvedValueOnce(loaded); - const svc = await MultisigService.init(account, 'pub', 'commit', async () => 'sig'); + const svc = await MultisigService.init(account, 'pub', 'commit', async () => 'sig', 'https://acct.guardian'); expect(svc).toBeInstanceOf(MultisigService); expect(svc.multisig).toBe(loaded); + // Endpoint is supplied by the caller (per-account), not read from storage. + expect(svc.guardianEndpoint).toBe('https://acct.guardian'); }); it('re-throws when MultisigClient.load rejects', async () => { const account = { id: () => ({ toString: () => 'acc-id' }) } as never; multisigClientConfig.load.mockRejectedValueOnce(new Error('load failed')); - await expect(MultisigService.init(account, 'pub', 'commit', async () => 'sig')).rejects.toThrow('load failed'); - }); - - it('falls back to DEFAULT_GUARDIAN_ENDPOINT when storage has no URL', async () => { - // Hits the `|| DEFAULT_GUARDIAN_ENDPOINT` branch on the endpoint lookup. - const account = { id: () => ({ toString: () => 'acc-id' }) } as never; - const loaded = makeMultisig(); - multisigClientConfig.load.mockResolvedValueOnce(loaded); - mockFetchFromStorage.mockResolvedValueOnce(undefined); - - const svc = await MultisigService.init(account, 'pub', 'commit', async () => 'sig'); - - expect(svc.guardianEndpoint).toBe('https://default.guardian.test'); + await expect( + MultisigService.init(account, 'pub', 'commit', async () => 'sig', 'https://acct.guardian') + ).rejects.toThrow('load failed'); }); }); @@ -460,6 +600,120 @@ describe('MultisigService', () => { }); }); + describe('signProposal pass-through', () => { + it('forwards signProposal to the wrapped Multisig and does not finalize the proposal', async () => { + const multisig = makeMultisig(); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + + await service.signProposal('p-3'); + + expect(multisig.signProposal).toHaveBeenCalledWith('p-3'); + expect(multisig.executeProposal).not.toHaveBeenCalled(); + expect(multisig.createTransactionProposalRequest).not.toHaveBeenCalled(); + }); + }); + + describe('buildColdMultisigService', () => { + it('reads the cold commitment from on-chain via getSignerDetailsFromAccount(_, true) and inits a service with cold pubkey', async () => { + const account = { id: () => ({ toString: () => 'acc-id' }) } as never; + const walletAccount = { publicKey: 'acc-id', coldPublicKey: 'cold-pub' } as never; + const loaded = makeMultisig(); + multisigClientConfig.load.mockResolvedValueOnce(loaded); + mockGetSignerDetailsFromAccount.mockResolvedValueOnce({ commitment: 'cold-commit-no-prefix' }); + + const signWordFn = jest.fn(async () => 'sig'); + const svc = await MultisigService.buildColdMultisigService(account, walletAccount, signWordFn); + + expect(mockGetSignerDetailsFromAccount).toHaveBeenCalledWith(account, true); + expect(svc).toBeInstanceOf(MultisigService); + // The service initialized via init forwards the COLD pubkey/commitment + // (each prefixed with 0x) to the WalletSigner. We can't introspect that + // directly here, so we assert load was called — proving init proceeded. + expect(multisigClientConfig.load).toHaveBeenCalledWith('acc-id', expect.anything()); + // Endpoint is resolved per-account (here falling back to the stored value). + expect(svc.guardianEndpoint).toBe('https://stored.guardian.test'); + }); + + it('throws when the WalletAccount has no coldPublicKey', async () => { + const account = { id: () => ({ toString: () => 'acc-id' }) } as never; + const walletAccount = { publicKey: 'acc-id' } as never; // missing coldPublicKey + const signWordFn = jest.fn(async () => 'sig'); + + await expect(MultisigService.buildColdMultisigService(account, walletAccount, signWordFn)).rejects.toThrow( + /missing coldPublicKey/ + ); + expect(multisigClientConfig.load).not.toHaveBeenCalled(); + }); + }); + + describe('createReplaceHotKeyProposal', () => { + it('mints a fresh hot key and builds a single-proposal swap with target list [newHot, cold]', async () => { + const multisig = makeMultisig({ threshold: 1 }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + const account = { id: () => ({ toString: () => 'acc-id' }) } as never; + + mockGenerateHotKey.mockResolvedValueOnce({ + ciphertext: 'new-hot-cipher', + publicKeyHex: 'new-hot-pub', + commitmentHex: '0xnewhotcommit' + }); + mockGetSignerDetailsFromAccount.mockResolvedValueOnce({ commitment: 'coldcommitnoprefix' }); + + const result = await service.createReplaceHotKeyProposal(account); + + expect(mockGenerateHotKey).toHaveBeenCalled(); + expect(mockGetSignerDetailsFromAccount).toHaveBeenCalledWith(account, true); + // Order preservation: newHot at index 0, cold at index 1. + expect(mockBuildUpdateSignersTransactionRequest).toHaveBeenCalledWith( + expect.anything(), + 1, + ['0xnewhotcommit', '0xcoldcommitnoprefix'], + { signatureScheme: 'ecdsa' } + ); + expect(mockExecuteForSummary).toHaveBeenCalledWith(expect.anything(), 'acc-id', { kind: 'request' }); + // Proposal label is cosmetic; on-chain effect is dictated by targetSignerCommitments. + expect(multisig.createProposal).toHaveBeenCalledWith( + expect.any(Number), + 'base64-bytes', + expect.objectContaining({ + proposalType: 'add_signer', + targetThreshold: 1, + targetSignerCommitments: ['0xnewhotcommit', '0xcoldcommitnoprefix'], + saltHex: 'salt-hex' + }) + ); + expect(result.newHot).toEqual({ + ciphertext: 'new-hot-cipher', + publicKeyHex: 'new-hot-pub', + commitmentHex: '0xnewhotcommit' + }); + expect(result.proposal).toEqual({ kind: 'custom', id: 'proposal-id' }); + }); + + it('handles secureHotKey commitments without 0x prefix by adding it', async () => { + // Defensive: not all commitment producers may prefix. We normalize. + const multisig = makeMultisig({ threshold: 1 }); + const service = new MultisigService(multisig as never, {} as never, 'https://x'); + const account = { id: () => 'acc-id' } as never; + + mockGenerateHotKey.mockResolvedValueOnce({ + ciphertext: 'cx', + publicKeyHex: 'pk', + commitmentHex: 'newhotnoprefix' // intentionally unprefixed + }); + mockGetSignerDetailsFromAccount.mockResolvedValueOnce({ commitment: 'coldnoprefix' }); + + await service.createReplaceHotKeyProposal(account); + + expect(mockBuildUpdateSignersTransactionRequest).toHaveBeenCalledWith( + expect.anything(), + 1, + ['0xnewhotnoprefix', '0xcoldnoprefix'], + { signatureScheme: 'ecdsa' } + ); + }); + }); + describe('sync de-duplication', () => { it('coalesces overlapping sync() calls onto a single in-flight run', async () => { let resolveSync: () => void = () => {}; diff --git a/src/lib/miden/guardian/index.ts b/src/lib/miden/guardian/index.ts index b26bf696c..930c16935 100644 --- a/src/lib/miden/guardian/index.ts +++ b/src/lib/miden/guardian/index.ts @@ -3,15 +3,21 @@ import { Multisig, MultisigClient, GuardianHttpClient, + buildUpdateSignersTransactionRequest, + executeForSummary, type ProposalMetadata, type TransactionProposal, type Proposal } from '@openzeppelin/miden-multisig-client'; import { DEFAULT_GUARDIAN_ENDPOINT } from 'lib/miden-chain/constants'; +import * as secureHotKey from 'lib/secure-hot-key'; +import type { GeneratedHotKey } from 'lib/secure-hot-key'; import { GUARDIAN_URL_STORAGE_KEY } from 'lib/settings/constants'; import { b64ToU8, u8ToB64 } from 'lib/shared/helpers'; +import type { WalletAccount } from 'lib/shared/types'; +import { getSignerDetailsFromAccount, resolveGuardianEndpoint } from './account'; import { WalletSigner, type SignWordFunction } from './signer'; import { fetchFromStorage } from '../front/storage'; import { accountIdStringToSdk } from '../sdk/helpers'; @@ -47,16 +53,19 @@ export class MultisigService { /** * Initialize a MultisigService for an existing Guardian account. + * + * `guardianEndpoint` is resolved per-account by the caller (see + * `resolveGuardianEndpoint`) so accounts on different operators don't collide. */ static async init( account: Account, publicKey: string, signerCommitment: string, - signWordFn: SignWordFunction + signWordFn: SignWordFunction, + guardianEndpoint: string ): Promise { try { const signer = new WalletSigner(publicKey, signerCommitment, signWordFn); - const guardianEndpoint = (await fetchFromStorage(GUARDIAN_URL_STORAGE_KEY)) || DEFAULT_GUARDIAN_ENDPOINT; // Reuse the shared singleton client instead of spinning up a fresh // WebClient (each new WebClient spawns a ~6MB web-client-methods-worker @@ -77,6 +86,35 @@ export class MultisigService { } } + /** + * Build a transient cold-bound MultisigService for ops that must be cold-signed + * (switch_guardian co-sign and replace_hot_key). The cold commitment is read + * from on-chain storage via getSignerDetailsFromAccount(_, true) — order + * convention `[hot, cold]` is preserved across rotations because + * createReplaceHotKeyProposal uses an in-place swap target list. + * + * Caller is expected to drop the returned service immediately after use so + * cold key material doesn't outlive the operation. + */ + static async buildColdMultisigService( + account: Account, + walletAccount: WalletAccount, + signWordFn: SignWordFunction + ): Promise { + if (!walletAccount.coldPublicKey) { + throw new Error(`Guardian account ${walletAccount.publicKey} is missing coldPublicKey — re-create the wallet`); + } + const { commitment } = await getSignerDetailsFromAccount(account, true); + const guardianEndpoint = await resolveGuardianEndpoint(walletAccount); + return MultisigService.init( + account, + `0x${walletAccount.coldPublicKey}`, + `0x${commitment}`, + signWordFn, + guardianEndpoint + ); + } + static async importAccountFromGuardian( publicKey: string, signerCommitment: string, @@ -134,6 +172,42 @@ export class MultisigService { return withWasmClientLock(() => this.multisig.createConsumeNotesProposal(noteIds)); } + /** Current on-chain threshold for `procedure`, or undefined if none is set. */ + getProcedureThreshold(procedure: string): number | undefined { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (this.multisig as any).procedureThresholds?.get(procedure); + } + + /** + * The account's loaded on-chain auth structure: overall threshold, signer + * commitments, and per-procedure thresholds. Used by the E2E harness to + * assert the 3-key shape (e.g. that `update_guardian` is hardened to 2 and + * the signer set is `[hot, cold]`) — properties the balance-only checks miss. + */ + getAuthInfo(): { threshold: number; signerCommitments: string[]; procedureThresholds: Record } { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const m = this.multisig as any; + const procedureThresholds: Record = {}; + if (m.procedureThresholds instanceof Map) { + for (const [proc, threshold] of m.procedureThresholds.entries()) { + procedureThresholds[String(proc)] = threshold as number; + } + } + return { + threshold: typeof m.threshold === 'number' ? m.threshold : NaN, + signerCommitments: Array.isArray(m.signerCommitments) ? m.signerCommitments.map(String) : [], + procedureThresholds + }; + } + + /** Create a proposal that sets `procedure`'s signature threshold to `threshold`. */ + async createUpdateProcedureThresholdProposal(procedure: string, threshold: number): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return withWasmClientLock(() => + (this.multisig as any).createUpdateProcedureThresholdProposal(procedure, threshold) + ); + } + async signAndExecuteProposal(id: string): Promise { // `signProposal` is signing + guardian HTTP (no shared-client access); only // `executeProposal` touches the WASM client and needs the mutex. @@ -142,13 +216,23 @@ export class MultisigService { } /** - * Create a custom transaction proposal from a TransactionSummary. + * Create a custom transaction proposal from a serialized transaction request. * This is used for 'execute' type transactions. */ async createCustomProposal(requestBytes: Uint8Array, proposalType: string = 'custom transaction'): Promise { return await withWasmClientLock(() => this.multisig.createCustomProposal(requestBytes, proposalType)); } + /** + * Sign a proposal with this service's bound signer. Used by switch_guardian's + * cold co-sign path where cold contributes a signature without driving the + * follow-up createTransactionProposalRequest call (hot does that). + * Sigs accumulate on the Guardian server keyed by proposal id. + */ + async signProposal(id: string): Promise { + await this.multisig.signProposal(id); + } + async signAndCreateTransactionRequest(id: string, requestBytes?: Uint8Array): Promise { const proposal = await this.multisig.signProposal(id); if (proposal.metadata.proposalType === 'custom') { @@ -181,23 +265,54 @@ export class MultisigService { // around each `syncState` attempt and release during the back-off wait so // other client operations can proceed between retries. this.syncRetryCount = 0; + // The guardian-realign self-heal below runs at most once per sync run so a + // genuinely stuck guardian doesn't loop re-registering every tick. + let realignAttempted = false; for (;;) { try { await withWasmClientLock(() => this.multisig.syncState()); this.syncRetryCount = 0; // Reset retry count on successful sync return; } catch (error) { - const isNonceTooLow = - error instanceof Error && error.message.includes('nonce') && error.message.includes('too low'); - if (!isNonceTooLow) { - throw error; // Rethrow if it's a different error + const message = error instanceof Error ? error.message : String(error); + const isNonceTooLow = message.includes('nonce') && message.includes('too low'); + if (isNonceTooLow) { + if (this.syncRetryCount >= MAX_SYNC_RETRIES) { + throw new Error('Max sync retries reached: local state is ahead of on-chain state'); + } + this.syncRetryCount++; + console.warn( + 'Nonce is too low, local state is ahead of on-chain state, retrying sync...', + this.syncRetryCount + ); + await delay(SYNC_RETRY_DELAY_MS); + continue; } - if (this.syncRetryCount >= MAX_SYNC_RETRIES) { - throw new Error('Max sync retries reached: local state is ahead of on-chain state'); + + // `multisig.syncState` refuses to overwrite local state when the guardian's + // stored blob lags the on-chain account ("Refusing to overwrite local + // state ..." / commitment-mismatch). The OZ lib only re-registers + // structural rotations on the guardian for `switch_guardian`; after a + // replace-hot-key (`update_signers`) or `update_procedure_threshold` the + // guardian's blob is never updated, so it diverges from on-chain and this + // throws every ~3s AutoSync tick — forever, until a full reinstall. Self-heal + // once per run: push our current on-chain state up to the guardian (see + // `reRegisterCurrentStateOnGuardian`) so it realigns, then retry the sync. + // Best-effort: if the realign itself fails, fall through to the original error. + if (!realignAttempted) { + realignAttempted = true; + try { + console.warn( + 'Guardian sync failed; realigning guardian to current on-chain state, then retrying:', + message + ); + await this.reRegisterCurrentStateOnGuardian(); + continue; + } catch (realignError) { + console.warn('Guardian re-registration during sync failed (non-fatal):', realignError); + } } - this.syncRetryCount++; - console.warn('Nonce is too low, local state is ahead of on-chain state, retrying sync...', this.syncRetryCount); - await delay(SYNC_RETRY_DELAY_MS); + throw error; // Rethrow if it's a different error (caller logs per-account) } } } @@ -231,6 +346,62 @@ export class MultisigService { } } + /** + * Build a proposal that replaces this account's hot signer in-place. Mints a + * fresh hot key via the secureHotKey facade and constructs an `update_signers` + * proposal whose target list is `[newHotCommit, coldCommit]` (preserving the + * `[hot, cold]` ordering convention so getSignerDetailsFromAccount keeps + * working post-rotation). + * + * Bypasses the SDK's createAddSignerProposal/createRemoveSignerProposal + * convenience wrappers (those compute different target lists). At execution + * time, multisig.ts's buildTransactionRequestFromMetadata treats all three + * `update_signers` variants identically and uses metadata.targetSignerCommitments + * directly — so labeling this as 'add_signer' is cosmetic. + * + * Sign + submit this proposal with a cold-bound MultisigService — replacing + * the hot key cannot itself require the hot key (recovery-friendly). Default + * threshold for update_signers is 1, so cold alone satisfies it. + * + * Caller is responsible for persisting `newHot.ciphertext` BEFORE submitting + * the resulting tx (see initiateReplaceHotKeyTransaction). + */ + async createReplaceHotKeyProposal(account: Account): Promise<{ proposal: Proposal; newHot: GeneratedHotKey }> { + const newHot = await secureHotKey.generateHotKey(); + const { commitment: coldCommitRaw } = await getSignerDetailsFromAccount(account, true); + const ensure0x = (h: string): string => (h.startsWith('0x') ? h : `0x${h}`); + const targetSignerCommitments = [ensure0x(newHot.commitmentHex), ensure0x(coldCommitRaw)]; + const targetThreshold = this.multisig.threshold; + + // Keep getMidenClient() and both WASM ops inside a single lock scope — the + // WASM client is single-threaded, so resolving the client outside the lock + // (or splitting the build/execute into two lock windows) leaves a gap where + // another holder can run and trigger "recursive use ... unsafe aliasing". + const { summaryBase64, saltHex } = await withWasmClientLock(async () => { + const webClient = (await getMidenClient()).client; + const { request, salt } = await buildUpdateSignersTransactionRequest( + webClient, + targetThreshold, + targetSignerCommitments, + { signatureScheme: 'ecdsa' } + ); + const summary = await executeForSummary(webClient, this.accountId, request); + return { summaryBase64: u8ToB64(summary.serialize()), saltHex: salt.toHex() }; + }); + const metadata: ProposalMetadata = { + proposalType: 'add_signer', + targetThreshold, + targetSignerCommitments, + saltHex, + requiredSignatures: this.multisig.getEffectiveThreshold('add_signer'), + description: 'Replace device (hot) signer' + }; + + const proposal = await this.multisig.createProposal(Date.now(), summaryBase64, metadata); + console.log('Created replace-hot-key proposal:', proposal.id); + return { proposal, newHot }; + } + /** * Post-submit finalization for a switch-guardian proposal. Mirrors the * block that upstream's `multisig.executeProposal` runs when it detects @@ -287,6 +458,34 @@ export class MultisigService { } throw new Error('Failed to register account on the new guardian after switching', { cause: lastError }); } + + /** + * Push this account's CURRENT on-chain state to its (unchanged) guardian, so the + * guardian's stored blob tracks structural rotations. + * + * Upstream `multisig.executeProposal` only re-registers the post-execution state + * on the guardian for `switch_guardian` proposals; for `update_signers` + * (replace-hot-key) and `update_procedure_threshold` it submits the tx on-chain + * but never updates the guardian. Without this push, the guardian's `getState` + * keeps serving the pre-rotation blob, so the next `multisig.syncState` sees + * guardian-commitment != on-chain-commitment and throws on + * `ensureSafeToOverwriteLocalState` every ~3s tick — permanently, until a full + * reinstall re-registers the account. Mirrors `finalizeGuardianSwitch`'s + * registration step but keeps the same guardian endpoint. Idempotent: if the + * guardian already has this state, re-registering is a no-op. + */ + async reRegisterCurrentStateOnGuardian(): Promise { + const updatedStateBase64 = await withWasmClientLock(async () => { + const client = await getMidenClient(); + await client.syncState(); + const account = await client.getAccount(this.accountId); + if (!account) { + throw new Error(`Account ${this.accountId} is missing from local client`); + } + return u8ToB64(account.serialize()); + }); + await this.registerOnGuardianWithRetry(updatedStateBase64); + } } // Re-export types that may be needed by consumers diff --git a/src/lib/miden/guardian/signer.test.ts b/src/lib/miden/guardian/signer.test.ts index 95281b17c..02389499a 100644 --- a/src/lib/miden/guardian/signer.test.ts +++ b/src/lib/miden/guardian/signer.test.ts @@ -36,14 +36,15 @@ describe('WalletSigner', () => { expect(signer.scheme).toBe('ecdsa'); }); - it('signAccountIdWithTimestamp hashes and delegates to signWord with commitment stripped of 0x', async () => { + it('signAccountIdWithTimestamp hashes and delegates to signWord with publicKey stripped of 0x', async () => { mockFromAccountIdWithTimestamp.mockReturnValueOnce({ toHex: () => '0xdigest1' }); const sig = await signer.signAccountIdWithTimestamp(accountId, timestamp); expect(mockFromAccountIdWithTimestamp).toHaveBeenCalledWith(accountId, timestamp); - // signWordFn sees the commitment *without* leading 0x — this is what the guardian expects. - expect(signWordFn).toHaveBeenCalledWith('dead', '0xdigest1'); + // signWordFn sees the hot publicKey *without* leading 0x — Vault.signWord + // looks the hot ciphertext up by hotPublicKey in storage, not commitment. + expect(signWordFn).toHaveBeenCalledWith('abc', '0xdigest1'); expect(sig).toBe('0xsig'); }); @@ -54,19 +55,28 @@ describe('WalletSigner', () => { const sig = await signer.signRequest(accountId, timestamp, payload); expect(mockFromRequest).toHaveBeenCalledWith(accountId, timestamp, payload); - expect(signWordFn).toHaveBeenCalledWith('dead', '0xdigest2'); + expect(signWordFn).toHaveBeenCalledWith('abc', '0xdigest2'); expect(sig).toBe('0xsig'); }); it('signCommitment forwards the hex through signWord, adding the 0x prefix when missing', async () => { await signer.signCommitment('cafecafe'); - expect(signWordFn).toHaveBeenCalledWith('dead', '0xcafecafe'); + expect(signWordFn).toHaveBeenCalledWith('abc', '0xcafecafe'); }); it('signCommitment preserves an existing 0x prefix instead of double-prefixing', async () => { await signer.signCommitment('0xcafecafe'); - expect(signWordFn).toHaveBeenCalledWith('dead', '0xcafecafe'); + expect(signWordFn).toHaveBeenCalledWith('abc', '0xcafecafe'); + }); + + it('passes the publicKey verbatim to signWord when it has no 0x prefix', async () => { + const noPrefixSigner = new WalletSigner('beef', commitment, signWordFn); + mockFromAccountIdWithTimestamp.mockReturnValueOnce({ toHex: () => '0xdigest3' }); + + await noPrefixSigner.signAccountIdWithTimestamp(accountId, timestamp); + + expect(signWordFn).toHaveBeenCalledWith('beef', '0xdigest3'); }); }); diff --git a/src/lib/miden/guardian/signer.ts b/src/lib/miden/guardian/signer.ts index 8f1378364..14f2d6d5a 100644 --- a/src/lib/miden/guardian/signer.ts +++ b/src/lib/miden/guardian/signer.ts @@ -8,14 +8,22 @@ export class WalletSigner implements Signer { readonly publicKey: string; // Must match the scheme of the secret key the vault signs with. Guardian // accounts derive their signer key via `AuthSecretKey.ecdsaWithRNG`, so the - // guardian verifies these signatures as ECDSA. - readonly scheme: SignatureScheme = 'ecdsa'; + // guardian verifies these signatures as ECDSA. Configurable so cold/hot paths + // can opt into a different scheme if ever needed; defaults to ECDSA. + readonly scheme: SignatureScheme; private signWordFn: (wordHex: string) => Promise; - constructor(publicKey: string, commitment: string, signWordFn: SignWordFunction) { + constructor(publicKey: string, commitment: string, signWordFn: SignWordFunction, scheme: SignatureScheme = 'ecdsa') { this.publicKey = publicKey; this.commitment = commitment; - this.signWordFn = (wordHex: string) => signWordFn(commitment.slice(2), wordHex); + this.scheme = scheme; + // Vault.signWord looks up the stored hot ciphertext by hotPublicKey (the + // 33-byte compressed pubkey hex), NOT by commitment — see + // accAuthSecretKeyStrgKey(keys.hotPublicKey) in vault.ts persistGuardianKeys. + // The legacy Falcon path keyed storage by commitment, which was equivalent + // for that scheme but broke once Phase 2 standardized on hotPublicKey. + const pubKeyNoPrefix = publicKey.startsWith('0x') ? publicKey.slice(2) : publicKey; + this.signWordFn = (wordHex: string) => signWordFn(pubKeyNoPrefix, wordHex); } async signAccountIdWithTimestamp(accountId: string, timestamp: number): Promise { diff --git a/src/lib/miden/sdk/helpers.test.ts b/src/lib/miden/sdk/helpers.test.ts index 1515f7a69..4dc39dcfb 100644 --- a/src/lib/miden/sdk/helpers.test.ts +++ b/src/lib/miden/sdk/helpers.test.ts @@ -1,11 +1,14 @@ import { Address } from '@miden-sdk/miden-sdk/lazy'; -import { getBech32AddressFromAccountId } from './helpers'; +import { accountIdStringToSdk, getBech32AddressFromAccountId } from './helpers'; jest.mock('@miden-sdk/miden-sdk/lazy', () => ({ Address: { fromAccountId: jest.fn((id: any) => ({ toBech32: () => `bech32-${id}` + })), + fromBech32: jest.fn((str: any) => ({ + accountId: () => `accountId-${str}` })) }, NetworkId: { testnet: jest.fn(() => 'testnet'), devnet: jest.fn(() => 'devnet') } @@ -21,4 +24,10 @@ describe('miden sdk helpers', () => { expect(Address.fromAccountId).toHaveBeenCalledWith('abc', 'BasicWallet'); expect(res).toBe('bech32-abc'); }); + + it('converts a bech32 string back to an AccountId', () => { + const res = accountIdStringToSdk('mtst1qabc'); + expect(Address.fromBech32).toHaveBeenCalledWith('mtst1qabc'); + expect(res).toBe('accountId-mtst1qabc'); + }); }); diff --git a/src/lib/miden/sdk/miden-client-interface.test.ts b/src/lib/miden/sdk/miden-client-interface.test.ts index 7dd084a1c..814e24a7d 100644 --- a/src/lib/miden/sdk/miden-client-interface.test.ts +++ b/src/lib/miden/sdk/miden-client-interface.test.ts @@ -500,10 +500,17 @@ describe('MidenClientInterface', () => { ); }); - it('createMidenWallet routes a Guardian wallet type to createGuardianAccount', async () => { + it('createGuardianMidenWallet returns accountId + hot/cold key material', async () => { const fakeMidenClient = buildFakeMidenClient(); + const keys = { + hotPublicKey: 'hot-pub', + coldPublicKey: 'cold-pub', + hotCiphertext: 'hot-ct', + coldSecretKeyHex: 'cold-sk' + }; const createGuardianAccount = jest.fn(async () => ({ - id: () => ({ toString: () => 'guardian-id' }) + account: { id: () => ({ toString: () => 'guardian-id' }) }, + keys })); jest.doMock('./helpers', () => ({ @@ -523,10 +530,10 @@ describe('MidenClientInterface', () => { const { MidenClientInterface } = await import('./miden-client-interface'); const client = MidenClientInterface.fromClient(fakeMidenClient as any, 'testnet'); - const result = await client.createMidenWallet('guardian' as any, new Uint8Array([9])); + const result = await client.createGuardianMidenWallet(new Uint8Array([9])); expect(createGuardianAccount).toHaveBeenCalledWith(fakeMidenClient, expect.any(Uint8Array)); - expect(result).toBe('guardian-id'); + expect(result).toEqual({ accountId: 'guardian-id', keys }); }); it('getInputNote delegates to client.notes.get and returns its result', async () => { @@ -545,7 +552,7 @@ describe('MidenClientInterface', () => { }); describe('importAccountBySeed', () => { - it('falls through to importPublicMidenWalletFromSeed for non-Guardian accounts', async () => { + it('delegates to importPublicMidenWalletFromSeed', async () => { const fakeMidenClient = buildFakeMidenClient({ accounts: { import: jest.fn(async () => ({ id: () => 'public-acc-id' })) @@ -565,113 +572,20 @@ describe('MidenClientInterface', () => { const { MidenClientInterface } = await import('./miden-client-interface'); const client = MidenClientInterface.fromClient(fakeMidenClient as any, 'testnet'); - const result = await client.importAccountBySeed( - 'on-chain' as any, - new Uint8Array([1, 2, 3]), - jest.fn(async () => '0xsig'), - jest.fn(async () => 'pk') - ); + const result = await client.importAccountBySeed(new Uint8Array([1, 2, 3])); expect(result).toBe('public-acc-id'); expect(fakeMidenClient.accounts.import).toHaveBeenCalledWith({ seed: expect.any(Uint8Array) }); }); - it('Guardian path: creates the account locally and re-hydrates state from the guardian', async () => { - const fakeMidenClient = buildFakeMidenClient(); - const createGuardianAccount = jest.fn(async () => ({ - id: () => ({ toString: () => 'guardian-acc-id' }) - })); - const getSignerDetailsFromAccount = jest.fn(async () => ({ commitment: 'abc', publicKey: 'def' })); - const importAccountFromGuardian = jest.fn(async () => {}); - - jest.doMock('./helpers', () => ({ - getBech32AddressFromAccountId: (id: any) => (typeof id === 'function' ? id().toString() : String(id)) - })); - jest.doMock('screens/onboarding/types', () => ({ - WalletType: { OnChain: 'on-chain', OffChain: 'off-chain', Guardian: 'guardian' } - })); - jest.doMock('../guardian/account', () => ({ - createGuardianAccount, - getSignerDetailsFromAccount - })); - jest.doMock('../guardian/index', () => ({ - MultisigService: { importAccountFromGuardian } - })); - jest.doMock('lib/miden-chain/constants', () => ({ - DEFAULT_GUARDIAN_ENDPOINT: 'https://default.guardian.test', - getDefaultGuardianEndpoint: () => 'https://default.guardian.test' - })); - jest.doMock('lib/miden/activity/connectivity-issues', () => ({ - addConnectivityIssue: jest.fn() - })); - - const { MidenClientInterface } = await import('./miden-client-interface'); - const client = MidenClientInterface.fromClient(fakeMidenClient as any, 'testnet'); - - const signWordFn = jest.fn(async () => '0xsig'); - const getPublicKeyForCommitment = jest.fn(async () => 'pk'); - const result = await client.importAccountBySeed( - 'guardian' as any, - new Uint8Array([1, 2, 3, 4]), - signWordFn, - getPublicKeyForCommitment - ); - - expect(createGuardianAccount).toHaveBeenCalledWith( - fakeMidenClient, - expect.any(Uint8Array), - true, - 'https://default.guardian.test' - ); - expect(getSignerDetailsFromAccount).toHaveBeenCalled(); - expect(importAccountFromGuardian).toHaveBeenCalledWith( - '0xdef', - '0xabc', - signWordFn, - 'guardian-acc-id', - fakeMidenClient - ); - expect(result).toBe('guardian-acc-id'); - }); - - it('Guardian path: wraps underlying errors in a "Failed to import Guardian account from seed" message', async () => { - const fakeMidenClient = buildFakeMidenClient(); - - jest.doMock('./helpers', () => ({ - getBech32AddressFromAccountId: (id: any) => String(id) - })); - jest.doMock('screens/onboarding/types', () => ({ - WalletType: { OnChain: 'on-chain', OffChain: 'off-chain', Guardian: 'guardian' } - })); - jest.doMock('../guardian/account', () => ({ - createGuardianAccount: jest.fn(async () => { - throw new Error('guardian down'); - }), - getSignerDetailsFromAccount: jest.fn() - })); - jest.doMock('../guardian/index', () => ({ - MultisigService: { importAccountFromGuardian: jest.fn() } - })); - jest.doMock('lib/miden-chain/constants', () => ({ - DEFAULT_GUARDIAN_ENDPOINT: 'https://default.guardian.test', - getDefaultGuardianEndpoint: () => 'https://default.guardian.test' - })); - jest.doMock('lib/miden/activity/connectivity-issues', () => ({ - addConnectivityIssue: jest.fn() - })); - - const { MidenClientInterface } = await import('./miden-client-interface'); - const client = MidenClientInterface.fromClient(fakeMidenClient as any, 'testnet'); - - await expect( - client.importAccountBySeed( - 'guardian' as any, - new Uint8Array([1]), - jest.fn(async () => '0xsig'), - jest.fn(async () => 'pk') - ) - ).rejects.toThrow('Failed to import Guardian account from seed'); - }); + // importGuardianAccountBySeed was removed in Phase 8 — replaced by the + // atomic recoverGuardianAccountsBySeed orchestrator that does lookup + + // adopt + cold-signed `replace_signer` rotation in one step. The + // orchestrator's end-to-end behavior depends on the guardian SDK, + // secureHotKey facade, and on-chain proving — covered by the manual + // devnet smoke in the Phase 8 plan rather than a fully-mocked unit + // test. Phase 9 will add comprehensive coverage with a faked + // MultisigClient. }); it('recordProveTiming swallows globalThis.__PROVE_TIMINGS__ push errors silently', async () => { diff --git a/src/lib/miden/sdk/miden-client-interface.ts b/src/lib/miden/sdk/miden-client-interface.ts index 886ea6d40..e160c6fad 100644 --- a/src/lib/miden/sdk/miden-client-interface.ts +++ b/src/lib/miden/sdk/miden-client-interface.ts @@ -1,6 +1,7 @@ import { Account, AccountFile, + AuthSecretKey, exportStore, getWasmOrThrow, importStore, @@ -17,6 +18,7 @@ import { TransactionRequest, TransactionResult } from '@miden-sdk/miden-sdk/lazy'; +import { Buffer } from 'buffer'; import { isLikelyNetworkError } from 'lib/miden/activity/connectivity-classify'; import { clearConnectivityIssue, markConnectivityIssue } from 'lib/miden/activity/connectivity-state'; @@ -41,7 +43,39 @@ import { ConsumeTransaction, SendTransaction } from '../db/types'; // a module init cycle: miden-client-interface → guardian/index → sdk/miden-client → // miden-client-interface. Static imports here deadlock init_guardian_manager in the // SW bundle (both sides' __esmMin wrappers await each other). -import type { SignWordFunction } from '../guardian/signer'; +import type { CreatedGuardianKeys } from '../guardian/account'; + +export interface GuardianAccountCreationResult { + accountId: string; + keys: CreatedGuardianKeys; + // Guardian operator endpoint the account was registered with — persisted onto + // the WalletAccount so runtime endpoint resolution is per-account. + guardianEndpoint: string; +} + +/** + * One Guardian account discovered + adopted via lookup-based recovery. The + * orchestrator does NOT rotate the hot signer at recovery time — the on-chain + * hot pubkey's secret is unrecoverable, but the wallet defers replacement + * until the user explicitly opts in (via the post-recovery banner on the + * home view). Vault.spawn persists `coldSecretKeyHex` under + * `accColdSecretKeyStrgKey(coldPublicKey)` and writes the WalletAccount + * with `requiresHotKeyRotation: true` and no `hotPublicKey` — the rotation + * flow (initiateReplaceHotKeyTransaction) generates the fresh hot key when + * the user clicks the banner. + */ +export interface RecoveredGuardianAccount { + accountId: string; + hdIndex: number; + coldPublicKey: string; + coldSecretKeyHex: string; +} + +const MAX_RECOVERY_HD_INDEX = 20; +// Tolerate a few consecutive empty HD indices before concluding there are no +// more accounts — handles a non-contiguous index set or a transient empty +// guardian response, matching BIP-44 wallet gap-limit conventions. +const RECOVERY_GAP_LIMIT = 3; // E2E-build only. The per-step prove-timing markers are useful for the // Playwright harness (it polls __PROVE_TIMINGS__ to drive its step @@ -211,7 +245,7 @@ export class MidenClientInterface { async createMidenWallet(walletType: WalletType, seed?: Uint8Array, auth?: AuthScheme): Promise { if (walletType === WalletType.Guardian) { const { createGuardianAccount } = await import('../guardian/account'); - const account = await createGuardianAccount(this.client, seed); + const { account } = await createGuardianAccount(this.client, seed); return getBech32AddressFromAccountId(account.id()); } @@ -227,6 +261,17 @@ export class MidenClientInterface { return getBech32AddressFromAccountId(wallet.id()); } + /** + * Create a 3-key Guardian account. Returns the account ID alongside the hot + * ciphertext + cold secret-key bytes the wallet must persist (vault wraps + * both before writing them to storage). + */ + async createGuardianMidenWallet(coldSeed?: Uint8Array): Promise { + const { createGuardianAccount } = await import('../guardian/account'); + const { account, keys, guardianEndpoint } = await createGuardianAccount(this.client, coldSeed); + return { accountId: getBech32AddressFromAccountId(account.id()), keys, guardianEndpoint }; + } + async importMidenWallet(accountBytes: Uint8Array): Promise { const accountFile = AccountFile.deserialize(accountBytes); const wallet: Account = await this.client.accounts.import({ file: accountFile }); @@ -247,46 +292,91 @@ export class MidenClientInterface { return getBech32AddressFromAccountId(account.id()); } - async importAccountBySeed( - walletType: WalletType, - seed: Uint8Array, - signWordFn: SignWordFunction, - getPublicKeyForCommitment: (commitment: string) => Promise - ): Promise { - if (walletType === WalletType.Guardian) { - try { - const [ - { createGuardianAccount, getSignerDetailsFromAccount }, - { MultisigService }, - { getDefaultGuardianEndpoint } - ] = await Promise.all([ - import('../guardian/account'), - import('../guardian/index'), - import('lib/miden-chain/constants') - ]); - // Derive the account ID against the default guardian so it matches the ID - // the account had at creation time. The user's custom guardian URL (persisted - // in GUARDIAN_URL_STORAGE_KEY) is picked up later by importAccountFromGuardian for the - // live state fetch. - const account = await createGuardianAccount(this.client, seed, true, getDefaultGuardianEndpoint()); - console.log('[MidenClientInterface] Imported Guardian account from seed with ID:', account.id().toString()); - const accountId = account.id().toString(); - const { commitment, publicKey } = await getSignerDetailsFromAccount(account, getPublicKeyForCommitment); - await MultisigService.importAccountFromGuardian( - `0x${publicKey}`, - `0x${commitment}`, - signWordFn, - accountId, - this.client - ); - return getBech32AddressFromAccountId(account.id()); - } catch (error) { - console.error('Failed to import Guardian account from seed', error); - throw new Error('Failed to import Guardian account from seed', { cause: error }); + async importAccountBySeed(seed: Uint8Array): Promise { + return await this.importPublicMidenWalletFromSeed(seed); + } + + /** + * Discover and adopt all Guardian accounts authorized by the cold keys + * derived from `mnemonic` against `guardianEndpoint`. Iterates HD indices + * 0..MAX-1 and stops after RECOVERY_GAP_LIMIT consecutive empty indices (so a + * small gap doesn't silently drop later accounts). + * + * Each match is adopted locally only: the on-chain Account state is + * decoded and inserted into the WASM client + the cold key registered in + * the keystore. The hot signer is NOT rotated here — the on-chain hot + * pubkey's secret is unrecoverable, but rotation is deferred to a + * user-triggered banner action on the home view (initiateReplaceHotKey). + * The persisted WalletAccount is flagged `requiresHotKeyRotation: true` + * and carries no `hotPublicKey` until the rotation completes. + * + * The orchestrator acquires the WASM client mutex granularly per op, so + * callers must NOT hold the outer lock. + * + * @param deriveColdSeed - Sync closure returning the HD-derived cold seed + * for a given index. Supplied by Vault.spawn so the BIP-39 / HD-path + * logic stays out of this module (avoids a vault → miden-client-interface + * import cycle). + * @param guardianEndpoint - Operator the lookup is scoped to. Must match + * the endpoint the account was originally registered with — account IDs + * are content-hash bound to the guardian pubkey baked into storage. + */ + async recoverGuardianAccountsBySeed( + deriveColdSeed: (hdIndex: number) => Uint8Array, + guardianEndpoint: string + ): Promise { + const [{ withWasmClientLock }, { MultisigClient, EcdsaSigner }] = await Promise.all([ + import('../sdk/miden-client'), + import('@openzeppelin/miden-multisig-client') + ]); + + const recovered: RecoveredGuardianAccount[] = []; + let consecutiveMisses = 0; + + for (let hdIndex = 0; hdIndex < MAX_RECOVERY_HD_INDEX; hdIndex++) { + const coldSeed = deriveColdSeed(hdIndex); + const coldSk = AuthSecretKey.ecdsaWithRNG(coldSeed); + const coldPublicKey = Buffer.from(coldSk.publicKey().serialize().slice(1)).toString('hex'); + const coldSecretKeyHex = Buffer.from(coldSk.serialize()).toString('hex'); + + const lookupClient = new MultisigClient(this.client, { guardianEndpoint }); + const lookupSigner = new EcdsaSigner(coldSk); + const matches = await lookupClient.recoverByKey(lookupSigner); + + if (matches.length === 0) { + // Tolerate a small gap before giving up, so a non-contiguous index or a + // transient empty guardian response doesn't silently drop later accounts. + consecutiveMisses++; + if (consecutiveMisses >= RECOVERY_GAP_LIMIT) break; + continue; + } + consecutiveMisses = 0; + + for (const { state } of matches) { + // Decode the on-chain account state and adopt it locally so subsequent + // SDK calls (.load, executeForSummary) can resolve the account. + const accountBytes = new Uint8Array(Buffer.from(state.stateJson.data, 'base64')); + const bech32 = await withWasmClientLock(async () => { + const acc = Account.deserialize(accountBytes); + await this.client.accounts.insert({ account: acc, overwrite: true }); + await this.client.keystore.insert(acc.id(), coldSk); + return getBech32AddressFromAccountId(acc.id()); + }); + + recovered.push({ + accountId: bech32, + hdIndex, + coldPublicKey, + coldSecretKeyHex + }); } } - return await this.importPublicMidenWalletFromSeed(seed); + if (recovered.length === 0) { + throw new Error('No Guardian accounts found at this guardian endpoint for this seed'); + } + + return recovered; } /** diff --git a/src/lib/secure-hot-key/hotKeyPlugin.test.ts b/src/lib/secure-hot-key/hotKeyPlugin.test.ts new file mode 100644 index 000000000..96f2cbc3c --- /dev/null +++ b/src/lib/secure-hot-key/hotKeyPlugin.test.ts @@ -0,0 +1,20 @@ +const mockPlugin = { + generateHotKey: jest.fn(), + signWithHotKey: jest.fn(), + deleteHotKey: jest.fn(), + revealHotKey: jest.fn() +}; +const mockRegisterPlugin = jest.fn((_name: string) => mockPlugin); + +jest.mock('@capacitor/core', () => ({ + registerPlugin: (name: string) => mockRegisterPlugin(name) +})); + +describe('secure-hot-key hotKeyPlugin', () => { + it('registers the Capacitor HotKey plugin handle', async () => { + const { HotKey } = await import('./hotKeyPlugin'); + + expect(mockRegisterPlugin).toHaveBeenCalledWith('HotKey'); + expect(HotKey).toBe(mockPlugin); + }); +}); diff --git a/src/lib/secure-hot-key/hotKeyPlugin.ts b/src/lib/secure-hot-key/hotKeyPlugin.ts new file mode 100644 index 000000000..0bf9575da --- /dev/null +++ b/src/lib/secure-hot-key/hotKeyPlugin.ts @@ -0,0 +1,27 @@ +/** + * Capacitor handle for the per-account Guardian "hot" signing key. + * iOS implementation lives in ios/App/App/HotKeyPlugin.swift; Android lands + * in Phase 4b and will register the same `HotKey` jsName so this interface + * stays platform-shared. + * + * Native side ECIES-wraps a fresh secp256k1 secret under a per-account + * Secure Enclave / StrongBox P-256 key; the resulting ciphertext embeds the + * key tag so signWithHotKey can look it up without an extra arg. + */ + +import { registerPlugin } from '@capacitor/core'; + +export interface HotKeyPlugin { + generateHotKey(): Promise<{ ciphertext: string; publicKeyHex: string }>; + + signWithHotKey(options: { ciphertext: string; digestHex: string }): Promise<{ signatureHex: string }>; + + deleteHotKey(options: { ciphertext: string }): Promise; + + // Unwraps the SE/StrongBox-wrapped secret and returns the raw 32-byte + // secp256k1 secret hex. Triggers a biometric prompt on both platforms, + // identical UX to `signWithHotKey`. Used by Settings → Reveal Hot Key. + revealHotKey(options: { ciphertext: string }): Promise<{ secretKeyHex: string }>; +} + +export const HotKey = registerPlugin('HotKey'); diff --git a/src/lib/secure-hot-key/index.test.ts b/src/lib/secure-hot-key/index.test.ts new file mode 100644 index 000000000..e4209ed61 --- /dev/null +++ b/src/lib/secure-hot-key/index.test.ts @@ -0,0 +1,94 @@ +import * as secureHotKey from './index'; + +const mockIsMobile = jest.fn(); +jest.mock('lib/platform', () => ({ + isMobile: () => mockIsMobile() +})); + +const mockJsGenerateHotKey = jest.fn(); +const mockJsSignHotDigest = jest.fn(); +const mockJsDeleteHotKey = jest.fn(); +const mockJsRevealHotKey = jest.fn(); +jest.mock('./jsFallback', () => ({ + generateHotKey: () => mockJsGenerateHotKey(), + signHotDigest: (ciphertext: string, wordHex: string) => mockJsSignHotDigest(ciphertext, wordHex), + deleteHotKey: (ciphertext: string) => mockJsDeleteHotKey(ciphertext), + revealHotKey: (ciphertext: string) => mockJsRevealHotKey(ciphertext) +})); + +const mockNativeGenerateHotKey = jest.fn(); +const mockNativeSignHotDigest = jest.fn(); +const mockNativeDeleteHotKey = jest.fn(); +const mockNativeRevealHotKey = jest.fn(); +jest.mock('./nativePlugin', () => ({ + generateHotKey: () => mockNativeGenerateHotKey(), + signHotDigest: (ciphertext: string, wordHex: string) => mockNativeSignHotDigest(ciphertext, wordHex), + deleteHotKey: (ciphertext: string) => mockNativeDeleteHotKey(ciphertext), + revealHotKey: (ciphertext: string) => mockNativeRevealHotKey(ciphertext) +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('secure-hot-key facade', () => { + it('routes every operation to the JS fallback off mobile', async () => { + mockIsMobile.mockReturnValue(false); + mockJsGenerateHotKey.mockResolvedValue({ + ciphertext: 'js-ciphertext', + publicKeyHex: 'js-public', + commitmentHex: 'js-commitment' + }); + mockJsSignHotDigest.mockResolvedValue('0xjs-signature'); + mockJsDeleteHotKey.mockResolvedValue(undefined); + mockJsRevealHotKey.mockResolvedValue('js-secret'); + + await expect(secureHotKey.generateHotKey()).resolves.toEqual({ + ciphertext: 'js-ciphertext', + publicKeyHex: 'js-public', + commitmentHex: 'js-commitment' + }); + await expect(secureHotKey.signHotDigest('js-ciphertext', '0xword')).resolves.toBe('0xjs-signature'); + await expect(secureHotKey.deleteHotKey('js-ciphertext')).resolves.toBeUndefined(); + await expect(secureHotKey.revealHotKey('js-ciphertext')).resolves.toBe('js-secret'); + + expect(mockJsGenerateHotKey).toHaveBeenCalledTimes(1); + expect(mockJsSignHotDigest).toHaveBeenCalledWith('js-ciphertext', '0xword'); + expect(mockJsDeleteHotKey).toHaveBeenCalledWith('js-ciphertext'); + expect(mockJsRevealHotKey).toHaveBeenCalledWith('js-ciphertext'); + expect(mockNativeGenerateHotKey).not.toHaveBeenCalled(); + expect(mockNativeSignHotDigest).not.toHaveBeenCalled(); + expect(mockNativeDeleteHotKey).not.toHaveBeenCalled(); + expect(mockNativeRevealHotKey).not.toHaveBeenCalled(); + }); + + it('routes every operation to the native plugin on mobile', async () => { + mockIsMobile.mockReturnValue(true); + mockNativeGenerateHotKey.mockResolvedValue({ + ciphertext: 'native-ciphertext', + publicKeyHex: 'native-public', + commitmentHex: 'native-commitment' + }); + mockNativeSignHotDigest.mockResolvedValue('0xnative-signature'); + mockNativeDeleteHotKey.mockResolvedValue(undefined); + mockNativeRevealHotKey.mockResolvedValue('native-secret'); + + await expect(secureHotKey.generateHotKey()).resolves.toEqual({ + ciphertext: 'native-ciphertext', + publicKeyHex: 'native-public', + commitmentHex: 'native-commitment' + }); + await expect(secureHotKey.signHotDigest('native-ciphertext', '0xword')).resolves.toBe('0xnative-signature'); + await expect(secureHotKey.deleteHotKey('native-ciphertext')).resolves.toBeUndefined(); + await expect(secureHotKey.revealHotKey('native-ciphertext')).resolves.toBe('native-secret'); + + expect(mockNativeGenerateHotKey).toHaveBeenCalledTimes(1); + expect(mockNativeSignHotDigest).toHaveBeenCalledWith('native-ciphertext', '0xword'); + expect(mockNativeDeleteHotKey).toHaveBeenCalledWith('native-ciphertext'); + expect(mockNativeRevealHotKey).toHaveBeenCalledWith('native-ciphertext'); + expect(mockJsGenerateHotKey).not.toHaveBeenCalled(); + expect(mockJsSignHotDigest).not.toHaveBeenCalled(); + expect(mockJsDeleteHotKey).not.toHaveBeenCalled(); + expect(mockJsRevealHotKey).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/secure-hot-key/index.ts b/src/lib/secure-hot-key/index.ts new file mode 100644 index 000000000..7c111d797 --- /dev/null +++ b/src/lib/secure-hot-key/index.ts @@ -0,0 +1,47 @@ +/** + * Platform-abstraction facade for the Guardian "hot" signing key. + * + * Hot keys live outside the WASM keystore. On mobile, they are wrapped under + * a per-account hardware-backed key (iOS Secure Enclave via ECIES, Android + * Keystore/StrongBox via RSA-OAEP) and unwrapped only inside a native plugin + * during a biometric prompt. On extension and desktop, the JS fallback + * serializes an `AuthSecretKey.ecdsaWithRNG(...)` blob and relies on the + * surrounding vault envelope for at-rest protection. + * + * Callers should never need to know which path executed: all three operations + * take/return strings. + */ + +import { isMobile } from 'lib/platform'; + +import * as jsFallback from './jsFallback'; +import * as nativePlugin from './nativePlugin'; + +export type { GeneratedHotKey } from './jsFallback'; + +function impl() { + return isMobile() ? nativePlugin : jsFallback; +} + +export async function generateHotKey() { + return impl().generateHotKey(); +} + +export async function signHotDigest(ciphertext: string, wordHex: string): Promise { + return impl().signHotDigest(ciphertext, wordHex); +} + +export async function deleteHotKey(ciphertext: string): Promise { + return impl().deleteHotKey(ciphertext); +} + +/** + * Unwrap the hot ciphertext and return the raw 32-byte secp256k1 secret hex. + * On mobile this fires a biometric prompt (same SE/StrongBox unwrap path as + * `signHotDigest`, minus the actual signing step). On extension/desktop the + * JS fallback decodes the serialized `AuthSecretKey` and strips the 1-byte + * scheme prefix so the format matches the native return. + */ +export async function revealHotKey(ciphertext: string): Promise { + return impl().revealHotKey(ciphertext); +} diff --git a/src/lib/secure-hot-key/jsFallback.test.ts b/src/lib/secure-hot-key/jsFallback.test.ts new file mode 100644 index 000000000..6953adc7d --- /dev/null +++ b/src/lib/secure-hot-key/jsFallback.test.ts @@ -0,0 +1,85 @@ +import * as jsFallback from './jsFallback'; + +const mockEcdsaWithRNG = jest.fn(); +const mockDeserialize = jest.fn(); +const mockWordFromHex = jest.fn(); +jest.mock('@miden-sdk/miden-sdk/lazy', () => ({ + AuthSecretKey: { + ecdsaWithRNG: (seed: Uint8Array) => mockEcdsaWithRNG(seed), + deserialize: (bytes: Uint8Array) => mockDeserialize(bytes) + }, + Word: { + fromHex: (hex: string) => mockWordFromHex(hex) + } +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('secure-hot-key jsFallback', () => { + it('generates an ECDSA hot key and returns ciphertext, public key, and commitment hex', async () => { + const randomSpy = jest.spyOn(crypto, 'getRandomValues').mockImplementation(array => { + (array as Uint8Array).fill(0x7a); + return array; + }); + const publicKey = { + serialize: jest.fn(() => new Uint8Array([0x01, 0x02, ...new Array(32).fill(0xab)])), + toCommitment: jest.fn(() => ({ toHex: () => '0xcommitment' })) + }; + const sk = { + serialize: jest.fn(() => new Uint8Array([0x01, ...new Array(32).fill(0xcd)])), + publicKey: jest.fn(() => publicKey) + }; + mockEcdsaWithRNG.mockReturnValue(sk); + + try { + const generated = await jsFallback.generateHotKey(); + + expect(mockEcdsaWithRNG).toHaveBeenCalledWith(new Uint8Array(32).fill(0x7a)); + expect(generated).toEqual({ + ciphertext: `01${'cd'.repeat(32)}`, + publicKeyHex: `02${'ab'.repeat(32)}`, + commitmentHex: '0xcommitment' + }); + expect(sk.publicKey).toHaveBeenCalledTimes(1); + expect(publicKey.toCommitment).toHaveBeenCalledTimes(1); + } finally { + randomSpy.mockRestore(); + } + }); + + it('signs a word using a serialized AuthSecretKey ciphertext', async () => { + const word = { tag: 'word' }; + const signature = { + serialize: jest.fn(() => new Uint8Array([0x01, 0x11, 0x22, 0x33])) + }; + const sk = { + sign: jest.fn(() => signature) + }; + mockDeserialize.mockReturnValue(sk); + mockWordFromHex.mockReturnValue(word); + + const signatureHex = await jsFallback.signHotDigest('0a0b0c', '0xword'); + + expect(mockDeserialize).toHaveBeenCalledWith(new Uint8Array([0x0a, 0x0b, 0x0c])); + expect(mockWordFromHex).toHaveBeenCalledWith('0xword'); + expect(sk.sign).toHaveBeenCalledWith(word); + expect(signatureHex).toBe('0x112233'); + }); + + it('deleteHotKey is a no-op for the JS fallback', async () => { + await expect(jsFallback.deleteHotKey('serialized-key')).resolves.toBeUndefined(); + }); + + it('reveals the raw secret key bytes without the serialized scheme prefix', async () => { + mockDeserialize.mockReturnValue({ + serialize: jest.fn(() => new Uint8Array([0x01, ...new Array(32).fill(0xef)])) + }); + + const secret = await jsFallback.revealHotKey('010203'); + + expect(mockDeserialize).toHaveBeenCalledWith(new Uint8Array([0x01, 0x02, 0x03])); + expect(secret).toBe('ef'.repeat(32)); + }); +}); diff --git a/src/lib/secure-hot-key/jsFallback.ts b/src/lib/secure-hot-key/jsFallback.ts new file mode 100644 index 000000000..66aa91eee --- /dev/null +++ b/src/lib/secure-hot-key/jsFallback.ts @@ -0,0 +1,57 @@ +/** + * JS fallback for the secure-hot-key facade. Used by extension and desktop + * (Tauri reuses this for now; desktop SE work deferred per Phase 4 scope). + * + * In this fallback the "ciphertext" is the serialized AuthSecretKey blob — + * the per-key wrap that mobile gets via Secure Enclave / StrongBox is replaced + * by the vault-key envelope the caller applies on top before persisting. This + * means hot-key isolation on extension is only as strong as the vault password + * (acknowledged in the migration plan, Risks §6). + */ + +import { AuthSecretKey } from '@miden-sdk/miden-sdk/lazy'; +import { Buffer } from 'buffer'; + +export type GeneratedHotKey = { + ciphertext: string; + // SDK serialize().slice(1) form — matches the storage-key convention used by + // the rest of the wallet so vault.signWord can lookup by this hex directly. + publicKeyHex: string; + // Multisig commitment (toCommitment().toHex()) — what MultisigClient.create + // expects in `signerCommitments`. + commitmentHex: string; +}; + +export async function generateHotKey(): Promise { + const rawSeed = crypto.getRandomValues(new Uint8Array(32)); + const sk = AuthSecretKey.ecdsaWithRNG(rawSeed); + const ciphertext = Buffer.from(sk.serialize()).toString('hex'); + const publicKey = sk.publicKey(); + const publicKeyHex = Buffer.from(publicKey.serialize().slice(1)).toString('hex'); + const commitmentHex = publicKey.toCommitment().toHex(); + return { ciphertext, publicKeyHex, commitmentHex }; +} + +export async function signHotDigest(ciphertext: string, wordHex: string): Promise { + const { Word } = await import('@miden-sdk/miden-sdk/lazy'); + const sk = AuthSecretKey.deserialize(new Uint8Array(Buffer.from(ciphertext, 'hex'))); + const word = Word.fromHex(wordHex); + const signature = sk.sign(word); + return `0x${Buffer.from(signature.serialize().slice(1)).toString('hex')}`; +} + +export async function deleteHotKey(_ciphertext: string): Promise { + // No-op in the JS fallback: the vault-wrapped blob is removed by the caller + // when it deletes the account record. There's no native handle to release. +} + +/** + * Decode the serialized AuthSecretKey ciphertext and return the raw 32-byte + * secp256k1 secret hex (drops the 1-byte scheme prefix). Matches the native + * plugins' return shape so the facade hands callers a platform-agnostic hex + * string. + */ +export async function revealHotKey(ciphertext: string): Promise { + const sk = AuthSecretKey.deserialize(new Uint8Array(Buffer.from(ciphertext, 'hex'))); + return Buffer.from(sk.serialize().slice(1)).toString('hex'); +} diff --git a/src/lib/secure-hot-key/nativePlugin.test.ts b/src/lib/secure-hot-key/nativePlugin.test.ts new file mode 100644 index 000000000..ce0383cc0 --- /dev/null +++ b/src/lib/secure-hot-key/nativePlugin.test.ts @@ -0,0 +1,145 @@ +/** + * Wrapper-level test for the native hot-key path. This does NOT validate + * byte-for-byte ECDSA wire-format parity against the WASM SDK — that gate is + * the manual on-device run. What we do guard here: + * - iOS and Android both forward generate / sign / delete to the HotKey + * plugin (same wire format on both platforms, see HotKeyPlugin.swift / + * HotKeyPlugin.kt). + * - Calling outside iOS/Android (e.g. extension/desktop) throws so callers + * can't accidentally hit a no-op native bridge instead of the JS fallback. + * - The commitment derivation reframes the publicKey hex with the ECDSA + * type prefix (currently hardcoded as 0x01). + */ + +import * as nativePlugin from './nativePlugin'; + +const mockGenerateHotKey = jest.fn(); +const mockSignWithHotKey = jest.fn(); +const mockDeleteHotKey = jest.fn(); +const mockRevealHotKey = jest.fn(); +jest.mock('./hotKeyPlugin', () => ({ + HotKey: { + generateHotKey: (...a: unknown[]) => mockGenerateHotKey(...a), + signWithHotKey: (...a: unknown[]) => mockSignWithHotKey(...a), + deleteHotKey: (...a: unknown[]) => mockDeleteHotKey(...a), + revealHotKey: (...a: unknown[]) => mockRevealHotKey(...a) + } +})); + +const mockIsIOS = jest.fn(); +const mockIsAndroid = jest.fn(); +jest.mock('lib/platform', () => ({ + isIOS: () => mockIsIOS(), + isAndroid: () => mockIsAndroid() +})); + +const publicKeyDeserialize = jest.fn((bytes: Uint8Array) => ({ + toCommitment: () => ({ toHex: () => `0xcommit:${Array.from(bytes).join(',')}` }) +})); +jest.mock('@miden-sdk/miden-sdk/lazy', () => ({ + PublicKey: { + deserialize: (bytes: Uint8Array) => publicKeyDeserialize(bytes) + } +})); + +beforeEach(() => { + jest.clearAllMocks(); + mockIsIOS.mockReturnValue(false); + mockIsAndroid.mockReturnValue(false); +}); + +const sharedForwardingSpec = (label: 'iOS' | 'Android', setPlatform: () => void) => { + describe(`secure-hot-key nativePlugin (${label})`, () => { + beforeEach(setPlatform); + + it('generateHotKey forwards to HotKey and frames commitment with the 0x01 type prefix', async () => { + // Native plugin returns the compressed secp256k1 pubkey: 33 bytes + // (parity prefix + 32-byte x). The wrapper enforces this length and + // rejects anything else — see commitmentFromPublicKeyHex. + const compressedHex = '02' + 'ab'.repeat(32); + mockGenerateHotKey.mockResolvedValue({ + ciphertext: 'tag:payload', + publicKeyHex: compressedHex + }); + + const out = await nativePlugin.generateHotKey(); + + expect(mockGenerateHotKey).toHaveBeenCalledTimes(1); + expect(out.ciphertext).toBe('tag:payload'); + expect(out.publicKeyHex).toBe(compressedHex); + + const framed = publicKeyDeserialize.mock.calls[0]?.[0]; + expect(framed).toBeInstanceOf(Uint8Array); + expect(framed!.length).toBe(34); // 1 type prefix + 33 compressed pubkey + expect(framed![0]).toBe(1); // type prefix + expect(framed![1]).toBe(0x02); // first byte of compressed pubkey + expect(out.commitmentHex).toMatch(/^0xcommit:1,2(,171){32}$/); + }); + + it('generateHotKey rejects when native returns a non-33-byte public key', async () => { + mockGenerateHotKey.mockResolvedValue({ + ciphertext: 'tag:payload', + publicKeyHex: 'aabbcc' // only 3 bytes + }); + + await expect(nativePlugin.generateHotKey()).rejects.toThrow('unexpected public key length 3 (expected 33)'); + }); + + it('signHotDigest forwards ciphertext + digest and returns the native signatureHex unchanged', async () => { + mockSignWithHotKey.mockResolvedValue({ signatureHex: '0xdeadbeef' }); + + const sig = await nativePlugin.signHotDigest('tag:payload', '0xfeedface'); + + expect(mockSignWithHotKey).toHaveBeenCalledWith({ + ciphertext: 'tag:payload', + digestHex: '0xfeedface' + }); + expect(sig).toBe('0xdeadbeef'); + }); + + it('deleteHotKey forwards ciphertext to HotKey', async () => { + mockDeleteHotKey.mockResolvedValue(undefined); + + await nativePlugin.deleteHotKey('tag:payload'); + + expect(mockDeleteHotKey).toHaveBeenCalledWith({ ciphertext: 'tag:payload' }); + }); + + it('revealHotKey forwards ciphertext and returns the native secretKeyHex unchanged', async () => { + mockRevealHotKey.mockResolvedValue({ secretKeyHex: 'aa'.repeat(32) }); + + const secret = await nativePlugin.revealHotKey('tag:payload'); + + expect(mockRevealHotKey).toHaveBeenCalledWith({ ciphertext: 'tag:payload' }); + expect(secret).toBe('aa'.repeat(32)); + }); + }); +}; + +sharedForwardingSpec('iOS', () => { + mockIsIOS.mockReturnValue(true); + mockIsAndroid.mockReturnValue(false); +}); + +sharedForwardingSpec('Android', () => { + mockIsIOS.mockReturnValue(false); + mockIsAndroid.mockReturnValue(true); +}); + +describe('secure-hot-key nativePlugin (non-mobile guard)', () => { + // Off-mobile (extension/desktop) should never hit this module — secure-hot-key + // routes to jsFallback there. Belt-and-braces: every export throws so a + // misrouting is loud rather than a silent no-op. + it.each([ + ['generateHotKey', () => nativePlugin.generateHotKey()], + ['signHotDigest', () => nativePlugin.signHotDigest('tag:payload', '0x00')], + ['deleteHotKey', () => nativePlugin.deleteHotKey('tag:payload')], + ['revealHotKey', () => nativePlugin.revealHotKey('tag:payload')] + ])('%s rejects when invoked outside iOS/Android', async (_name, op) => { + await expect(op()).rejects.toThrow('outside iOS/Android'); + expect(mockGenerateHotKey).not.toHaveBeenCalled(); + expect(mockSignWithHotKey).not.toHaveBeenCalled(); + expect(mockDeleteHotKey).not.toHaveBeenCalled(); + expect(mockRevealHotKey).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/secure-hot-key/nativePlugin.ts b/src/lib/secure-hot-key/nativePlugin.ts new file mode 100644 index 000000000..8436155d1 --- /dev/null +++ b/src/lib/secure-hot-key/nativePlugin.ts @@ -0,0 +1,69 @@ +/** + * Native plugin path for the secure-hot-key facade. iOS landed in Phase 4a of + * the 3-key migration (Secure Enclave-wrapped k256 secret); Android landed in + * Phase 4b (Android Keystore RSA-OAEP-wrapped k256 secret, StrongBox-preferred). + * Both platforms register the same `HotKey` Capacitor plugin with an identical + * wire format: ciphertext is ":" and the signature is + * `0x` (65 bytes hex), so this wrapper is platform-agnostic past the + * isMobile gate. + * + * Native side returns only ciphertext + raw k256 publicKeyHex (the wrap blob + * embeds its own tag). The commitmentHex needed by MultisigClient.create is + * derived here via the SDK so the GeneratedHotKey shape matches jsFallback. + */ + +import { Buffer } from 'buffer'; + +import { isAndroid, isIOS } from 'lib/platform'; + +import { HotKey } from './hotKeyPlugin'; +import type { GeneratedHotKey } from './jsFallback'; + +function assertMobile(): void { + if (!isIOS() && !isAndroid()) { + throw new Error('secure-hot-key native plugin invoked outside iOS/Android'); + } +} + +export async function generateHotKey(): Promise { + assertMobile(); + + const { ciphertext, publicKeyHex } = await HotKey.generateHotKey(); + const commitmentHex = await commitmentFromPublicKeyHex(publicKeyHex); + return { ciphertext, publicKeyHex, commitmentHex }; +} + +export async function signHotDigest(ciphertext: string, wordHex: string): Promise { + assertMobile(); + + const { signatureHex } = await HotKey.signWithHotKey({ + ciphertext, + digestHex: wordHex + }); + return signatureHex; +} + +export async function deleteHotKey(ciphertext: string): Promise { + assertMobile(); + + await HotKey.deleteHotKey({ ciphertext }); +} + +export async function revealHotKey(ciphertext: string): Promise { + assertMobile(); + + const { secretKeyHex } = await HotKey.revealHotKey({ ciphertext }); + return secretKeyHex; +} + +async function commitmentFromPublicKeyHex(publicKeyHex: string): Promise { + const { PublicKey } = await import('@miden-sdk/miden-sdk/lazy'); + const raw = Buffer.from(publicKeyHex, 'hex'); + const framed = new Uint8Array(raw.length + 1); + if (raw.length !== 33) { + throw new Error(`unexpected public key length ${raw.length} (expected 33)`); + } + framed[0] = 1; // ECDSA k256 type prefix expected by PublicKey.deserialize + framed.set(raw, 1); + return PublicKey.deserialize(framed).toCommitment().toHex(); +} diff --git a/src/lib/shared/types.ts b/src/lib/shared/types.ts index 55caa1760..2d0120c35 100644 --- a/src/lib/shared/types.ts +++ b/src/lib/shared/types.ts @@ -38,6 +38,10 @@ export enum WalletMessageType { RevealViewKeyResponse = 'REVEAL_VIEW_KEY_RESPONSE', RevealPrivateKeyRequest = 'REVEAL_PRIVATE_KEY_REQUEST', RevealPrivateKeyResponse = 'REVEAL_PRIVATE_KEY_RESPONSE', + RevealHotKeyRequest = 'REVEAL_HOT_KEY_REQUEST', + RevealHotKeyResponse = 'REVEAL_HOT_KEY_RESPONSE', + RevealGuardianKeysRequest = 'REVEAL_GUARDIAN_KEYS_REQUEST', + RevealGuardianKeysResponse = 'REVEAL_GUARDIAN_KEYS_RESPONSE', RevealMnemonicRequest = 'REVEAL_MNEMONIC_REQUEST', RevealMnemonicResponse = 'REVEAL_MNEMONIC_RESPONSE', RemoveAccountRequest = 'REMOVE_ACCOUNT_REQUEST', @@ -58,6 +62,10 @@ export enum WalletMessageType { SignTransactionResponse = 'SIGN_TRANSACTION_RESPONSE', SignWordRequest = 'SIGN_WORD_REQUEST', SignWordResponse = 'SIGN_WORD_RESPONSE', + PersistNewHotKeyRequest = 'PERSIST_NEW_HOT_KEY_REQUEST', + PersistNewHotKeyResponse = 'PERSIST_NEW_HOT_KEY_RESPONSE', + SwapHotKeyRequest = 'SWAP_HOT_KEY_REQUEST', + SwapHotKeyResponse = 'SWAP_HOT_KEY_RESPONSE', GetPublicKeyForCommitmentRequest = 'GET_PUBLIC_KEY_FOR_COMMITMENT_REQUEST', GetPublicKeyForCommitmentResponse = 'GET_PUBLIC_KEY_FOR_COMMITMENT_RESPONSE', GetAuthSecretKeyRequest = 'GET_AUTH_SECRET_KEY_REQUEST', @@ -332,6 +340,25 @@ export interface WalletAccount { isPublic: boolean; type: WalletType; hdIndex: number; + // Set on Guardian accounts created with the 3-key model (hot + cold + guardian). + // Absent on non-Guardian accounts and on legacy single-signer Guardian records + // produced before the migration; consumers should treat absence as "not 3-key". + hotPublicKey?: string; + coldPublicKey?: string; + // True for Guardian accounts adopted via seed-phrase recovery — the on-chain + // hot signer's secret is unrecoverable, so the wallet defers replacement to + // a user-triggered rotation (banner on the home view). Cleared by Vault.swapHotKey + // once the cold+guardian-signed update_signers tx lands on-chain. + requiresHotKeyRotation?: boolean; + /** + * Guardian operator endpoint this account is registered with. Set at create / + * recovery time and updated when the user switches guardians. Per-account so + * multiple Guardian accounts can live on different operators — absence means a + * record created before this field existed, in which case consumers fall back + * to the legacy global `GUARDIAN_URL_STORAGE_KEY` (see `resolveGuardianEndpoint`). + * Non-Guardian accounts leave this undefined. + */ + guardianEndpoint?: string; /** * Auth scheme this account was created with. See {@link AuthScheme} for * the missing-on-read → `"falcon"` legacy interpretation. @@ -430,6 +457,30 @@ export interface RevealPrivateKeyResponse extends WalletMessageBase { privateKey: string; } +export interface RevealHotKeyRequest extends WalletMessageBase { + type: WalletMessageType.RevealHotKeyRequest; + accountPublicKey: string; + password?: string; +} + +export interface RevealHotKeyResponse extends WalletMessageBase { + type: WalletMessageType.RevealHotKeyResponse; + hotPrivateKey: string; +} + +export interface RevealGuardianKeysRequest extends WalletMessageBase { + type: WalletMessageType.RevealGuardianKeysRequest; + accountPublicKey: string; + password?: string; +} + +export interface RevealGuardianKeysResponse extends WalletMessageBase { + type: WalletMessageType.RevealGuardianKeysResponse; + coldPrivateKey: string; + coldPublicKey: string; + hotPublicKey?: string; +} + export interface RevealMnemonicRequest extends WalletMessageBase { type: WalletMessageType.RevealMnemonicRequest; password?: string; @@ -547,6 +598,26 @@ export interface SignWordResponse extends WalletMessageBase { signature: string; } +export interface PersistNewHotKeyRequest extends WalletMessageBase { + type: WalletMessageType.PersistNewHotKeyRequest; + newHotPubKey: string; + newHotCiphertext: string; +} + +export interface PersistNewHotKeyResponse extends WalletMessageBase { + type: WalletMessageType.PersistNewHotKeyResponse; +} + +export interface SwapHotKeyRequest extends WalletMessageBase { + type: WalletMessageType.SwapHotKeyRequest; + accountPublicKey: string; + newHotPubKey: string; +} + +export interface SwapHotKeyResponse extends WalletMessageBase { + type: WalletMessageType.SwapHotKeyResponse; +} + export interface GetPublicKeyForCommitmentRequest extends WalletMessageBase { type: WalletMessageType.GetPublicKeyForCommitmentRequest; commitment: string; @@ -746,6 +817,8 @@ export type WalletRequest = | RevealPublicKeyRequest | RevealViewKeyRequest | RevealPrivateKeyRequest + | RevealHotKeyRequest + | RevealGuardianKeysRequest | RevealMnemonicRequest | RemoveAccountRequest | EditAccountRequest @@ -757,6 +830,8 @@ export type WalletRequest = | SignDataRequest | SignTransactionRequest | SignWordRequest + | PersistNewHotKeyRequest + | SwapHotKeyRequest | GetPublicKeyForCommitmentRequest | GetAuthSecretKeyRequest | PageRequest @@ -798,6 +873,8 @@ export type WalletResponse = | RevealPublicKeyResponse | RevealViewKeyResponse | RevealPrivateKeyResponse + | RevealHotKeyResponse + | RevealGuardianKeysResponse | RevealMnemonicResponse | RemoveAccountResponse | EditAccountResponse @@ -809,6 +886,8 @@ export type WalletResponse = | SignDataResponse | SignTransactionResponse | SignWordResponse + | PersistNewHotKeyResponse + | SwapHotKeyResponse | GetPublicKeyForCommitmentResponse | GetAuthSecretKeyResponse | PageResponse diff --git a/src/lib/store/index.ts b/src/lib/store/index.ts index 850c485ee..d607df5bf 100644 --- a/src/lib/store/index.ts +++ b/src/lib/store/index.ts @@ -241,6 +241,30 @@ export const useWalletStore = create()( return res.privateKey; }, + revealHotKey: async (accountPublicKey, password) => { + const res = await request({ + type: WalletMessageType.RevealHotKeyRequest, + accountPublicKey, + password + }); + assertResponse(res.type === WalletMessageType.RevealHotKeyResponse); + return res.hotPrivateKey; + }, + + revealGuardianKeys: async (accountPublicKey, password) => { + const res = await request({ + type: WalletMessageType.RevealGuardianKeysRequest, + accountPublicKey, + password + }); + assertResponse(res.type === WalletMessageType.RevealGuardianKeysResponse); + return { + coldPrivateKey: res.coldPrivateKey, + coldPublicKey: res.coldPublicKey, + hotPublicKey: res.hotPublicKey + }; + }, + importAccount: async (privateKey, name) => { const res = await request({ type: WalletMessageType.ImportAccountRequest, @@ -306,6 +330,24 @@ export const useWalletStore = create()( return res.signature; }, + persistNewHotKey: async (newHotPubKey, newHotCiphertext) => { + const res = await request({ + type: WalletMessageType.PersistNewHotKeyRequest, + newHotPubKey, + newHotCiphertext + }); + assertResponse(res.type === WalletMessageType.PersistNewHotKeyResponse); + }, + + swapHotKey: async (accountPublicKey, newHotPubKey) => { + const res = await request({ + type: WalletMessageType.SwapHotKeyRequest, + accountPublicKey, + newHotPubKey + }); + assertResponse(res.type === WalletMessageType.SwapHotKeyResponse); + }, + getPublicKeyForCommitment: async commitment => { const res = await request({ type: WalletMessageType.GetPublicKeyForCommitmentRequest, @@ -678,4 +720,32 @@ if (process.env.MIDEN_E2E_TEST === 'true') { console.error('[E2E] Failed to expose __TEST_HEX_TO_BECH32_FAUCET__:', e); } })(); + + // Guardian on-chain auth structure (overall threshold + signer set + procedure + // thresholds) for E2E assertions — the harness's balance checks can't see the + // 3-key shape. Reads the cached front-end MultisigService; dynamic imports + // avoid a static cycle (guardian-sync pulls in this store module). + (globalThis as any).__TEST_GUARDIAN_AUTH__ = async (accountPublicKey: string) => { + try { + const [{ getOrCreateMultisigService }, { zustandProvider }] = await Promise.all([ + import('lib/miden/front/guardian-manager'), + import('lib/miden/front/guardian-sync') + ]); + const service = await getOrCreateMultisigService(accountPublicKey, zustandProvider); + try { + // Best-effort refresh of on-chain state before reading. service.sync() + // takes the global WASM lock; on mobile the background sync can hold it + // for tens of seconds, which would blow the 30s execute_async_script + // budget the iOS bridge runs this under. Cap the wait — the auth + // structure (signers + procedure thresholds) is immutable during this + // assertion, so a slightly stale local read is still correct. + await Promise.race([service.sync(), new Promise(resolve => setTimeout(resolve, 8_000))]); + } catch { + // best-effort — fall back to last-synced state + } + return service.getAuthInfo(); + } catch (e) { + return { error: e instanceof Error ? e.message : String(e) }; + } + }; } diff --git a/src/lib/store/types.ts b/src/lib/store/types.ts index 7ce3394f4..68d20992e 100644 --- a/src/lib/store/types.ts +++ b/src/lib/store/types.ts @@ -136,6 +136,11 @@ export interface WalletActions { editAccountName: (accountPublicKey: string, name: string) => Promise; revealMnemonic: (password?: string) => Promise; revealPrivateKey: (accountPublicKey: string, password?: string) => Promise; + revealHotKey: (accountPublicKey: string, password?: string) => Promise; + revealGuardianKeys: ( + accountPublicKey: string, + password?: string + ) => Promise<{ coldPrivateKey: string; coldPublicKey: string; hotPublicKey?: string }>; importAccount: (privateKey: string, name?: string) => Promise; // Settings actions @@ -145,6 +150,8 @@ export interface WalletActions { signData: (publicKey: string, signingInputs: string) => Promise; signTransaction: (publicKey: string, signingInputs: string) => Promise; signWord: (publicKey: string, wordHex: string) => Promise; + persistNewHotKey: (newHotPubKey: string, newHotCiphertext: string) => Promise; + swapHotKey: (accountPublicKey: string, newHotPubKey: string) => Promise; getPublicKeyForCommitment: (commitment: string) => Promise; getAuthSecretKey: (key: string) => Promise; diff --git a/src/screens/onboarding/create-wallet-flow/BackUpSeedPhrase.tsx b/src/screens/onboarding/create-wallet-flow/BackUpSeedPhrase.tsx index adeb251b3..e3507e32c 100644 --- a/src/screens/onboarding/create-wallet-flow/BackUpSeedPhrase.tsx +++ b/src/screens/onboarding/create-wallet-flow/BackUpSeedPhrase.tsx @@ -52,6 +52,7 @@ export const BackUpSeedPhraseScreen: React.FC = ({

{t('backUpWalletInstructions')}

{t('doNotShareWithAnywone')}

+

{t('seedPhraseRecoveryCaption')}

diff --git a/vite.mobile.config.ts b/vite.mobile.config.ts index b4488105b..9714c796d 100644 --- a/vite.mobile.config.ts +++ b/vite.mobile.config.ts @@ -152,8 +152,13 @@ export default defineConfig({ output: { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name].[hash].js', - assetFileNames: 'static/[name].[hash][extname]', - inlineDynamicImports: true + assetFileNames: 'static/[name].[hash][extname]' + // NB: inlineDynamicImports was true here, but rolldown-vite (Vite 8) + // emits non-async arrow wrappers around inlined TLA-using modules + // (e.g. @miden-sdk/miden-sdk/lazy → `(()=>{ await _V() })`), which + // JavaScriptCore rejects with "SyntaxError: Unexpected identifier + // '_V'". Letting dynamic imports stay as real chunks keeps the TLA + // contained to module-level where the runtime supports it. } } },