eleven / messenger Public

native: the synchronizable DEK item — SiwA-gated, flag-off (#1089) (#1144)

The DEK-sync slice, per the design comment on #1089: the backup blob's
key ALSO stored as an iCloud-Keychain-synchronizable item (distinct
service com.lchat.client.dek-synced, added by addition — never
delete-then-add), so a new device on the same Apple ID opens the vault
after one Sign in with Apple sheet.

- Write-gated on the account holding a SiwA anchor (GET /api/anchors);
  written from the session-adoption seam and once per launch (how
  existing installs migrate by addition on first unlock).
- Use-gated on a fresh SiwA assertion: DEKSync.release is the item's
  only reader, called solely with the token siwaVerify just returned,
  and requires the resolved account's anchors to contain the very
  anchor the item was written under AND the DEK to open that account's
  blob. Every failure falls through to today's sealed/empty landing.
- iOS native sheet + Mac ASWebAuthenticationSession door both wired.
- Build-flag gated, OFF by default: Debug opt-in via LCHAT_DEK_SYNC=1
  (latched) / dek_sync_enabled default; ships by defining LCHAT_DEK_SYNC
  once the device matrix passes.

No changelog entry: native change, held for the release that ships it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 16a031c89a2cdaf09e8029c9b2ab624806569600 parent adcd554
8 files changed, +386 −11
  • docs/accounts-and-recovery.md +10 −2
  • ios/LChat/LChatApp.swift +5 −0
  • ios/LChat/Views/SignInWithEleven.swift +28 −6
  • ios/LChatCore/Sources/LChatCore/DEKSync.swift +207 −0
  • ios/LChatCore/Sources/LChatCore/HomeClient.swift +27 −0
  • ios/LChatCore/Tests/LChatCoreTests/DEKSyncTests.swift +83 −0
  • ios/LChatMac/LChatMacApp.swift +5 −0
  • ios/LChatMac/MacHomeSignIn.swift +21 −3
diff --git a/docs/accounts-and-recovery.md b/docs/accounts-and-recovery.md
index 92cb59d..73eacfb 100644
--- a/docs/accounts-and-recovery.md
+++ b/docs/accounts-and-recovery.md
@@ -148,11 +148,19 @@ The seamless path for the current (Apple-heavy) user base:
synchronizable iCloud Keychain item makes the key travel invisibly — same
trust anchor as an iCloud passkey, zero WebAuthn ceremonies, and the
third-party-provider bug class (the 1Password PRF mismatch) disappears
- because credential managers are never in the loop.
+ because credential managers are never in the loop. *Built (issue #1089):
+ `ios/LChatCore/.../DEKSync.swift` — a second item beside the device-local
+ one (service `com.lchat.client.dek-synced`, added by addition, never
+ delete-then-add), write-gated on the account holding a SiwA anchor.
+ Build-flag gated, OFF by default until the device matrix passes.*
- **Job 1 gates job 2.** A synced DEK alone would auto-unlock on any device
signed into the same Apple ID (family iPad on a parent's account). The
synced item is used only after a fresh SiwA assertion for the matching
- hashed `sub`. Face ID on the SiwA sheet is the one visible moment.
+ hashed `sub`. Face ID on the SiwA sheet is the one visible moment. *In the
+ built slice `DEKSync.release` is the item's only reader, called solely
+ with the token a fresh SiwA verify just returned; release further requires
+ the resolved account's anchor list to contain the very anchor the item was
+ written under AND the DEK to actually open that account's blob.*
- **The honest trade, written down:** the "zero surviving devices" recovery
trust moves *into* Apple's ecosystem (Apple ID + iCloud Keychain escrow)
rather than being eliminated. That is exactly what the seamless experience
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 2f9a173..aa85c13 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -389,6 +389,11 @@ struct RootView: View {
routeNotificationTap()
pullHome()
routeChangelogTap()
+ // The DEK-sync migrate-by-addition moment (#1089, flag-gated):
+ // an existing signed-in install adds the synchronizable DEK item
+ // on its first unlocked launch — write-gated on the account
+ // holding a SiwA anchor; a no-op for everyone else.
+ Task { await DEKSync.writeIfEligible() }
}
// Sign-in restore populates instances while the first-run screen is
// up — the root folds to the spaces home screen (the restored list),
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
index 4ec4862..0acb3f2 100644
--- a/ios/LChat/Views/SignInWithEleven.swift
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -135,6 +135,9 @@ final class HomeSignInModel: ObservableObject {
sessionExists = true
sessionSealed = session.dek == nil
UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey)
+ // The DEK-sync write-gate (#1089, flag-gated): a DEK-holding session
+ // on a SiwA-anchored account adds/refreshes the synchronizable item.
+ Task { await DEKSync.writeIfEligible() }
}
// (No partial sign-out exists: with a passkey there's nothing to be half
@@ -205,7 +208,7 @@ final class HomeSignInModel: ObservableObject {
/// SignInWithAppleButton's completion half: verify the identity token
/// against home, then adopt the session through the same seam as a
/// passkey ceremony finish.
- func handleApple(_ result: Result<ASAuthorization, Error>) async {
+ func handleApple(_ result: Result<ASAuthorization, Error>, into app: AppState) async {
let nonce = appleNonce
appleNonce = nil
switch result {
@@ -218,7 +221,19 @@ final class HomeSignInModel: ObservableObject {
}
do {
let fin = try await HomeClient().siwaVerify(identityToken: idToken, nonce: nonce)
- phase = await finishAnchor(fin, method: .apple)
+ // The DEK-sync use-gate (#1089, flag-gated): the assertion
+ // that just verified is exactly the "fresh SiwA assertion"
+ // the synced item's release requires. When the iCloud-synced
+ // DEK is here AND belongs to this account's vault, this
+ // Apple sign-in opens the spaces outright — no passkey. Any
+ // failure falls through to the sealed/empty landing as today.
+ if let released = await DEKSync.release(token: fin.token) {
+ phase = .syncing
+ adopt(released.session, method: .apple)
+ phase = mergeVault(released.contents, into: app)
+ } else {
+ phase = await finishAnchor(fin, method: .apple)
+ }
} catch {
phase = .failed(Self.anchorMessage(error))
}
@@ -333,11 +348,18 @@ final class HomeSignInModel: ObservableObject {
// NOT stored — the DEK is all sync needs.
adopt(HomeSession(token: result.token, credentialID: result.credentialID,
dek: opened.dek), method: .passkey)
+ return mergeVault(opened.contents, into: app)
+ }
+
+ /// An OPENED vault landing on this device — the vault-wins merge every
+ /// unlocked sign-in shares (the passkey restore above, and the Apple door
+ /// when the synced DEK releases). Caller adopts the session first.
+ private func mergeVault(_ contents: HomeVault.Contents, into app: AppState) -> Phase {
// Removals made on the home page win over stale device copies — apply
// their tombstones before anything flows back up.
- app.dropTombstoned(opened.contents.removed)
+ app.dropTombstoned(contents.removed)
var added = 0
- for server in opened.contents.servers { // array order is the master order
+ for server in contents.servers { // array order is the master order
// A credential-less entry (a space saved by URL alone on the home
// page) can't be signed into from here — skip it; it stays in the
// vault untouched.
@@ -357,7 +379,7 @@ final class HomeSignInModel: ObservableObject {
}
// Vault wins for servers on BOTH sides: its stars/names/order apply to
// the device before anything flows back.
- app.applyVault(opened.contents.servers.map {
+ app.applyVault(contents.servers.map {
(url: $0.url, starred: $0.isStarred, archived: $0.isArchived, name: $0.name)
})
// Device-only servers (joined before signing in) flow back up, so home
@@ -546,7 +568,7 @@ struct SignInFirstRun: View {
tapped = .apple
model.prepareApple(request)
} onCompletion: { result in
- Task { await model.handleApple(result) }
+ Task { await model.handleApple(result, into: app) }
}
.signInWithAppleButtonStyle(scheme == .dark ? .white : .black)
.frame(height: DoorButtonStyle.height)
diff --git a/ios/LChatCore/Sources/LChatCore/DEKSync.swift b/ios/LChatCore/Sources/LChatCore/DEKSync.swift
new file mode 100644
index 0000000..3a98b04
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/DEKSync.swift
@@ -0,0 +1,207 @@
+import CryptoKit
+import Foundation
+import Security
+
+// 🤖 The synchronizable DEK item — issue #1089's DEK-sync slice: the backup
+// blob's key ALSO stored as an iCloud-Keychain-synchronizable item, so a new
+// device on the same Apple ID can open the vault after one Sign in with Apple
+// sheet — no passkey ceremony, no credential manager in the loop.
+//
+// Three invariants, from the design (docs/accounts-and-recovery.md § Sign in
+// with Apple, precisely):
+//
+// 1. **Two items, never a swap.** This is a SECOND item beside the existing
+// device-local session (HomeSessionStore, ThisDeviceOnly) — a distinct
+// service name, added by addition (add-or-update, never delete-then-add:
+// a crash between delete and add would lose the key). The device-local
+// item stays the source of truth on this device; the synced item is an
+// accelerator for the NEXT device, never a dependency — every failure
+// here falls back to the passkey path exactly as today.
+// 2. **Write-gated on the anchor.** The item is only ever written once the
+// account holds a SiwA anchor — a synced DEK without the identity gate
+// would auto-unlock any device on the same Apple ID (the family-iPad
+// case). If the account definitively sheds its last SiwA anchor, the item
+// is cleared (account-wide via iCloud — correct: the gate is gone).
+// 3. **Use-gated on a fresh assertion (job 1 gates job 2).** `release` is
+// the ONLY reader of the item, and its only callers run immediately after
+// a fresh SiwA verify — the Face ID moment on Apple's sheet. Release
+// additionally requires (a) the resolved account's anchor list to contain
+// the very anchor hash the item was written under, and (b) the DEK to
+// actually open that account's blob — cryptographic proof the key belongs
+// to the asserted identity's vault.
+//
+// The distinct service name is load-bearing beyond tidiness: AppReset's
+// sign-out wipe deletes whole services by exact name, and deleting a
+// synchronizable item removes it from EVERY device via iCloud — signing out
+// of one Mac must not strip the account's travelling key from the member's
+// other devices. Sign-out therefore leaves this item alone by construction;
+// the use-gate is what keeps a signed-out device's lingering copy inert.
+//
+// Build-flag gated, OFF by default (the device matrix runs before it ships).
+public enum DEKSync {
+ // ---- the flag ------------------------------------------------------------
+
+ static let flagKey = "dek_sync_enabled"
+
+ /// OFF by default. Ships on by defining LCHAT_DEK_SYNC in Package.swift
+ /// (the one-line flip once the device matrix passes). Until then Debug
+ /// builds opt in per device: env LCHAT_DEK_SYNC=1 (a devicectl/scheme
+ /// launch — latched into UserDefaults so later icon-tap launches keep it;
+ /// =0 unlatches), or `defaults write <bundle id> dek_sync_enabled -bool
+ /// YES` on the Mac. Resolved once per launch, like HomeBase.
+ public static let enabled: Bool = resolveEnabled()
+
+ private static func resolveEnabled() -> Bool {
+ #if LCHAT_DEK_SYNC
+ return true
+ #elseif DEBUG
+ let defaults = UserDefaults.standard
+ switch ProcessInfo.processInfo.environment["LCHAT_DEK_SYNC"] {
+ case "1": defaults.set(true, forKey: flagKey)
+ case "0": defaults.removeObject(forKey: flagKey)
+ default: break
+ }
+ return defaults.bool(forKey: flagKey)
+ #else
+ return false
+ #endif
+ }
+
+ // ---- the item ------------------------------------------------------------
+
+ /// What syncs: the DEK plus the SiwA anchor hash it was gated on (the
+ /// opaque HMAC-peppered id GET /api/anchors hands the account's own
+ /// client — reverses to nothing, and it's exactly what release must
+ /// match). NEVER the link token: tokens are per-device transport auth;
+ /// the releasing device mints its own via the fresh verify.
+ struct Item: Codable, Equatable {
+ var v: Int
+ var dekB64: String // base64url raw DEK bytes (32)
+ var siwaAnchor: String // the anchor id the write-gate saw
+ var credentialID: String // which passkey wraps this DEK (informative)
+ }
+
+ // ---- the write-gate --------------------------------------------------------
+
+ /// Add the synced item if this device's session holds the DEK and the
+ /// account holds a SiwA anchor; clear it if the account definitively
+ /// holds none. Every other outcome (signed out, anchor-only session,
+ /// offline) changes nothing. Called from the session-adoption seam and
+ /// once per launch — the launch call is how existing installs migrate by
+ /// addition on their first unlock.
+ public static func writeIfEligible(client: HomeClient = HomeClient()) async {
+ guard enabled else { return }
+ guard let session = HomeSessionStore.load(), session.dek != nil else { return }
+ guard let anchors = try? await client.anchors(token: session.token) else { return }
+ guard let siwa = anchors.first(where: { $0.kind == "siwa" }) else {
+ clear() // the gate is gone — the key must not keep travelling
+ return
+ }
+ save(Item(v: 1, dekB64: session.dekB64, siwaAnchor: siwa.id,
+ credentialID: session.credentialID))
+ }
+
+ // ---- the use-gate ----------------------------------------------------------
+
+ /// Release the synced DEK against a FRESH SiwA session token — callers
+ /// pass the token `siwaVerify` just returned, never a stored one. Returns
+ /// the upgraded session plus the opened vault, or nil for any reason at
+ /// all (flag off, no item, anchor mismatch, no blob, stale DEK) — nil
+ /// always means "carry on exactly as before this feature existed".
+ public static func release(token: String, client: HomeClient = HomeClient())
+ async -> (session: HomeSession, contents: HomeVault.Contents)? {
+ guard enabled, let item = load() else { return nil }
+ guard let anchors = try? await client.anchors(token: token),
+ releasable(item, anchors: anchors) else { return nil }
+ guard let dekRaw = Data(base64urlEncoded: item.dekB64), dekRaw.count == 32 else { return nil }
+ let dek = SymmetricKey(data: dekRaw)
+ guard let fetch = try? await client.fetchBlob(token: token),
+ let contents = try? HomeVault.open(fetch.bytes, dek: dek) else { return nil }
+ return (HomeSession(token: token, credentialID: item.credentialID, dek: dek),
+ contents)
+ }
+
+ /// The pure matching rule: the account the fresh assertion resolved must
+ /// carry the very SiwA anchor the item was written under. A fresh
+ /// account (unknown sub), someone else's account, or an account that
+ /// since shed that anchor all fail here.
+ static func releasable(_ item: Item, anchors: [HomeClient.AnchorEntry]) -> Bool {
+ anchors.contains { $0.kind == "siwa" && $0.id == item.siwaAnchor }
+ }
+
+ // ---- the synchronizable keychain store -------------------------------------
+
+ // Distinct service (see the header) + per-home-base account scoping, like
+ // HomeSessionStore: a DEK minted against the dev home must never release
+ // against production's.
+ private static let service = "com.lchat.client.dek-synced"
+ private static let accessGroup = "J5NDF5PCD8.com.elevenmessenger.messenger.shared"
+ private static var account: String { "dek" + HomeBase.storageSuffix }
+
+ /// Base attributes every call shares. kSecAttrSynchronizable must ride
+ /// EVERY query — without it the keychain silently hides synchronizable
+ /// items from matches and deletes.
+ private static func attrs(withGroup: Bool) -> [String: Any] {
+ var q: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecAttrSynchronizable as String: true,
+ kSecUseDataProtectionKeychain as String: true,
+ ]
+ if withGroup { q[kSecAttrAccessGroup as String] = accessGroup }
+ return q
+ }
+
+ static func load() -> Item? {
+ for withGroup in [true, false] {
+ var query = attrs(withGroup: withGroup)
+ query[kSecReturnData as String] = true
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
+ var out: CFTypeRef?
+ if SecItemCopyMatching(query as CFDictionary, &out) == errSecSuccess,
+ let data = out as? Data,
+ let item = try? JSONDecoder().decode(Item.self, from: data) {
+ return item
+ }
+ }
+ return nil
+ }
+
+ /// Add-or-update in place — deliberately NOT Keychain.save's
+ /// delete-then-add (a crash in that window loses the key everywhere).
+ /// The shared group first so the Mac app can read what the iPhone wrote;
+ /// ungrouped fallback for ad-hoc dev builds, matching Keychain.swift.
+ @discardableResult
+ static func save(_ item: Item) -> Bool {
+ guard let data = try? JSONEncoder().encode(item) else { return false }
+ for withGroup in [true, false] {
+ var add = attrs(withGroup: withGroup)
+ add[kSecValueData as String] = data
+ // AfterFirstUnlock WITHOUT ThisDeviceOnly — the whole point is
+ // that this item may leave the device (via iCloud Keychain only).
+ add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
+ switch SecItemAdd(add as CFDictionary, nil) {
+ case errSecSuccess:
+ return true
+ case errSecDuplicateItem:
+ let update = [kSecValueData as String: data]
+ if SecItemUpdate(attrs(withGroup: withGroup) as CFDictionary,
+ update as CFDictionary) == errSecSuccess {
+ return true
+ }
+ default:
+ break
+ }
+ }
+ return false
+ }
+
+ /// Account-wide removal (iCloud propagates the delete) — only for when
+ /// the gate itself is gone. Never called on sign-out.
+ public static func clear() {
+ for withGroup in [true, false] {
+ SecItemDelete(attrs(withGroup: withGroup) as CFDictionary)
+ }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/HomeClient.swift b/ios/LChatCore/Sources/LChatCore/HomeClient.swift
index 45370cf..670c18d 100644
--- a/ios/LChatCore/Sources/LChatCore/HomeClient.swift
+++ b/ios/LChatCore/Sources/LChatCore/HomeClient.swift
@@ -94,6 +94,33 @@ public struct HomeClient {
return try JSONDecoder().decode(R.self, from: data).version.value
}
+ // ---- sign-in methods (anchors) -------------------------------------------
+
+ /// One row of GET /api/anchors — a way into the account. The id is the
+ /// opaque removal handle home already hands its own client (for siwa the
+ /// HMAC-peppered sub hash — reverses to nothing); DEKSync stores and
+ /// matches it verbatim.
+ public struct AnchorEntry: Decodable {
+ public let kind: String
+ public let id: String
+
+ public init(kind: String, id: String) {
+ self.kind = kind
+ self.id = id
+ }
+ }
+
+ /// GET /api/anchors — every sign-in method on the account (link-token
+ /// gated, like the blob).
+ public func anchors(token: String) async throws -> [AnchorEntry] {
+ var req = URLRequest(url: base.appending(path: "/api/anchors"))
+ req.setValue("Bearer " + token, forHTTPHeaderField: "Authorization")
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ try Self.ensureOK(resp, data)
+ struct R: Decodable { let anchors: [AnchorEntry]? }
+ return try JSONDecoder().decode(R.self, from: data).anchors ?? []
+ }
+
// ---- space creation (the cloud provisioner, via home) -------------------
/// GET /api/features — whether this home server offers space creation
diff --git a/ios/LChatCore/Tests/LChatCoreTests/DEKSyncTests.swift b/ios/LChatCore/Tests/LChatCoreTests/DEKSyncTests.swift
new file mode 100644
index 0000000..630dffd
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/DEKSyncTests.swift
@@ -0,0 +1,83 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 The DEK-sync slice's pure halves (issue #1089): the release matching rule
+// (a fresh SiwA assertion's account must carry the very anchor the item was
+// written under) and the synced item's wire shape. The keychain and network
+// edges are deliberately thin and exercised by the device matrix, not here.
+final class DEKSyncTests: XCTestCase {
+ private let item = DEKSync.Item(v: 1, dekB64: "AAAA", siwaAnchor: "hash-1",
+ credentialID: "cred-1")
+ private func anchor(_ kind: String, _ id: String) -> HomeClient.AnchorEntry {
+ HomeClient.AnchorEntry(kind: kind, id: id)
+ }
+
+ // ---- the release matching rule ------------------------------------------
+
+ func testReleasableWhenAccountCarriesTheAnchor() {
+ XCTAssertTrue(DEKSync.releasable(item, anchors: [
+ anchor("passkey", "pk-1"), anchor("siwa", "hash-1"),
+ ]))
+ }
+
+ func testReleasableAcceptsAnyOfSeveralSiwaAnchors() {
+ // A combined account may hold two Apple anchors; the item matches the
+ // one it was written under regardless of which Apple ID asserted.
+ XCTAssertTrue(DEKSync.releasable(item, anchors: [
+ anchor("siwa", "hash-other"), anchor("siwa", "hash-1"),
+ ]))
+ }
+
+ func testNotReleasableForADifferentAccount() {
+ // The family-iPad shape: the assertion resolved someone else's (or a
+ // brand-new) account — its anchors don't include the item's.
+ XCTAssertFalse(DEKSync.releasable(item, anchors: [
+ anchor("siwa", "hash-other"),
+ ]))
+ }
+
+ func testNotReleasableWhenTheAnchorWasRemoved() {
+ XCTAssertFalse(DEKSync.releasable(item, anchors: [
+ anchor("passkey", "pk-1"), anchor("email", "hash-1"),
+ ]))
+ }
+
+ func testNotReleasableAgainstAnEmptyAccount() {
+ XCTAssertFalse(DEKSync.releasable(item, anchors: []))
+ }
+
+ func testMatchRequiresTheSiwaKind() {
+ // Same id under a different kind must not satisfy the gate — the rule
+ // is "the SiwA anchor", not "any anchor with that hash".
+ XCTAssertFalse(DEKSync.releasable(item, anchors: [
+ anchor("email", "hash-1"), anchor("google", "hash-1"),
+ ]))
+ }
+
+ // ---- the item's wire shape ----------------------------------------------
+
+ func testItemRoundTrips() throws {
+ let data = try JSONEncoder().encode(item)
+ XCTAssertEqual(try JSONDecoder().decode(DEKSync.Item.self, from: data), item)
+ }
+
+ func testItemToleratesUnknownFieldsFromNewerBuilds() throws {
+ // A newer build may extend the synced item; older builds must still
+ // read the fields they know (the vault blob's same forward rule).
+ let json = #"{"v":2,"dekB64":"BBBB","siwaAnchor":"h","credentialID":"c","future":true}"#
+ let decoded = try JSONDecoder().decode(DEKSync.Item.self, from: Data(json.utf8))
+ XCTAssertEqual(decoded.dekB64, "BBBB")
+ XCTAssertEqual(decoded.siwaAnchor, "h")
+ }
+
+ // ---- the anchors listing decode -----------------------------------------
+
+ func testAnchorEntryDecodesTheServerShape() throws {
+ // home/anchors.go rows: {kind, id, created_at} — created_at ignored.
+ let json = #"{"anchors":[{"kind":"siwa","id":"hash-1","created_at":1752882000}]}"#
+ struct R: Decodable { let anchors: [HomeClient.AnchorEntry] }
+ let r = try JSONDecoder().decode(R.self, from: Data(json.utf8))
+ XCTAssertEqual(r.anchors.first?.kind, "siwa")
+ XCTAssertEqual(r.anchors.first?.id, "hash-1")
+ }
+}
diff --git a/ios/LChatMac/LChatMacApp.swift b/ios/LChatMac/LChatMacApp.swift
index 3f1eca6..b769346 100644
--- a/ios/LChatMac/LChatMacApp.swift
+++ b/ios/LChatMac/LChatMacApp.swift
@@ -165,6 +165,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
func applicationDidFinishLaunching(_ notification: Notification) {
ClickLab.start()
+ // The DEK-sync migrate-by-addition moment (#1089, flag-gated): an
+ // existing signed-in install adds the synchronizable DEK item on its
+ // first unlocked launch — write-gated on the account holding a SiwA
+ // anchor; a no-op for everyone else.
+ Task { await DEKSync.writeIfEligible() }
// "cmd and plus": the View menu's ⌘+ equivalent strictly needs the
// shifted key (⌘⇧=), but people press ⌘= and expect bigger text —
// Messages and Safari both accept it, so map it here. (⌘− needs no
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift
index 3037d10..d4474c5 100644
--- a/ios/LChatMac/MacHomeSignIn.swift
+++ b/ios/LChatMac/MacHomeSignIn.swift
@@ -69,6 +69,9 @@ final class MacHomeSignIn: ObservableObject {
sessionExists = true
sessionSealed = session.dek == nil
UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey)
+ // The DEK-sync write-gate (#1089, flag-gated): a DEK-holding session
+ // on a SiwA-anchored account adds/refreshes the synchronizable item.
+ Task { await DEKSync.writeIfEligible() }
}
func signIn(into app: MacAppState) async {
@@ -245,7 +248,7 @@ final class MacHomeSignIn: ObservableObject {
private var webAuthSession: ASWebAuthenticationSession?
private let webAuthPresenter = WebAuthPresenter()
- func signInWithApple() async {
+ func signInWithApple(into app: MacAppState) async {
// The state ties Apple's return leg to THIS attempt. Deliberately not
// "web."-prefixed — that routes the trampoline back to the home page;
// everything else bounces to the custom scheme. The web flow carries
@@ -269,7 +272,22 @@ final class MacHomeSignIn: ObservableObject {
return
}
let fin = try await HomeClient().siwaVerify(identityToken: idToken, nonce: "")
- phase = await finishAnchor(fin, method: .apple)
+ // The DEK-sync use-gate (#1089, flag-gated): the web-session
+ // assertion that just verified is the fresh SiwA assertion the
+ // synced item's release requires — same rule as iOS's native
+ // sheet. When the iCloud-synced DEK is here AND belongs to this
+ // account's vault, this Apple sign-in opens the spaces outright;
+ // any failure falls through to the sealed/empty landing as today.
+ if let released = await DEKSync.release(token: fin.token) {
+ phase = .syncing
+ adopt(released.session, method: .apple)
+ let added = app.applyVault(released.contents.servers,
+ removed: released.contents.removed)
+ app.syncHome()
+ phase = added > 0 ? .restored(added) : .emptyBackup
+ } else {
+ phase = await finishAnchor(fin, method: .apple)
+ }
} catch let e as ASWebAuthenticationSessionError where e.code == .canceledLogin {
phase = .idle // closing Apple's window is a choice, not an error
} catch {
@@ -656,7 +674,7 @@ struct MacFirstRun: View {
let appleBusy = model.phase.busy && tapped == .apple
AppleDoorButton(style: scheme == .dark ? .white : .black) {
tapped = .apple
- Task { await model.signInWithApple() }
+ Task { await model.signInWithApple(into: app) }
}
.id(scheme)
.frame(height: MacDoorButtonStyle.height)