eleven / messenger Public

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>
Paul Campbell a695ddf17da1eb69b2cbb7a8b8f70736054a40e4 parent 11bc686
6 files changed, +690 −0
  • home/db.go +34 −0
  • home/main.go +30 −0
  • home/s3store.go +58 −0
  • home/siwa.go +310 −0
  • home/siwa_test.go +248 −0
  • home/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.
}