package main // ๐Ÿค– Sign in with Apple โ€” the identity-anchor front door (issue #1089; design: // docs/accounts-and-recovery.md ยง "Sign in with Apple, precisely"). // // SiwA is an IDENTITY anchor: it authenticates a person to home and hands back // the same things a passkey ceremony does โ€” a device link token and a fresh // ceremony proof โ€” and nothing else. It can never decrypt: the vault stays a // sealed blob, and the DEK that opens it lives client-side (a synced-keychain // item on Apple platforms, gated behind a fresh SiwA assertion โ€” job 1 gates // job 2). Instances never see any of this; membership stays anonymous. // // One verification path serves every platform: iOS's native sheet and the // Mac/web ASWebAuthenticationSession flow both yield the same Apple identity // token (an RS256 JWT), POSTed to /api/siwa/verify. The web flow's form_post // lands on /siwa/return, a dumb trampoline that bounces the token to the app โ€” // deliberately without verifying anything, so there is exactly one verifier. // // The account key is HMAC(pepper, sub) โ€” nothing mailable, nothing to leak; // Apple's stable team-scoped sub never touches the store in the clear. The // pepper (LCHAT_HOME_SIWA_PEPPER) must be DURABLE: unlike the session key it // keys accounts, so there is no ephemeral fallback โ€” unset means SiwA is off. // Verification is hand-rolled (split/decode/RSA-verify, the apns.go style) // rather than a JWT dependency โ€” and since #1112 that hand-rolled verifier is // the shared OIDC core in oidc.go (verifyIDToken + jwksCache), with Apple's // row supplying only its issuer check. Apple deliberately keeps its own // routes, env vars, and siwa_anchors store rows: deployed clients speak // /api/siwa/*, and nothing here may change on the wire. import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/http" "net/url" "strings" "time" ) const ( appleJWKSURL = "https://appleid.apple.com/auth/keys" appleIssuer = "https://appleid.apple.com" // siwaReturnScheme is where the web-flow trampoline bounces the token โ€” // the same custom scheme family as the browser-assisted passkey handoff // (restore.js's elevenmessenger://home-session). siwaReturnScheme = "elevenmessenger://home-siwa" ) // verifySiwaToken checks an Apple identity token end to end โ€” signature // against Apple's JWKS, then issuer, audience (one of our client ids: the // iOS bundle id or the web Services ID), expiry, and the nonce when the // caller bound one โ€” and returns Apple's stable `sub`. The mechanics live in // the shared OIDC core (oidc.go); Apple's row is just its fixed issuer. func verifySiwaToken(jwks *jwksCache, token string, clientIDs []string, nonce string, now time.Time) (string, error) { return verifyIDToken(jwks, token, func(c oidcClaims) error { if c.Iss != appleIssuer { return errors.New("wrong issuer") } return nil }, clientIDs, nonce, now) } func (s *Server) siwaEnabled() bool { return len(s.siwaClientIDs) > 0 && len(s.siwaPepper) > 0 } func (s *Server) siwaSubHash(sub string) string { m := hmac.New(sha256.New, s.siwaPepper) m.Write([]byte(sub)) return hex.EncodeToString(m.Sum(nil)) } // siwaVerify is POST /api/siwa/verify โ€” the one verifier for every platform. // Sign-in (default): resolve-or-create the account behind the hashed sub and // answer exactly like finishCeremony (token + proof; a fresh Apple assertion // is the same "I just proved it's me" moment as a passkey ceremony โ€” job 1). // Link (`"link":true` + X-Ceremony-Proof): attach the Apple anchor to the // proof's existing account instead, the same freshness bar as adding a // passkey. func (s *Server) siwaVerify(w http.ResponseWriter, r *http.Request) { if !s.siwaEnabled() { http.Error(w, "not found", http.StatusNotFound) return } var req struct { IdentityToken string `json:"identity_token"` Nonce string `json:"nonce"` Link bool `json:"link"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } sub, err := verifySiwaToken(s.siwa, req.IdentityToken, s.siwaClientIDs, req.Nonce, time.Now()) if err != nil { http.Error(w, "That sign-in couldn't be confirmed", http.StatusUnauthorized) return } subHash := s.siwaSubHash(sub) if req.Link { uid, ok := s.ceremonyUser(r) if !ok { http.Error(w, "Confirm it's you first", http.StatusUnauthorized) return } conflict, err := s.store.insertSiwaAnchor(uid, subHash) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if conflict { http.Error(w, "That Apple ID already signs in somewhere else", http.StatusConflict) return } writeJSON(w, http.StatusOK, map[string]any{"linked": true}) return } uid, ok := s.store.siwaUser(subHash) created := false if !ok { // The seamless front door: an unknown Apple ID becomes a fresh // account on the spot โ€” no separate registration ceremony. The vault // starts empty; the client builds and PUTs the first sealed blob. id, err := randToken() if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if err := s.store.createUser(id); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } if _, err := s.store.insertSiwaAnchor(id, subHash); err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } uid, created = id, true } tok, err := s.store.createLinkToken(uid) if err != nil { http.Error(w, "db error", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]any{ "token": tok, "proof": s.keys.mintCeremonyProof(uid), "created": created, }) } // siwaReturn is POST /siwa/return โ€” where Apple's web flow form_posts. It is // a TRAMPOLINE, nothing more: bounce the fields to the app over the custom // scheme (ASWebAuthenticationSession intercepts it) and let the app POST the // token to /api/siwa/verify like every other caller. Verifying here would // mean two verifiers; the fragment keeps the token out of request logs. func (s *Server) siwaReturn(w http.ResponseWriter, r *http.Request) { if !s.siwaEnabled() { http.Error(w, "not found", http.StatusNotFound) return } r.Body = http.MaxBytesReader(w, r.Body, 64<<10) if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } idToken := r.PostFormValue("id_token") state := r.PostFormValue("state") if idToken == "" || len(idToken) > 8192 || len(state) > 512 { http.Error(w, "bad request", http.StatusBadRequest) return } // A "web."-prefixed state was minted by the home page itself (home.js): // bounce back to the page โ€” a relative redirect, same origin โ€” and it // picks the fragment up on load. Every other state is a native app's // ASWebAuthenticationSession, which intercepts the custom scheme. if strings.HasPrefix(state, "web.") { http.Redirect(w, r, "/#siwa="+url.QueryEscape(idToken)+ "&state="+url.QueryEscape(state), http.StatusSeeOther) return } http.Redirect(w, r, siwaReturnScheme+"#state="+url.QueryEscape(state)+ "&id_token="+url.QueryEscape(idToken), http.StatusSeeOther) }