Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* [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)
* [FIX][mobile] **iOS hot-key signing now requires Face ID / Touch ID on device, matching Android.** The Secure Enclave hot key was created with `.privateKeyUsage` only — a *usage permission* that, contrary to the old code comment, does **not** prompt for authentication — so user-initiated Guardian claims and sends signed silently on iOS, while Android (StrongBox + `setUserAuthenticationRequired`) already prompted. New hot keys now also set `.userPresence`, so every user-initiated hot signature and hot-key reveal requires user presence (Face ID / Touch ID with passcode fallback). `.userPresence` is used rather than `.biometryCurrentSet` so the key survives biometric re-enrollment instead of bricking until re-activation. Background auto-consume is unaffected — it is cold-signed in WASM and never touches the hot key. Scope: the gate applies to device builds only (the simulator / iOS E2E path keeps `.privateKeyUsage`-only silent signing), and existing hot keys keep their prior behavior until re-activated/rotated. (#299)
* [FIX][mobile] **iOS app builds again under Xcode 26.** Two `foundKey as? SecKey` downcasts in the hot-key plugin (`signWithHotKey` / `revealHotKey`) are no-ops for CoreFoundation types — they always succeed — which Xcode 26 now rejects as a hard error, breaking the iOS build. Replaced with a `CFGetTypeID(foundKey) == SecKeyGetTypeID()` guard plus a force-cast: both the correct defensive downcast and Xcode-26-clean. (#299)
* [FIX][all] **Guardian accounts can now connect to dApps (faucet, etc.) instead of failing with "Connection Failed" / `NOT_GRANTED`.** A Guardian account's auth component is built by `@openzeppelin/miden-multisig-client` and its procedures live in the `openzeppelin::auth::*` MASM namespace, so they don't MAST-match any bundled `miden-standards` template. The SDK's `AccountInterface` therefore classifies the component as `Custom` and `Account.getPublicKeyCommitments()` returns `[]`; the wallet's connect flow read that as "no public key" and rejected the connection (surfaced to the dApp as `NOT_GRANTED`). Public-key resolution now falls back, for accounts the SDK can't classify, to reading the hot signer's commitment directly from the account's `openzeppelin::multisig::signer_public_keys` storage map — the key the wallet actually signs with — so Guardian accounts resolve a usable session key. Plain single-key accounts are unaffected (their `AuthSingleSig` component is recognized as before). The same resolution covers the reveal-private-key and advanced-settings public-key views, which broke identically for Guardian accounts. (#300)

## 1.15.2 (2026-06-22)
Expand Down
55 changes: 39 additions & 16 deletions ios/App/App/HotKeyPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,34 @@ public class HotKeyPlugin: CAPPlugin, CAPBridgedPlugin {
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.
// 4. Create the SE-backed P-256 key.
// `.privateKeyUsage` is only a *usage permission* — by itself it does
// NOT prompt for authentication. On real devices we add `.userPresence`
// so every use of the private key (SecKeyCreateDecryptedData in
// signWithHotKey / revealHotKey) requires Face ID / Touch ID, with a
// device-passcode fallback. That is the "tap-to-confirm" gate for
// user-initiated hot signing; background auto-consume is cold-signed in
// WASM and never reaches this key (see generateGuardianTransaction).
// THREAT MODEL: we use `.userPresence`, NOT `.biometryCurrentSet`. Both
// require the user to be present, but `.biometryCurrentSet` invalidates
// the SE key whenever biometric enrollment changes (a face/finger added
// or re-enrolled), bricking the hot key until re-activation.
// `.userPresence` survives re-enrollment and falls back to the passcode.
// Switch to `.biometryCurrentSet` (with a re-activation migration) only
// if a stricter "enrollment change = re-activate" posture is required.
// Simulator: the host SE is unavailable and biometrics aren't reliably
// enrolled, so we keep `.privateKeyUsage` only there — matching the
// kSecAttrTokenIDSecureEnclave guard below and keeping the iOS E2E
// harness's silent signing working.
var accessFlags: SecAccessControlCreateFlags = [.privateKeyUsage]
#if !targetEnvironment(simulator)
accessFlags.insert(.userPresence)
#endif
var accessError: Unmanaged<CFError>?
guard let accessControl = SecAccessControlCreateWithFlags(
kCFAllocatorDefault,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
.privateKeyUsage,
accessFlags,
&accessError
) else {
zeroBytes(&secretBytes)
Expand Down Expand Up @@ -231,13 +243,19 @@ public class HotKeyPlugin: CAPPlugin, CAPBridgedPlugin {
}
// 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 {
// `as? SecKey` is a no-op for CoreFoundation types — it always succeeds,
// so the conditional cast never actually guarded against a foreign item
// (and Xcode 26+ rejects it as an error). Validate the CF type id, then
// force-cast, which is the correct way to defensively downcast to SecKey.
guard CFGetTypeID(foundKey) == SecKeyGetTypeID() else {
call.reject("Hot-key Keychain item is not a SecKey")
return
}
let sePrivateKey = foundKey as! SecKey

// 4. SecKeyCreateDecryptedData triggers Face ID via .privateKeyUsage
// on the SE key.
// 4. SecKeyCreateDecryptedData triggers Face ID / Touch ID via the
// `.userPresence` flag on the SE key (device builds; silent on the
// simulator, where the key is created without `.userPresence`).
var decError: Unmanaged<CFError>?
guard var unwrapped = SecKeyCreateDecryptedData(
sePrivateKey,
Expand Down Expand Up @@ -336,10 +354,15 @@ public class HotKeyPlugin: CAPPlugin, CAPBridgedPlugin {
return
}
// Conditional cast: avoid crashing on a corrupted/foreign Keychain item.
guard let sePrivateKey = foundKey as? SecKey else {
// `as? SecKey` is a no-op for CoreFoundation types — it always succeeds,
// so the conditional cast never actually guarded against a foreign item
// (and Xcode 26+ rejects it as an error). Validate the CF type id, then
// force-cast, which is the correct way to defensively downcast to SecKey.
guard CFGetTypeID(foundKey) == SecKeyGetTypeID() else {
call.reject("Hot-key Keychain item is not a SecKey")
return
}
let sePrivateKey = foundKey as! SecKey

var decError: Unmanaged<CFError>?
guard var unwrapped = SecKeyCreateDecryptedData(
Expand Down
Loading