eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package 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
30import (
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
42const (
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.
56func 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
65func (s *Server) siwaEnabled() bool {
66 return len(s.siwaClientIDs) > 0 && len(s.siwaPepper) > 0
67}
68
69func (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.
82func (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.
160func (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