| 1 | package main |
| 2 | |
| 3 | // π€ Sign in with Apple β the identity-anchor front door (issue #1089; design: |
| 4 | // docs/accounts-and-recovery.md Β§ "Sign in with Apple, precisely"). |
| 5 | // |
| 6 | // SiwA is an IDENTITY anchor: it authenticates a person to home and hands back |
| 7 | // the same things a passkey ceremony does β a device link token and a fresh |
| 8 | // ceremony proof β and nothing else. It can never decrypt: the vault stays a |
| 9 | // sealed blob, and the DEK that opens it lives client-side (a synced-keychain |
| 10 | // item on Apple platforms, gated behind a fresh SiwA assertion β job 1 gates |
| 11 | // job 2). Instances never see any of this; membership stays anonymous. |
| 12 | // |
| 13 | // One verification path serves every platform: iOS's native sheet and the |
| 14 | // Mac/web ASWebAuthenticationSession flow both yield the same Apple identity |
| 15 | // token (an RS256 JWT), POSTed to /api/siwa/verify. The web flow's form_post |
| 16 | // lands on /siwa/return, a dumb trampoline that bounces the token to the app β |
| 17 | // deliberately without verifying anything, so there is exactly one verifier. |
| 18 | // |
| 19 | // The account key is HMAC(pepper, sub) β nothing mailable, nothing to leak; |
| 20 | // Apple's stable team-scoped sub never touches the store in the clear. The |
| 21 | // pepper (LCHAT_HOME_SIWA_PEPPER) must be DURABLE: unlike the session key it |
| 22 | // keys accounts, so there is no ephemeral fallback β unset means SiwA is off. |
| 23 | // Verification is hand-rolled (split/decode/RSA-verify, the apns.go style) |
| 24 | // rather than a JWT dependency β and since #1112 that hand-rolled verifier is |
| 25 | // the shared OIDC core in oidc.go (verifyIDToken + jwksCache), with Apple's |
| 26 | // row supplying only its issuer check. Apple deliberately keeps its own |
| 27 | // routes, env vars, and siwa_anchors store rows: deployed clients speak |
| 28 | // /api/siwa/*, and nothing here may change on the wire. |
| 29 | |
| 30 | import ( |
| 31 | "crypto/hmac" |
| 32 | "crypto/sha256" |
| 33 | "encoding/hex" |
| 34 | "encoding/json" |
| 35 | "errors" |
| 36 | "net/http" |
| 37 | "net/url" |
| 38 | "strings" |
| 39 | "time" |
| 40 | ) |
| 41 | |
| 42 | const ( |
| 43 | appleJWKSURL = "https://appleid.apple.com/auth/keys" |
| 44 | appleIssuer = "https://appleid.apple.com" |
| 45 | // siwaReturnScheme is where the web-flow trampoline bounces the token β |
| 46 | // the same custom scheme family as the browser-assisted passkey handoff |
| 47 | // (restore.js's elevenmessenger://home-session). |
| 48 | siwaReturnScheme = "elevenmessenger://home-siwa" |
| 49 | ) |
| 50 | |
| 51 | // verifySiwaToken checks an Apple identity token end to end β signature |
| 52 | // against Apple's JWKS, then issuer, audience (one of our client ids: the |
| 53 | // iOS bundle id or the web Services ID), expiry, and the nonce when the |
| 54 | // caller bound one β and returns Apple's stable `sub`. The mechanics live in |
| 55 | // the shared OIDC core (oidc.go); Apple's row is just its fixed issuer. |
| 56 | func verifySiwaToken(jwks *jwksCache, token string, clientIDs []string, nonce string, now time.Time) (string, error) { |
| 57 | return verifyIDToken(jwks, token, func(c oidcClaims) error { |
| 58 | if c.Iss != appleIssuer { |
| 59 | return errors.New("wrong issuer") |
| 60 | } |
| 61 | return nil |
| 62 | }, clientIDs, nonce, now) |
| 63 | } |
| 64 | |
| 65 | func (s *Server) siwaEnabled() bool { |
| 66 | return len(s.siwaClientIDs) > 0 && len(s.siwaPepper) > 0 |
| 67 | } |
| 68 | |
| 69 | func (s *Server) siwaSubHash(sub string) string { |
| 70 | m := hmac.New(sha256.New, s.siwaPepper) |
| 71 | m.Write([]byte(sub)) |
| 72 | return hex.EncodeToString(m.Sum(nil)) |
| 73 | } |
| 74 | |
| 75 | // siwaVerify is POST /api/siwa/verify β the one verifier for every platform. |
| 76 | // Sign-in (default): resolve-or-create the account behind the hashed sub and |
| 77 | // answer exactly like finishCeremony (token + proof; a fresh Apple assertion |
| 78 | // is the same "I just proved it's me" moment as a passkey ceremony β job 1). |
| 79 | // Link (`"link":true` + X-Ceremony-Proof): attach the Apple anchor to the |
| 80 | // proof's existing account instead, the same freshness bar as adding a |
| 81 | // passkey. |
| 82 | func (s *Server) siwaVerify(w http.ResponseWriter, r *http.Request) { |
| 83 | if !s.siwaEnabled() { |
| 84 | http.Error(w, "not found", http.StatusNotFound) |
| 85 | return |
| 86 | } |
| 87 | var req struct { |
| 88 | IdentityToken string `json:"identity_token"` |
| 89 | Nonce string `json:"nonce"` |
| 90 | Link bool `json:"link"` |
| 91 | } |
| 92 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&req); err != nil { |
| 93 | http.Error(w, "bad request", http.StatusBadRequest) |
| 94 | return |
| 95 | } |
| 96 | sub, err := verifySiwaToken(s.siwa, req.IdentityToken, s.siwaClientIDs, req.Nonce, time.Now()) |
| 97 | if err != nil { |
| 98 | http.Error(w, "That sign-in couldn't be confirmed", http.StatusUnauthorized) |
| 99 | return |
| 100 | } |
| 101 | subHash := s.siwaSubHash(sub) |
| 102 | |
| 103 | if req.Link { |
| 104 | uid, ok := s.ceremonyUser(r) |
| 105 | if !ok { |
| 106 | http.Error(w, "Confirm it's you first", http.StatusUnauthorized) |
| 107 | return |
| 108 | } |
| 109 | conflict, err := s.store.insertSiwaAnchor(uid, subHash) |
| 110 | if err != nil { |
| 111 | http.Error(w, "db error", http.StatusInternalServerError) |
| 112 | return |
| 113 | } |
| 114 | if conflict { |
| 115 | http.Error(w, "That Apple ID already signs in somewhere else", http.StatusConflict) |
| 116 | return |
| 117 | } |
| 118 | writeJSON(w, http.StatusOK, map[string]any{"linked": true}) |
| 119 | return |
| 120 | } |
| 121 | |
| 122 | uid, ok := s.store.siwaUser(subHash) |
| 123 | created := false |
| 124 | if !ok { |
| 125 | // The seamless front door: an unknown Apple ID becomes a fresh |
| 126 | // account on the spot β no separate registration ceremony. The vault |
| 127 | // starts empty; the client builds and PUTs the first sealed blob. |
| 128 | id, err := randToken() |
| 129 | if err != nil { |
| 130 | http.Error(w, "db error", http.StatusInternalServerError) |
| 131 | return |
| 132 | } |
| 133 | if err := s.store.createUser(id); err != nil { |
| 134 | http.Error(w, "db error", http.StatusInternalServerError) |
| 135 | return |
| 136 | } |
| 137 | if _, err := s.store.insertSiwaAnchor(id, subHash); err != nil { |
| 138 | http.Error(w, "db error", http.StatusInternalServerError) |
| 139 | return |
| 140 | } |
| 141 | uid, created = id, true |
| 142 | } |
| 143 | tok, err := s.store.createLinkToken(uid) |
| 144 | if err != nil { |
| 145 | http.Error(w, "db error", http.StatusInternalServerError) |
| 146 | return |
| 147 | } |
| 148 | writeJSON(w, http.StatusOK, map[string]any{ |
| 149 | "token": tok, |
| 150 | "proof": s.keys.mintCeremonyProof(uid), |
| 151 | "created": created, |
| 152 | }) |
| 153 | } |
| 154 | |
| 155 | // siwaReturn is POST /siwa/return β where Apple's web flow form_posts. It is |
| 156 | // a TRAMPOLINE, nothing more: bounce the fields to the app over the custom |
| 157 | // scheme (ASWebAuthenticationSession intercepts it) and let the app POST the |
| 158 | // token to /api/siwa/verify like every other caller. Verifying here would |
| 159 | // mean two verifiers; the fragment keeps the token out of request logs. |
| 160 | func (s *Server) siwaReturn(w http.ResponseWriter, r *http.Request) { |
| 161 | if !s.siwaEnabled() { |
| 162 | http.Error(w, "not found", http.StatusNotFound) |
| 163 | return |
| 164 | } |
| 165 | r.Body = http.MaxBytesReader(w, r.Body, 64<<10) |
| 166 | if err := r.ParseForm(); err != nil { |
| 167 | http.Error(w, "bad request", http.StatusBadRequest) |
| 168 | return |
| 169 | } |
| 170 | idToken := r.PostFormValue("id_token") |
| 171 | state := r.PostFormValue("state") |
| 172 | if idToken == "" || len(idToken) > 8192 || len(state) > 512 { |
| 173 | http.Error(w, "bad request", http.StatusBadRequest) |
| 174 | return |
| 175 | } |
| 176 | // A "web."-prefixed state was minted by the home page itself (home.js): |
| 177 | // bounce back to the page β a relative redirect, same origin β and it |
| 178 | // picks the fragment up on load. Every other state is a native app's |
| 179 | // ASWebAuthenticationSession, which intercepts the custom scheme. |
| 180 | if strings.HasPrefix(state, "web.") { |
| 181 | http.Redirect(w, r, "/#siwa="+url.QueryEscape(idToken)+ |
| 182 | "&state="+url.QueryEscape(state), http.StatusSeeOther) |
| 183 | return |
| 184 | } |
| 185 | http.Redirect(w, r, siwaReturnScheme+"#state="+url.QueryEscape(state)+ |
| 186 | "&id_token="+url.QueryEscape(idToken), http.StatusSeeOther) |
| 187 | } |
| 188 | |