eleven / messenger Public

ios: Sign in with Apple + email-code doors on the first-run screen (#1132)

* native: home base becomes resolvable — Debug builds can point at the dev home

LChatCore gains HomeBase: production (https://home.elevenmessenger.com) by
default, overridable ONLY in Debug builds — LCHAT_HOME_BASE in the process
environment first, then a home_base_override string in standard UserDefaults
(per-bundle-id on Mac dev alts). Release compiles the override out.

HomeClient's default base, HomeAuth's default rpID, the Mac browser-handoff
/restore link and the sidebar's Manage Spaces link all resolve through it.
HomeSessionStore scopes its Keychain account by the resolved base (suffix on
non-production only, so existing prod sessions keep their account name) — a
dev-home session can never be replayed against production accounts or vice
versa. Debug entitlements gain the dev clone's webcredentials association;
tools/dev + CLAUDE.md document the alt one-liner.

Unblocks PR #1054's testing and native sign-in device testing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ios: Sign in with Apple + email-code doors on the first-run screen

The native half of the #1112/#1091 identity anchors. LChatCore gains
HomeAnchorAuth (siwa/email client calls over HomeClient's plumbing, the
verbatim-nonce mint, identity-token extraction) and a DEK-less anchor
HomeSession (authenticates, never decrypts — sync parks until a passkey
upgrades it in place). The first-run screen becomes the doors: Apple leads
(SignInWithAppleButton, no scopes, nonce-bound verify), email beneath as a
serial card step (address → 6-digit code, web spin floors, the no-spam
note), the passkey demoted to a quiet text button — last-used promoted via
the home_last_signin UserDefaults marker, written by the one adopt() seam
every door funnels through. An anchor landing on a sealed backup promotes
the passkey to "Unlock your spaces with your passkey". SiwA entitlement
added for both build flavours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ec820586e77b703d0d671feaf1cbc0335655bf85 parent 86a8723
7 files changed, +511 −46
  • ios/LChat/LChat-Debug.entitlements +7 −0
  • ios/LChat/LChat.entitlements +7 −0
  • ios/LChat/LChatApp.swift +5 −3
  • ios/LChat/Views/SignInWithEleven.swift +406 −42
  • ios/LChat/Views/SpacesHomeView.swift +1 −0
  • ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift +72 −0
  • ios/LChatCore/Sources/LChatCore/HomeSync.swift +13 −1
diff --git a/ios/LChat/LChat-Debug.entitlements b/ios/LChat/LChat-Debug.entitlements
index 8f165dd..2812635 100644
--- a/ios/LChat/LChat-Debug.entitlements
+++ b/ios/LChat/LChat-Debug.entitlements
@@ -14,6 +14,13 @@
INSendMessageIntent — sender's photo as the icon, app icon badged. -->
<key>com.apple.developer.usernotifications.communication</key>
<true/>
+ <!-- Sign in with Apple: the native SiwA door (SignInWithEleven). The App
+ ID carries the capability; home verifies the identity token with this
+ bundle id as an accepted audience. -->
+ <key>com.apple.developer.applesignin</key>
+ <array>
+ <string>Default</string>
+ </array>
<!-- Universal links: tapping an https invite link on any instance subdomain
opens the app directly (delivered to onOpenURL, fragment included). -->
<key>com.apple.developer.associated-domains</key>
diff --git a/ios/LChat/LChat.entitlements b/ios/LChat/LChat.entitlements
index b10443a..fc89f74 100644
--- a/ios/LChat/LChat.entitlements
+++ b/ios/LChat/LChat.entitlements
@@ -14,6 +14,13 @@
INSendMessageIntent — sender's photo as the icon, app icon badged. -->
<key>com.apple.developer.usernotifications.communication</key>
<true/>
+ <!-- Sign in with Apple: the native SiwA door (SignInWithEleven). The App
+ ID carries the capability; home verifies the identity token with this
+ bundle id as an accepted audience. -->
+ <key>com.apple.developer.applesignin</key>
+ <array>
+ <string>Default</string>
+ </array>
<!-- Universal links: tapping an https invite link on any instance subdomain
opens the app directly (delivered to onOpenURL, fragment included). -->
<key>com.apple.developer.associated-domains</key>
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index df692f8..98d96d8 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -567,9 +567,11 @@ struct RootView: View {
}
}
- // First run: "Sign in with Eleven" is the ONLY door (Paul's call — no
- // paste-a-link; joining lives on the web, a native join flow comes later).
- // The ceremony is iOS 18-only (PRF), so iOS 17 says so instead.
+ // First run: the sign-in doors — Apple leading, email beneath, the
+ // passkey demoted (last-used promoted; see SignInFirstRun). Still no
+ // paste-a-link (Paul's call — joining lives on the web; a native join
+ // flow comes later). The screen is iOS 18-gated with the passkey
+ // ceremony (PRF), so iOS 17 says so instead.
@ViewBuilder private var noServers: some View {
if #available(iOS 18.0, *) {
SignInFirstRun(model: signIn)
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift
index 763cb00..26b19fb 100644
--- a/ios/LChat/Views/SignInWithEleven.swift
+++ b/ios/LChat/Views/SignInWithEleven.swift
@@ -4,14 +4,21 @@ import LChatCore
import SwiftUI
import UIKit
-// 🤖 "Sign in with Eleven" — the first-run hero path. A passkey to
-// home.elevenmessenger.com restores your saved server list onto this device
-// (the web's backup store, now reachable natively). The ceremony + crypto live
-// in LChatCore (HomeAuth / HomeVault / HomeCrypto); this file is the iOS
-// presentation plus the orchestration that turns a restored vault into
-// SavedInstances. The app still targets iOS 17 — the PRF ceremony (iOS 18) is
-// runtime-gated, so iOS 17 members keep the app and simply don't get this
-// feature (the affordances hide below 18).
+// 🤖 "Sign in with Eleven" — the first-run doors. Three ways in, all against
+// the same home server: Sign in with Apple (the iOS lead), an emailed 6-digit
+// code, and the passkey ceremony (demoted to a quiet text button; the
+// last-used door is promoted, the web's #1112 rule). The ceremony + crypto
+// live in LChatCore (HomeAuth / HomeAnchorAuth / HomeVault / HomeCrypto);
+// this file is the iOS presentation plus the orchestration that turns a
+// restored vault into SavedInstances. The app still targets iOS 17 — the PRF
+// ceremony (iOS 18) is runtime-gated, so iOS 17 members keep the app and
+// simply don't get this feature (the affordances hide below 18).
+//
+// The identity-vs-key split holds natively exactly as on the web: an Apple or
+// email sign-in proves who you are and yields a session token, but only a
+// passkey's PRF can derive the key that opens the backup — an anchor sign-in
+// against an account with a sealed backup lands signed in with the passkey
+// door promoted to "unlock".
//
// The PRF output never leaves the device: HomeAuth returns it, HomeVault derives
// the unwrap key from it in memory, and it's dropped when this model deallocates.
@@ -20,17 +27,35 @@ import UIKit
final class HomeSignInModel: ObservableObject {
enum Phase: Equatable {
case idle
- case working // the passkey sheet is up
+ case working // a sign-in sheet / verify round trip is in flight
case syncing // authenticated; fetching + restoring the backup
case restored(Int) // servers added to this device
case emptyBackup // signed in; the account holds no servers yet
case backedUp(Int) // N servers sealed into a (new or empty) backup
+ case sealed // anchor sign-in landed; the backup opens only under a passkey
case failed(String)
// No "noPasskey" case: the API can't distinguish "no passkey existed"
// from a cancelled sheet (both surface as .canceled), so the guidance
// lives as static idle copy on the first-run screen instead.
var busy: Bool { self == .working || self == .syncing }
+ /// A landed sign-in through any door — what folds the email door away.
+ var signedIn: Bool {
+ switch self {
+ case .restored, .emptyBackup, .backedUp, .sealed: return true
+ default: return false
+ }
+ }
+ }
+
+ /// Which door signed in — the #1112 last-used marker, mirrored natively
+ /// under the web's key name so the rule reads across: the last-used
+ /// method leads the doors next time (a future "Sign-in options" surface
+ /// reads the same marker).
+ enum SignInMethod: String { case passkey, apple, email }
+ static let lastSignInKey = "home_last_signin"
+ static var lastSignIn: SignInMethod? {
+ UserDefaults.standard.string(forKey: lastSignInKey).flatMap(SignInMethod.init(rawValue:))
}
@Published private(set) var phase: Phase = .idle
/// Whether a home session exists on this device — @Published so EVERY
@@ -57,7 +82,6 @@ final class HomeSignInModel: ObservableObject {
let result = try await HomeAuth(anchor: anchor).signIn()
phase = .syncing // the sheet is down; the blob fetch + restore runs
phase = try await restore(result, into: app)
- sessionExists = HomeSessionStore.load() != nil
} catch let e as ASAuthorizationError {
// The user backed out of the system sheet — quietly return to the
// first-run choices rather than showing an error. Everything else
@@ -82,6 +106,18 @@ final class HomeSignInModel: ObservableObject {
phase = .idle
}
+ /// Session adoption — the ONE seam every door funnels a finished sign-in
+ /// through (passkey ceremony finish, Apple verify, email verify): the
+ /// same Keychain write, the published flag every surface watches, and
+ /// the last-used marker the first-run doors re-order by. The ceremony
+ /// proof is only ever used transiently by its caller and never stored,
+ /// matching the web.
+ private func adopt(_ session: HomeSession, method: SignInMethod) {
+ HomeSessionStore.save(session)
+ sessionExists = true
+ UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey)
+ }
+
// (No partial sign-out exists: with a passkey there's nothing to be half
// signed-out OF — Sign out is the full local reset, owned by RootView's
// onSignOut so nav state folds with it. The passkey itself is untouched.)
@@ -119,10 +155,8 @@ final class HomeSignInModel: ObservableObject {
phase = .failed("Couldn't save your spaces — try again.")
return
}
- HomeSessionStore.save(HomeSession(token: result.token,
- credentialID: result.credentialID,
- dek: sealed.dek))
- sessionExists = true
+ adopt(HomeSession(token: result.token, credentialID: result.credentialID,
+ dek: sealed.dek), method: .passkey)
phase = .backedUp(app.instances.count)
} catch let e as ASAuthorizationError {
phase = e.code == .canceled ? .idle
@@ -132,6 +166,119 @@ final class HomeSignInModel: ObservableObject {
}
}
+ // ---- the Apple door (issue #1112; server: home/siwa.go) -----------------
+
+ /// The nonce minted for the sheet currently up — the SAME string rides
+ /// the Apple request and the verify body (home compares the identity
+ /// token's nonce claim against it verbatim).
+ private var appleNonce: String?
+
+ /// SignInWithAppleButton's request half. NO scopes — we want no name and
+ /// no email (the account stays anonymous; home only needs the token's
+ /// subject) — plus the fresh nonce binding this sheet to our verify.
+ func prepareApple(_ request: ASAuthorizationAppleIDRequest) {
+ let nonce = HomeAnchorAuth.mintNonce()
+ appleNonce = nonce
+ request.nonce = nonce
+ phase = .working
+ }
+
+ /// 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 {
+ let nonce = appleNonce
+ appleNonce = nil
+ switch result {
+ case .success(let auth):
+ guard let nonce,
+ let cred = auth.credential as? ASAuthorizationAppleIDCredential,
+ let idToken = HomeAnchorAuth.identityToken(from: cred) else {
+ phase = .failed("Apple didn't return a usable sign-in — try again.")
+ return
+ }
+ do {
+ let fin = try await HomeClient().siwaVerify(identityToken: idToken, nonce: nonce)
+ phase = await finishAnchor(fin, method: .apple)
+ } catch {
+ phase = .failed(Self.anchorMessage(error))
+ }
+ case .failure(let error):
+ // Backing out of Apple's sheet is a choice, not an error — the
+ // same quiet return as the passkey path's cancel.
+ if let e = error as? ASAuthorizationError, e.code == .canceled {
+ phase = .idle
+ } else {
+ phase = .failed(error.localizedDescription)
+ }
+ }
+ }
+
+ // ---- the email door (issue #1091; server: home/email.go) ----------------
+
+ /// Step one: send the code. True advances the door to the code step. The
+ /// spin floor mirrors the web's (~900ms): an instant send reads as a
+ /// glitch — and the server's 200 is the same known-or-not, so success
+ /// only ever means "sent".
+ func requestEmailCode(email: String) async -> Bool {
+ phase = .working
+ let floor = Task { try? await Task.sleep(for: .seconds(0.9)) }
+ do {
+ try await HomeClient().emailRequest(email: email)
+ await floor.value
+ phase = .idle
+ return true
+ } catch {
+ floor.cancel() // errors show immediately, like the web
+ phase = .failed(Self.anchorMessage(error))
+ return false
+ }
+ }
+
+ /// Step two: redeem the code and adopt the session — the same seam as
+ /// every other door. The floor is a touch longer than send's (the web's
+ /// 1.2s): a landing that swaps in instantly never registers.
+ func verifyEmailCode(email: String, code: String) async {
+ phase = .working
+ let floor = Task { try? await Task.sleep(for: .seconds(1.2)) }
+ do {
+ let fin = try await HomeClient().emailVerify(email: email, code: code)
+ await floor.value
+ phase = await finishAnchor(fin, method: .email)
+ } catch {
+ floor.cancel()
+ phase = .failed(Self.anchorMessage(error))
+ }
+ }
+
+ /// An anchor's landing: adopt the token (no DEK — an Apple/email sign-in
+ /// proves who you are but can never decrypt; the backup's key lives
+ /// behind a passkey's PRF), then report what the account holds: fresh or
+ /// backup-less → the empty pitch; an existing sealed backup → point at
+ /// the passkey door, which is what actually opens it.
+ private func finishAnchor(_ fin: HomeClient.AnchorFinish, method: SignInMethod) async -> Phase {
+ adopt(HomeSession(token: fin.token), method: method)
+ if fin.created == true { return .emptyBackup }
+ do {
+ return try await HomeClient().fetchBlob(token: fin.token) == nil ? .emptyBackup : .sealed
+ } catch {
+ // Signed in either way — the peek only picks the pitch.
+ return .failed("Signed in, but Eleven couldn't be reached to check your spaces — try again in a moment.")
+ }
+ }
+
+ /// Anchor errors surface the server's own member-facing copy ("that code
+ /// didn't work") when there is one; transport failures get the standing
+ /// network line.
+ private static func anchorMessage(_ error: Error) -> String {
+ if case HomeClientError.http(_, let body) = error {
+ let text = body.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !text.isEmpty, text.count <= 120 { return text }
+ }
+ if let e = error as? HomeClientError { return message(e) }
+ return error.localizedDescription
+ }
+
/// Fetch + open the backup and merge it into this device — the VAULT wins
/// on sign-in (its stars, names and order apply to servers the device
/// already has; missing ones are added). Only then does the device push,
@@ -153,9 +300,8 @@ final class HomeSignInModel: ObservableObject {
ifMatch: "0") != nil else {
return .failed("Couldn't save your spaces — try again.")
}
- HomeSessionStore.save(HomeSession(token: result.token,
- credentialID: result.credentialID,
- dek: sealed.dek))
+ adopt(HomeSession(token: result.token, credentialID: result.credentialID,
+ dek: sealed.dek), method: .passkey)
return app.instances.isEmpty ? .emptyBackup : .backedUp(app.instances.count)
}
guard let opened = try HomeVault.open(fetch.bytes,
@@ -166,9 +312,8 @@ final class HomeSignInModel: ObservableObject {
// Keep the session (link token + DEK, Keychain) so stars/reorders sync
// to home from now on without another Face ID. The PRF output itself is
// NOT stored — the DEK is all sync needs.
- HomeSessionStore.save(HomeSession(token: result.token,
- credentialID: result.credentialID,
- dek: opened.dek))
+ adopt(HomeSession(token: result.token, credentialID: result.credentialID,
+ dek: opened.dek), method: .passkey)
// Removals made on the home page win over stale device copies — apply
// their tombstones before anything flows back up.
app.dropTombstoned(opened.contents.removed)
@@ -231,12 +376,24 @@ final class HomeSignInModel: ObservableObject {
}
}
-// The first-run screen: "Sign in with Eleven" is the hero; joining a specific
-// chat by invite link (paste / QR) is demoted to a quiet secondary action.
+// The first-run screen: the doors. Apple leads on iOS (the platform default),
+// email beneath it, and the passkey path demoted to a quiet text button — with
+// the last-used method promoted to lead (the web's #1112 rule, mirrored
+// simply). The email door is a card step of its own, the web's screen-swap
+// pattern: address → code → session, serial, with a back affordance.
struct SignInFirstRun: View {
@EnvironmentObject var app: AppState
@ObservedObject var model: HomeSignInModel
- /// Opens the existing invite-link / QR add sheet.
+ @Environment(\.colorScheme) private var scheme
+
+ private enum EmailStep { case address, code }
+ @State private var emailStep: EmailStep?
+ @State private var email = ""
+ @State private var code = ""
+ // Which door the last tap opened — only so the promoted passkey button
+ // doesn't spin for an Apple verify that happens to share phase == .working.
+ @State private var tapped: HomeSignInModel.SignInMethod?
+ @FocusState private var focus: EmailStep?
var body: some View {
VStack(spacing: 0) {
@@ -275,34 +432,233 @@ struct SignInFirstRun: View {
.foregroundStyle(.secondary)
}
.padding(.bottom, 48)
+ } else if let step = emailStep {
+ emailDoor(step)
} else {
- VStack(spacing: 12) {
- Button(action: { Task { await model.signIn(into: app) } }) {
- HStack(spacing: 8) {
- if model.phase == .working { ProgressView().controlSize(.small) }
- Text(model.phase == .working ? "Signing in…" : "Sign in")
- .fontWeight(.semibold)
+ doors
+ }
+ }
+ .padding()
+ // A landed sign-in (any door) folds the email door away; the doors
+ // return beneath the status — sealed promotes the passkey to unlock.
+ .onChange(of: model.phase) { _, p in
+ if p.signedIn {
+ emailStep = nil
+ email = ""
+ code = ""
+ }
+ }
+ }
+
+ // ---- the doors ----------------------------------------------------------
+
+ /// Apple, email, passkey — with the last-used method promoted to lead.
+ /// After a sealed anchor sign-in the passkey leads regardless: it is the
+ /// one door that actually opens the backup.
+ private var doorOrder: [HomeSignInModel.SignInMethod] {
+ var order: [HomeSignInModel.SignInMethod] = [.apple, .email, .passkey]
+ let lead: HomeSignInModel.SignInMethod? =
+ model.phase == .sealed ? .passkey : HomeSignInModel.lastSignIn
+ if let lead, let i = order.firstIndex(of: lead) {
+ order.remove(at: i)
+ order.insert(lead, at: 0)
+ }
+ return order
+ }
+
+ private var doors: some View {
+ VStack(spacing: 12) {
+ ForEach(doorOrder, id: \.self) { method in
+ door(method, leading: method == doorOrder.first)
+ }
+
+ // The home page's privacy pitch, word for word.
+ Text("No signup required. We don't store any personal details. We can't spam what we don't have.")
+ .font(.footnote)
+ .foregroundStyle(.tertiary)
+ .multilineTextAlignment(.center)
+ }
+ .padding(.horizontal, 32)
+ .padding(.bottom, 8)
+ }
+
+ @ViewBuilder private func door(_ method: HomeSignInModel.SignInMethod, leading: Bool) -> some View {
+ switch method {
+ case .apple:
+ // Apple's own button (HIG); the model owns the nonce + verify so
+ // the session-adoption seam is shared with every other door.
+ SignInWithAppleButton(.continue) { request in
+ tapped = .apple
+ model.prepareApple(request)
+ } onCompletion: { result in
+ Task { await model.handleApple(result) }
+ }
+ .signInWithAppleButtonStyle(scheme == .dark ? .white : .black)
+ .frame(height: 50)
+ .disabled(model.phase.busy)
+ case .email:
+ Button {
+ tapped = .email
+ emailStep = .address
+ focus = .address
+ } label: {
+ Text("Continue with email")
+ .fontWeight(.semibold)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.large)
+ .disabled(model.phase.busy)
+ case .passkey:
+ if leading {
+ // Promoted: the hero form (last-used passkey, or a sealed
+ // anchor sign-in pointing at the door that opens the backup).
+ Button {
+ tapped = .passkey
+ Task { await model.signIn(into: app) }
+ } label: {
+ HStack(spacing: 8) {
+ if model.phase == .working && tapped == .passkey {
+ ProgressView().controlSize(.small)
}
- .frame(maxWidth: .infinity)
+ Text(passkeyLeadLabel)
+ .fontWeight(.semibold)
}
- .buttonStyle(.borderedProminent)
- .controlSize(.large)
- .disabled(model.phase.busy)
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(model.phase.busy)
+ } else {
+ // Demoted: the quiet text button under the anchor doors.
+ Button("Sign in with a passkey") {
+ tapped = .passkey
+ Task { await model.signIn(into: app) }
+ }
+ .buttonStyle(.plain)
+ .font(.subheadline.weight(.medium))
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 4)
+ .disabled(model.phase.busy)
+ }
+ }
+ }
+
+ private var passkeyLeadLabel: String {
+ if model.phase == .working && tapped == .passkey { return "Signing in…" }
+ if model.phase == .sealed { return "Unlock your spaces with your passkey" }
+ return "Sign in with a passkey"
+ }
+
+ // ---- the email door -----------------------------------------------------
- // The home page's privacy pitch, word for word.
- Text("No signup required. We don't store any personal details. We can't spam what we don't have.")
- .font(.footnote)
- .foregroundStyle(.tertiary)
- .multilineTextAlignment(.center)
+ @ViewBuilder private func emailDoor(_ step: EmailStep) -> some View {
+ VStack(spacing: 12) {
+ HStack {
+ // Serial steps: code backs to the address, address backs to
+ // the doors — a typo'd address is one tap away either way.
+ Button {
+ if step == .code {
+ emailStep = .address
+ code = ""
+ focus = .address
+ } else {
+ emailStep = nil
+ }
+ } label: {
+ Label("Back", systemImage: "chevron.backward")
+ .font(.subheadline)
+ }
+ .accessibilityLabel(step == .code ? "Back to your email address" : "Back to sign-in")
+ Spacer()
+ }
+ Text("Sign in with email")
+ .font(.headline)
+ if step == .address {
+ TextField("you@example.com", text: $email)
+ .textFieldStyle(.roundedBorder)
+ .keyboardType(.emailAddress)
+ .textContentType(.emailAddress)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .submitLabel(.send)
+ .focused($focus, equals: .address)
+ .onSubmit { if emailValid { sendCode() } }
+ .accessibilityLabel("Your email address")
+ Button(action: sendCode) {
+ HStack(spacing: 8) {
+ if model.phase == .working { ProgressView().controlSize(.small) }
+ Text("Send code").fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(model.phase.busy || !emailValid)
+ // The web's note, word for word — under the address field.
+ Text("We never keep your email address. No spam, ever.")
+ .font(.footnote)
+ .foregroundStyle(.tertiary)
+ .multilineTextAlignment(.center)
+ } else {
+ Text("Check your email for a 6-digit code.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ TextField("6-digit code", text: $code)
+ .textFieldStyle(.roundedBorder)
+ .keyboardType(.numberPad)
+ .textContentType(.oneTimeCode)
+ .multilineTextAlignment(.center)
+ .font(.title3.monospacedDigit())
+ .focused($focus, equals: .code)
+ .onChange(of: code) { _, v in
+ // Digits only, six of them — mirrors the web's input.
+ let digits = String(v.filter(\.isNumber).prefix(6))
+ if digits != v { code = digits }
+ }
+ .accessibilityLabel("The 6-digit code from your email")
+ Button(action: verifyCode) {
+ HStack(spacing: 8) {
+ if model.phase == .working { ProgressView().controlSize(.small) }
+ Text("Sign in").fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity)
}
- .padding(.horizontal, 32)
- .padding(.bottom, 8)
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .disabled(model.phase.busy || code.count != 6)
}
}
- .padding()
+ .padding(.horizontal, 32)
+ .padding(.bottom, 8)
+ .onAppear { focus = step }
+ }
+
+ // The server's own bar (email.go): non-empty, has an @, fits an address.
+ private var emailValid: Bool {
+ let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ return !addr.isEmpty && addr.contains("@") && addr.count <= 254
+ }
+
+ private func sendCode() {
+ let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ Task {
+ if await model.requestEmailCode(email: addr) {
+ emailStep = .code
+ focus = .code
+ }
+ }
+ }
+
+ private func verifyCode() {
+ let addr = email.trimmingCharacters(in: .whitespacesAndNewlines)
+ Task { await model.verifyEmailCode(email: addr, code: code) }
}
+ // ---- status -------------------------------------------------------------
+
@ViewBuilder private var statusLine: some View {
switch model.phase {
case .restored(let n):
@@ -314,10 +670,18 @@ struct SignInFirstRun: View {
case .backedUp(let n):
Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill")
.font(.callout).foregroundStyle(.green)
+ case .sealed:
+ // The identity-vs-key split, said plainly: the anchor signed you
+ // in; the passkey (promoted below) is what opens your spaces.
+ Text("Signed in — your spaces are locked with your passkey. Unlock them below.")
+ .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
+ case .working:
+ Text("Confirming it’s you…")
+ .font(.callout).foregroundStyle(.secondary)
case .failed(let msg):
Label(msg, systemImage: "exclamationmark.triangle")
.font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center)
- case .idle, .working, .syncing:
+ case .idle, .syncing:
EmptyView()
}
}
diff --git a/ios/LChat/Views/SpacesHomeView.swift b/ios/LChat/Views/SpacesHomeView.swift
index c0a4216..a6df291 100644
--- a/ios/LChat/Views/SpacesHomeView.swift
+++ b/ios/LChat/Views/SpacesHomeView.swift
@@ -212,6 +212,7 @@ struct SpacesHomeView: View {
case .emptyBackup: return "Signed in — no spaces saved yet."
case .backedUp(let n): return "Saved \(n) space\(n == 1 ? "" : "s") to Eleven."
case .restored(let n): return "\(n) space\(n == 1 ? "" : "s") restored."
+ case .sealed: return "Signed in — your spaces unlock with your passkey."
case .syncing: return "Syncing your spaces…"
case .idle, .working: return nil
}
diff --git a/ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift b/ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift
new file mode 100644
index 0000000..1956af8
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/HomeAnchorAuth.swift
@@ -0,0 +1,72 @@
+import AuthenticationServices
+import Foundation
+
+// 🤖 The identity anchors — Sign in with Apple and email codes (issues
+// #1091/#1112) — as native doors. An anchor verify answers exactly like a
+// passkey ceremony finish ({token, proof, created}), but no PRF is in play,
+// so an anchor session AUTHENTICATES and can never DECRYPT: the backup blob
+// stays sealed until a passkey sign-in supplies the PRF. These are the small
+// client calls over home's endpoints (siwa.go / email.go), riding
+// HomeClient's base + plumbing; sheet presentation belongs to each
+// platform's UI (SwiftUI's SignInWithAppleButton on iOS).
+
+extension HomeClient {
+ /// What every anchor verify answers — the same shape as a ceremony
+ /// finish, plus whether the account was created on the spot (the
+ /// seamless front door: an unknown Apple ID / address becomes a fresh
+ /// account, no separate registration).
+ public struct AnchorFinish: Decodable {
+ public let token: String
+ public let proof: String?
+ public let created: Bool?
+ }
+
+ /// POST /api/siwa/verify — Apple's signed identity token in, a session
+ /// out. The nonce must be the very string the sheet's request carried;
+ /// home compares the token's nonce claim against it verbatim.
+ public func siwaVerify(identityToken: String, nonce: String) async throws -> AnchorFinish {
+ try await postJSON("/api/siwa/verify",
+ ["identity_token": identityToken, "nonce": nonce])
+ }
+
+ /// POST /api/email/request — send the 6-digit code (15-minute,
+ /// single-use). The server answers the same 200 known-or-not (no
+ /// account enumeration), so success only ever means "sent".
+ public func emailRequest(email: String) async throws {
+ struct Sent: Decodable { let sent: Bool? }
+ let _: Sent = try await postJSON("/api/email/request", ["email": email])
+ }
+
+ /// POST /api/email/verify — the typed code in, a session out.
+ public func emailVerify(email: String, code: String) async throws -> AnchorFinish {
+ try await postJSON("/api/email/verify", ["email": email, "code": code])
+ }
+
+ private func postJSON<T: Decodable>(_ path: String, _ body: [String: String]) async throws -> T {
+ var req = URLRequest(url: base.appending(path: path))
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.httpBody = try JSONEncoder().encode(body)
+ let (data, resp) = try await URLSession.shared.data(for: req)
+ try Self.ensureOK(resp, data)
+ return try JSONDecoder().decode(T.self, from: data)
+ }
+}
+
+/// The Apple door's non-UI halves: the nonce and the token extraction.
+public enum HomeAnchorAuth {
+ /// 32 random bytes as hex — set as the Apple request's nonce AND sent in
+ /// the verify body, the SAME string both times (home compares the
+ /// identity token's nonce claim verbatim; no hashing convention rides in
+ /// between, unlike some SiwA integrations).
+ public static func mintNonce() -> String {
+ var bytes = [UInt8](repeating: 0, count: 32)
+ _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
+ return bytes.map { String(format: "%02x", $0) }.joined()
+ }
+
+ /// The signed JWT out of Apple's credential (UTF-8 bytes on the wire).
+ public static func identityToken(from credential: ASAuthorizationAppleIDCredential) -> String? {
+ credential.identityToken.flatMap { String(data: $0, encoding: .utf8) }
+ }
+}
diff --git a/ios/LChatCore/Sources/LChatCore/HomeSync.swift b/ios/LChatCore/Sources/LChatCore/HomeSync.swift
index d1c5865..a5164a3 100644
--- a/ios/LChatCore/Sources/LChatCore/HomeSync.swift
+++ b/ios/LChatCore/Sources/LChatCore/HomeSync.swift
@@ -32,7 +32,8 @@ public struct HomeSession: Codable {
public var dekB64: String // the backup blob's DEK (base64url raw bytes)
public var dek: SymmetricKey? {
- Data(base64urlEncoded: dekB64).map(SymmetricKey.init(data:))
+ guard !dekB64.isEmpty else { return nil } // anchor sessions carry no DEK
+ return Data(base64urlEncoded: dekB64).map(SymmetricKey.init(data:))
}
public init(token: String, credentialID: String, dek: SymmetricKey) {
@@ -40,6 +41,17 @@ public struct HomeSession: Codable {
self.credentialID = credentialID
self.dekB64 = dek.withUnsafeBytes { Data($0) }.base64urlEncodedString()
}
+
+ /// An anchor session (Sign in with Apple / an email code): the link
+ /// token alone. It authenticates every home call, but no passkey PRF was
+ /// in play, so there is no DEK — vault sync parks (pull/push quietly
+ /// no-op on the nil dek) until a passkey sign-in upgrades this session
+ /// in place.
+ public init(token: String) {
+ self.token = token
+ self.credentialID = ""
+ self.dekB64 = ""
+ }
}
public enum HomeSessionStore {