home: Sign in with Apple — the identity-anchor front door (#1089) (#1090)
The one verifier for every platform: POST /api/siwa/verify checks Apple's identity token (hand-rolled RS256 + JWKS cache, the apns.go style — no new deps), keys accounts on HMAC(pepper, sub), and answers exactly like finishCeremony (link token + ceremony proof). link:true + X-Ceremony-Proof anchors an Apple ID to an existing account (same freshness bar as adding a passkey; one account per Apple ID, 409 on conflict). POST /siwa/return is the web flow's form_post trampoline — bounces the token to the app over the custom scheme, verifying nothing, so there is exactly one verifier. siwa/<subHash> anchors land in both stores (SQLite table + S3 pointer/doc pair). Feature is off unless BOTH LCHAT_HOME_SIWA_CLIENT_IDS and LCHAT_HOME_SIWA_PEPPER are set — the pepper names accounts, so no ephemeral fallback exists by design.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+690
−0
home/db.go+34 −0home/main.go+30 −0home/s3store.go+58 −0home/siwa.go+310 −0home/siwa_test.go+248 −0home/store.go+10 −0
diff --git a/home/db.go b/home/db.go| index 8308a2e..c4a7dcb 100644 |
| --- a/home/db.go |
| +++ b/home/db.go |
| @@ -7,6 +7,7 @@ import ( |
| "encoding/base64" |
| "encoding/hex" |
| "encoding/json" |
| + "errors" |
| "strconv" |
| "time" |
| @@ -90,10 +91,43 @@ CREATE TABLE IF NOT EXISTS ceremony_proofs ( |
| proof_hash TEXT PRIMARY KEY, |
| user_id TEXT NOT NULL, |
| created_at INTEGER NOT NULL |
| +); |
| +-- Sign in with Apple anchors (siwa.go): the HMAC(pepper, sub) hash is the |
| +-- only trace of the Apple identity — nothing mailable, nothing reversible. |
| +CREATE TABLE IF NOT EXISTS siwa_anchors ( |
| + sub_hash TEXT PRIMARY KEY, |
| + user_id TEXT NOT NULL, |
| + created_at INTEGER NOT NULL |
| );`) |
| return err |
| } |
| +func (s *sqliteStore) siwaUser(subHash string) (string, bool) { |
| + var uid string |
| + err := s.sql.QueryRow(`SELECT user_id FROM siwa_anchors WHERE sub_hash = ?`, subHash).Scan(&uid) |
| + if err != nil { |
| + return "", false |
| + } |
| + return uid, true |
| +} |
| + |
| +func (s *sqliteStore) insertSiwaAnchor(uid, subHash string) (bool, error) { |
| + // ON CONFLICT DO NOTHING + re-read: the insert either won the row or |
| + // someone holds it — same-user is idempotent, different-user is the |
| + // conflict the handler turns into a 409. |
| + if _, err := s.sql.Exec( |
| + `INSERT INTO siwa_anchors (sub_hash, user_id, created_at) VALUES (?, ?, ?) |
| + ON CONFLICT (sub_hash) DO NOTHING`, |
| + subHash, uid, time.Now().Unix()); err != nil { |
| + return false, err |
| + } |
| + owner, ok := s.siwaUser(subHash) |
| + if !ok { |
| + return false, errors.New("siwa anchor vanished") |
| + } |
| + return owner != uid, nil |
| +} |
| + |
| func hashToken(t string) string { |
| h := sha256.Sum256([]byte(t)) |
| return hex.EncodeToString(h[:]) |
diff --git a/home/main.go b/home/main.go| index 8147c9b..60edb35 100644 |
| --- a/home/main.go |
| +++ b/home/main.go |
| @@ -49,6 +49,12 @@ type Server struct { |
| // limiter rate-limits the ceremony begin endpoints per IP — the abuse |
| // surface of an open, anonymous registration endpoint. |
| limiter *rateLimiter |
| + // Sign in with Apple (siwa.go): the accepted token audiences (iOS bundle |
| + // id + web Services ID), the account-keying pepper, and Apple's JWKS |
| + // cache. Both empty/nil → the endpoints answer 404 (feature off). |
| + siwaClientIDs []string |
| + siwaPepper []byte |
| + siwa *jwksCache |
| // cloud is the optional space provisioner client (see provision.go); nil |
| // means this home server doesn't offer space creation. When regions is |
| // non-empty (per-country provisioning), this points at the DEFAULT region's |
| @@ -121,6 +127,26 @@ func main() { |
| if err != nil { |
| log.Fatalf("server: %v", err) |
| } |
| + // Sign in with Apple: LCHAT_HOME_SIWA_CLIENT_IDS (comma-separated token |
| + // audiences — the iOS bundle id and the web Services ID) turns the |
| + // feature on, and LCHAT_HOME_SIWA_PEPPER keys accounts as |
| + // HMAC(pepper, sub). The pepper is env-only (a secret) and, unlike the |
| + // session key, must be DURABLE — it names accounts, so an ephemeral |
| + // fallback would orphan every SiwA account on restart. Both or neither. |
| + siwaClients, siwaPepper := os.Getenv("LCHAT_HOME_SIWA_CLIENT_IDS"), os.Getenv("LCHAT_HOME_SIWA_PEPPER") |
| + if (siwaClients == "") != (siwaPepper == "") { |
| + log.Fatalf("Sign in with Apple needs BOTH LCHAT_HOME_SIWA_CLIENT_IDS and LCHAT_HOME_SIWA_PEPPER (or neither)") |
| + } |
| + if siwaClients != "" { |
| + for _, c := range strings.Split(siwaClients, ",") { |
| + if c = strings.TrimSpace(c); c != "" { |
| + s.siwaClientIDs = append(s.siwaClientIDs, c) |
| + } |
| + } |
| + s.siwaPepper = []byte(siwaPepper) |
| + s.siwa = &jwksCache{url: appleJWKSURL} |
| + log.Printf("Sign in with Apple on (%d client ids)", len(s.siwaClientIDs)) |
| + } |
| if s.cloud = newCloudProvisioner(*cloudURL, *cloudToken); s.cloud != nil { |
| log.Printf("space provisioning via %s", s.cloud.url) |
| } |
| @@ -307,6 +333,10 @@ func (s *Server) handler() http.Handler { |
| mux.HandleFunc("POST /api/register/finish", s.registerFinish) |
| mux.HandleFunc("POST /api/login/begin", s.rateLimited(s.loginBegin)) |
| mux.HandleFunc("POST /api/login/finish", s.loginFinish) |
| + // Sign in with Apple (siwa.go): the one verifier every platform posts |
| + // Apple's identity token to, and the web flow's form_post trampoline. |
| + mux.HandleFunc("POST /api/siwa/verify", s.rateLimited(s.siwaVerify)) |
| + mux.HandleFunc("POST /siwa/return", s.rateLimited(s.siwaReturn)) |
| // Enrolment completion oracle (enrolstatus.go): /enroll reports a finished |
| // ceremony; the instance page that launched it polls for the answer. The |
diff --git a/home/s3store.go b/home/s3store.go| index 6aa1476..290973d 100644 |
| --- a/home/s3store.go |
| +++ b/home/s3store.go |
| @@ -183,6 +183,7 @@ func (s *s3Store) blobKey(uid string) string { return s.prefix + "blobs/" + uid |
| func (s *s3Store) userKey(uid string) string { return s.prefix + "users/" + uid } |
| func (s *s3Store) credKey(id string) string { return s.prefix + "creds/" + id } |
| func (s *s3Store) tokenKey(h string) string { return s.prefix + "tokens/" + h } |
| +func (s *s3Store) siwaKey(h string) string { return s.prefix + "siwa/" + h } |
| // ---- the blob ----------------------------------------------------------------- |
| @@ -361,6 +362,15 @@ func (s *s3Store) listVersions(prefix string) ([]s3Version, error) { |
| type s3UserDoc struct { |
| Credentials []webauthn.Credential `json:"credentials"` |
| Devices []s3Device `json:"devices"` |
| + // SiwaAnchors lists the account's Sign in with Apple sub hashes — the |
| + // doc-side half of the siwa/<subHash> pointers (pointers never decide; |
| + // see the vaultStore contract). |
| + SiwaAnchors []s3SiwaAnchor `json:"siwa_anchors,omitempty"` |
| +} |
| + |
| +type s3SiwaAnchor struct { |
| + SubHash string `json:"sub_hash"` |
| + CreatedAt int64 `json:"created_at"` |
| } |
| type s3Device struct { |
| @@ -623,6 +633,54 @@ func (s *s3Store) createLinkToken(uid string) (string, error) { |
| return tok, nil |
| } |
| +// siwaUser resolves siwa/<subHash> and verifies the anchor is still listed in |
| +// the user doc — a stale pointer left by a future unlink denies by |
| +// construction, per the vaultStore contract. |
| +func (s *s3Store) siwaUser(subHash string) (string, bool) { |
| + uid, found, err := s.getPointer(s.siwaKey(subHash)) |
| + if err != nil || !found { |
| + return "", false |
| + } |
| + doc, _, found, err := s.loadUser(uid) |
| + if err != nil || !found { |
| + return "", false |
| + } |
| + for _, a := range doc.SiwaAnchors { |
| + if a.SubHash == subHash { |
| + return uid, true |
| + } |
| + } |
| + return "", false |
| +} |
| + |
| +// insertSiwaAnchor writes pointer first, doc entry second (same ordering |
| +// rationale as createLinkToken: an orphaned pointer denies; a doc entry |
| +// without its pointer would be an unusable anchor). |
| +func (s *s3Store) insertSiwaAnchor(uid, subHash string) (bool, error) { |
| + if owner, found, err := s.getPointer(s.siwaKey(subHash)); err != nil { |
| + return false, err |
| + } else if found && owner != uid { |
| + return true, nil |
| + } |
| + if err := s.putPointer(s.siwaKey(subHash), uid); err != nil { |
| + return false, err |
| + } |
| + now := time.Now().Unix() |
| + err := s.updateUser(uid, func(doc *s3UserDoc) (bool, error) { |
| + for _, a := range doc.SiwaAnchors { |
| + if a.SubHash == subHash { |
| + return false, nil // already listed — idempotent |
| + } |
| + } |
| + doc.SiwaAnchors = append(doc.SiwaAnchors, s3SiwaAnchor{SubHash: subHash, CreatedAt: now}) |
| + return true, nil |
| + }) |
| + if err != nil { |
| + return false, err |
| + } |
| + return false, nil |
| +} |
| + |
| // linkTokenUser resolves tokens/<hash> and verifies the device is still in |
| // the user doc (revocation = removal from the doc; the pointer never decides). |
| // last_seen bumps only when more than lastSeenGranularity stale — that |
diff --git a/home/siwa.go b/home/siwa.go| new file mode 100644 |
| index 0000000..3c166fa |
| --- /dev/null |
| +++ b/home/siwa.go |
| @@ -0,0 +1,310 @@ |
| +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: ~sixty lines a person can read whole. |
| + |
| +import ( |
| + "crypto" |
| + "crypto/hmac" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "math/big" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "sync" |
| + "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" |
| +) |
| + |
| +var siwaHTTP = &http.Client{Timeout: 10 * time.Second} |
| + |
| +// jwksCache holds Apple's signing keys by kid. Unknown kids trigger a |
| +// refetch (key rotation), floored at one fetch a minute so a garbage kid |
| +// can't become a fetch amplifier. |
| +type jwksCache struct { |
| + url string |
| + |
| + mu sync.Mutex |
| + keys map[string]*rsa.PublicKey |
| + fetched time.Time |
| +} |
| + |
| +func (c *jwksCache) key(kid string) (*rsa.PublicKey, error) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + if k, ok := c.keys[kid]; ok { |
| + return k, nil |
| + } |
| + if c.keys != nil && time.Since(c.fetched) < time.Minute { |
| + return nil, errors.New("unknown key id") |
| + } |
| + resp, err := siwaHTTP.Get(c.url) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + return nil, errors.New("jwks fetch failed") |
| + } |
| + var doc struct { |
| + Keys []struct { |
| + Kty string `json:"kty"` |
| + Kid string `json:"kid"` |
| + N string `json:"n"` |
| + E string `json:"e"` |
| + } `json:"keys"` |
| + } |
| + if err := json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 64<<10)).Decode(&doc); err != nil { |
| + return nil, err |
| + } |
| + keys := make(map[string]*rsa.PublicKey, len(doc.Keys)) |
| + for _, k := range doc.Keys { |
| + if k.Kty != "RSA" { |
| + continue |
| + } |
| + nb, err := base64.RawURLEncoding.DecodeString(k.N) |
| + if err != nil { |
| + continue |
| + } |
| + eb, err := base64.RawURLEncoding.DecodeString(k.E) |
| + if err != nil { |
| + continue |
| + } |
| + keys[k.Kid] = &rsa.PublicKey{ |
| + N: new(big.Int).SetBytes(nb), |
| + E: int(new(big.Int).SetBytes(eb).Int64()), |
| + } |
| + } |
| + c.keys, c.fetched = keys, time.Now() |
| + if k, ok := keys[kid]; ok { |
| + return k, nil |
| + } |
| + return nil, errors.New("unknown key id") |
| +} |
| + |
| +// 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`. |
| +func verifySiwaToken(jwks *jwksCache, token string, clientIDs []string, nonce string, now time.Time) (string, error) { |
| + parts := strings.Split(token, ".") |
| + if len(parts) != 3 { |
| + return "", errors.New("malformed token") |
| + } |
| + headB, err := base64.RawURLEncoding.DecodeString(parts[0]) |
| + if err != nil { |
| + return "", errors.New("malformed header") |
| + } |
| + var head struct { |
| + Alg string `json:"alg"` |
| + Kid string `json:"kid"` |
| + } |
| + if err := json.Unmarshal(headB, &head); err != nil || head.Alg != "RS256" { |
| + return "", errors.New("unsupported algorithm") |
| + } |
| + key, err := jwks.key(head.Kid) |
| + if err != nil { |
| + return "", err |
| + } |
| + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| + if err != nil { |
| + return "", errors.New("malformed signature") |
| + } |
| + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) |
| + if err := rsa.VerifyPKCS1v15(key, crypto.SHA256, digest[:], sig); err != nil { |
| + return "", errors.New("bad signature") |
| + } |
| + claimsB, err := base64.RawURLEncoding.DecodeString(parts[1]) |
| + if err != nil { |
| + return "", errors.New("malformed claims") |
| + } |
| + var claims struct { |
| + Iss string `json:"iss"` |
| + Aud json.RawMessage `json:"aud"` // string or []string, per RFC 7519 |
| + Exp int64 `json:"exp"` |
| + Sub string `json:"sub"` |
| + Nonce string `json:"nonce"` |
| + } |
| + if err := json.Unmarshal(claimsB, &claims); err != nil { |
| + return "", errors.New("malformed claims") |
| + } |
| + if claims.Iss != appleIssuer { |
| + return "", errors.New("wrong issuer") |
| + } |
| + if now.Unix() >= claims.Exp { |
| + return "", errors.New("expired") |
| + } |
| + var auds []string |
| + var one string |
| + if json.Unmarshal(claims.Aud, &one) == nil { |
| + auds = []string{one} |
| + } else if json.Unmarshal(claims.Aud, &auds) != nil { |
| + return "", errors.New("malformed audience") |
| + } |
| + okAud := false |
| + for _, a := range auds { |
| + for _, c := range clientIDs { |
| + if a == c { |
| + okAud = true |
| + } |
| + } |
| + } |
| + if !okAud { |
| + return "", errors.New("wrong audience") |
| + } |
| + if nonce != "" && claims.Nonce != nonce { |
| + return "", errors.New("nonce mismatch") |
| + } |
| + if claims.Sub == "" { |
| + return "", errors.New("no subject") |
| + } |
| + return claims.Sub, nil |
| +} |
| + |
| +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 |
| + } |
| + http.Redirect(w, r, siwaReturnScheme+"#state="+url.QueryEscape(state)+ |
| + "&id_token="+url.QueryEscape(idToken), http.StatusSeeOther) |
| +} |
diff --git a/home/siwa_test.go b/home/siwa_test.go| new file mode 100644 |
| index 0000000..04fc8a5 |
| --- /dev/null |
| +++ b/home/siwa_test.go |
| @@ -0,0 +1,248 @@ |
| +package main |
| + |
| +// 🤖 Sign in with Apple tests (siwa.go): a fake Apple — an httptest JWKS |
| +// server and self-minted RS256 identity tokens — exercises the whole path |
| +// with no Apple dependency, matching the seed-state-directly convention. |
| + |
| +import ( |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// fakeApple serves a JWKS for a fresh RSA key and mints identity tokens |
| +// signed with it. |
| +type fakeApple struct { |
| + key *rsa.PrivateKey |
| + ts *httptest.Server |
| +} |
| + |
| +func newFakeApple(t *testing.T) *fakeApple { |
| + t.Helper() |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatalf("rsa: %v", err) |
| + } |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{ |
| + "keys": []map[string]string{{ |
| + "kty": "RSA", "kid": "test-kid", |
| + "n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), |
| + "e": base64.RawURLEncoding.EncodeToString([]byte{1, 0, 1}), |
| + }}, |
| + }) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return &fakeApple{key: key, ts: ts} |
| +} |
| + |
| +// token mints an identity token; overrides patch the default valid claims. |
| +func (a *fakeApple) token(t *testing.T, overrides map[string]any) string { |
| + t.Helper() |
| + claims := map[string]any{ |
| + "iss": appleIssuer, |
| + "aud": "com.elevenmessenger.messenger", |
| + "sub": "apple-sub-1", |
| + "exp": time.Now().Add(5 * time.Minute).Unix(), |
| + "nonce": "", |
| + } |
| + for k, v := range overrides { |
| + claims[k] = v |
| + } |
| + head, _ := json.Marshal(map[string]string{"alg": "RS256", "kid": "test-kid"}) |
| + body, _ := json.Marshal(claims) |
| + signing := base64.RawURLEncoding.EncodeToString(head) + "." + base64.RawURLEncoding.EncodeToString(body) |
| + digest := sha256.Sum256([]byte(signing)) |
| + sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, digest[:]) |
| + if err != nil { |
| + t.Fatalf("sign: %v", err) |
| + } |
| + return signing + "." + base64.RawURLEncoding.EncodeToString(sig) |
| +} |
| + |
| +// siwaServer is newTestServer with SiwA enabled against the fake Apple. |
| +func siwaServer(t *testing.T) (*httptest.Server, *Server, *fakeApple) { |
| + t.Helper() |
| + ts, s := newTestServer(t) |
| + apple := newFakeApple(t) |
| + s.siwaClientIDs = []string{"com.elevenmessenger.messenger", "com.elevenmessenger.signin"} |
| + s.siwaPepper = []byte("test-pepper") |
| + s.siwa = &jwksCache{url: apple.ts.URL} |
| + return ts, s, apple |
| +} |
| + |
| +func siwaVerifyReq(t *testing.T, ts *httptest.Server, headers map[string]string, body map[string]any) *http.Response { |
| + t.Helper() |
| + b, _ := json.Marshal(body) |
| + return do(t, "POST", ts.URL+"/api/siwa/verify", "", headers, b) |
| +} |
| + |
| +func TestSiwaDisabledIs404(t *testing.T) { |
| + ts, _ := newTestServer(t) // no client ids, no pepper |
| + resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": "x"}) |
| + if resp.StatusCode != http.StatusNotFound { |
| + t.Fatalf("disabled siwa: got %d, want 404", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestSiwaSignInCreatesThenResolvesAccount(t *testing.T) { |
| + ts, _, apple := siwaServer(t) |
| + |
| + // First sign-in: a fresh account, token + proof handed back. |
| + resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": apple.token(t, nil)}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("first sign-in: got %d", resp.StatusCode) |
| + } |
| + var first struct { |
| + Token string `json:"token"` |
| + Proof string `json:"proof"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&first); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if first.Token == "" || first.Proof == "" || !first.Created { |
| + t.Fatalf("first sign-in: token=%q proof=%q created=%v", first.Token, first.Proof, first.Created) |
| + } |
| + |
| + // The minted token is a real device link token: it can write the vault. |
| + put := do(t, "PUT", ts.URL+"/api/blob", first.Token, map[string]string{"If-Match": "0"}, []byte("sealed")) |
| + if put.StatusCode != http.StatusOK { |
| + t.Fatalf("blob put with siwa token: got %d", put.StatusCode) |
| + } |
| + |
| + // Second sign-in, same sub: the SAME account (its blob is readable). |
| + resp2 := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": apple.token(t, nil)}) |
| + var second struct { |
| + Token string `json:"token"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp2.Body).Decode(&second); err != nil { |
| + t.Fatalf("decode second: %v", err) |
| + } |
| + if second.Created { |
| + t.Fatalf("second sign-in claims a fresh account") |
| + } |
| + get := do(t, "GET", ts.URL+"/api/blob", second.Token, nil, nil) |
| + if get.StatusCode != http.StatusOK { |
| + t.Fatalf("blob get with second token: got %d", get.StatusCode) |
| + } |
| + if b, _ := io.ReadAll(get.Body); string(b) != "sealed" { |
| + t.Fatalf("second sign-in reads a different vault: %q", b) |
| + } |
| +} |
| + |
| +func TestSiwaRejectsBadTokens(t *testing.T) { |
| + ts, _, apple := siwaServer(t) |
| + cases := map[string]string{ |
| + "wrong issuer": apple.token(t, map[string]any{"iss": "https://evil.example"}), |
| + "wrong audience": apple.token(t, map[string]any{"aud": "com.other.app"}), |
| + "expired": apple.token(t, map[string]any{"exp": time.Now().Add(-time.Minute).Unix()}), |
| + "empty sub": apple.token(t, map[string]any{"sub": ""}), |
| + "garbage": "not.a.jwt", |
| + } |
| + for name, tok := range cases { |
| + resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": tok}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Errorf("%s: got %d, want 401", name, resp.StatusCode) |
| + } |
| + } |
| + // Tampered signature: valid token, one flipped payload byte. |
| + tok := apple.token(t, nil) |
| + tampered := tok[:len(tok)-40] + "AAAA" + tok[len(tok)-36:] |
| + resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": tampered}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Errorf("tampered: got %d, want 401", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestSiwaNonceBinding(t *testing.T) { |
| + ts, _, apple := siwaServer(t) |
| + tok := apple.token(t, map[string]any{"nonce": "expected-nonce"}) |
| + resp := siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": tok, "nonce": "expected-nonce"}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("matching nonce: got %d", resp.StatusCode) |
| + } |
| + resp = siwaVerifyReq(t, ts, nil, map[string]any{"identity_token": tok, "nonce": "different"}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("mismatched nonce: got %d, want 401", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestSiwaLinkToExistingAccount(t *testing.T) { |
| + ts, s, apple := siwaServer(t) |
| + uid, token := seedUser(t, s) |
| + proof := s.keys.mintCeremonyProof(uid) |
| + |
| + // Link needs a fresh ceremony proof — same bar as adding a passkey. |
| + resp := siwaVerifyReq(t, ts, nil, map[string]any{ |
| + "identity_token": apple.token(t, map[string]any{"sub": "link-sub"}), "link": true}) |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("link without proof: got %d, want 401", resp.StatusCode) |
| + } |
| + resp = siwaVerifyReq(t, ts, map[string]string{"X-Ceremony-Proof": proof}, map[string]any{ |
| + "identity_token": apple.token(t, map[string]any{"sub": "link-sub"}), "link": true}) |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("link with proof: got %d", resp.StatusCode) |
| + } |
| + |
| + // Signing in with that Apple ID now lands in the seeded account: its |
| + // vault is the one the seeded link token wrote. |
| + put := do(t, "PUT", ts.URL+"/api/blob", token, map[string]string{"If-Match": "0"}, []byte("seeded-vault")) |
| + if put.StatusCode != http.StatusOK { |
| + t.Fatalf("seed blob: got %d", put.StatusCode) |
| + } |
| + resp = siwaVerifyReq(t, ts, nil, map[string]any{ |
| + "identity_token": apple.token(t, map[string]any{"sub": "link-sub"})}) |
| + var signin struct { |
| + Token string `json:"token"` |
| + Created bool `json:"created"` |
| + } |
| + if err := json.NewDecoder(resp.Body).Decode(&signin); err != nil { |
| + t.Fatalf("decode: %v", err) |
| + } |
| + if signin.Created { |
| + t.Fatalf("sign-in after link created a fresh account") |
| + } |
| + get := do(t, "GET", ts.URL+"/api/blob", signin.Token, nil, nil) |
| + if b, _ := io.ReadAll(get.Body); string(b) != "seeded-vault" { |
| + t.Fatalf("linked sign-in reads a different vault: %q", b) |
| + } |
| + |
| + // The same Apple ID cannot anchor a SECOND account. |
| + uid2, _ := seedUser(t, s) |
| + proof2 := s.keys.mintCeremonyProof(uid2) |
| + resp = siwaVerifyReq(t, ts, map[string]string{"X-Ceremony-Proof": proof2}, map[string]any{ |
| + "identity_token": apple.token(t, map[string]any{"sub": "link-sub"}), "link": true}) |
| + if resp.StatusCode != http.StatusConflict { |
| + t.Fatalf("second-account link: got %d, want 409", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestSiwaReturnTrampoline(t *testing.T) { |
| + ts, _, _ := siwaServer(t) |
| + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { |
| + return http.ErrUseLastResponse |
| + }} |
| + resp, err := client.PostForm(ts.URL+"/siwa/return", map[string][]string{ |
| + "id_token": {"tok.en.value"}, "state": {"st ate"}}) |
| + if err != nil { |
| + t.Fatalf("post: %v", err) |
| + } |
| + if resp.StatusCode != http.StatusSeeOther { |
| + t.Fatalf("trampoline: got %d, want 303", resp.StatusCode) |
| + } |
| + loc := resp.Header.Get("Location") |
| + want := siwaReturnScheme + "#state=st+ate&id_token=tok.en.value" |
| + if loc != want { |
| + t.Fatalf("trampoline location: got %q, want %q", loc, want) |
| + } |
| +} |
diff --git a/home/store.go b/home/store.go| index 20fa367..a03070e 100644 |
| --- a/home/store.go |
| +++ b/home/store.go |
| @@ -98,6 +98,16 @@ type vaultStore interface { |
| // doesn't exist or belongs to someone else. |
| deleteDevice(uid, id string) (ok bool, err error) |
| + // ---- Sign in with Apple anchors (siwa/<subHash> pointers) ---- |
| + |
| + // siwaUser resolves a hashed Apple sub (HMAC(pepper, sub), hex) to its |
| + // user. ok=false when no account carries that anchor. |
| + siwaUser(subHash string) (uid string, ok bool) |
| + // insertSiwaAnchor stores the siwa/<subHash> pointer. Idempotent for the |
| + // same user; conflict=true (nothing written) when the sub is already |
| + // anchored to a DIFFERENT user — an Apple ID signs into one account. |
| + insertSiwaAnchor(uid, subHash string) (conflict bool, err error) |
| + |
| // Ceremony proofs are deliberately NOT here: they became stateless HMAC |
| // tokens (stateless.go) — a store that persists nothing needs no methods. |
| } |