server+web: require proof of the invite secret to claim (#98)
A key-invite could be claimed knowing only its id — which is exactly what the server stores — so a stolen DB let an attacker claim any pending invite and take over that identity. Claiming (and adopting) now requires presenting a claim secret derived from the invite secret T (which never reaches the server); the server stores only its hash and verifies the preimage in constant time. - crypto.js deriveInvite now also returns claimSecret + claimHash (HKDF over T, domain "lchat-invite-claim"; claimHash = sha256hex, which matches Go hashToken). - Every invite creator (admin console, member invite, re-send, add-device) sends claim_hash; every claim/adopt path sends claim_secret. - The server stores claim_hash on key_invites and requires a matching preimage in claimKeyInvite / adoptUserInvite (crypto/subtle constant-time). An invite with no claim_hash is unclaimable — no backward-compat fallback, so any pre-deploy outstanding invite links must be re-created (accepted). Verified: Go tests for enforcement (right/wrong/empty/no-hash) + a JS↔Go cross-implementation hash vector; existing invite-flow tests (fresh join, adopt, add-device, re-send) still pass; all changed JS syntax-checked and the node suite is green. NOTE: no automated e2e covers the browser join flow — run a manual join before deploying.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
12 files changed,
+228
−102
bots_test.go+10 −10db.go+44 −18db_test.go+6 −6handlers_test.go+14 −14invite_claim_test.go+69 −0invites_test.go+16 −16main.go+30 −22push_token_hash_test.go+2 −2web/static/admin.js+2 −1web/static/chat.js+8 −8web/static/crypto.js+17 −2web/static/join.js+10 −3
diff --git a/bots_test.go b/bots_test.go| index f9d3287..bc7f832 100644 |
| --- a/bots_test.go |
| +++ b/bots_test.go |
| @@ -91,8 +91,8 @@ func TestChangelogEndpoint(t *testing.T) { |
| } |
| uid, _ := srv.db.createUser("Paul") |
| - srv.db.createKeyInvite("inv-cl", uid, "", 0) |
| - tok, _, _ := srv.db.claimKeyInvite("inv-cl", "Paul", "") |
| + srv.db.createKeyInvite("inv-cl", uid, "", testClaimHash, 0) |
| + tok, _, _ := srv.db.claimKeyInvite("inv-cl", "Paul", "", testClaimSecret) |
| rr, _ := http.Get(ts.URL + "/api/changelog?token=" + tok) |
| var out struct { |
| Entries []ChangelogEntry `json:"entries"` |
| @@ -118,15 +118,15 @@ func TestHelpersCatalogHTTP(t *testing.T) { |
| // A member with no connection to the bot. |
| paul, _ := srv.db.createUser("Paul") |
| - srv.db.createKeyInvite("inv-paul", paul, "", 0) |
| - tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "") |
| + srv.db.createKeyInvite("inv-paul", paul, "", testClaimHash, 0) |
| + tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "", testClaimSecret) |
| srv.db.setUserPublicKey(paul, "pub-paul") |
| // Admin mints an agent invite with a description, shown to members. |
| r := adminPost(t, ts.URL+"/api/admin/key-invites", |
| `{"label":"Sunny","id":"inv-bot","wrapped":"","agent":true, |
| "model":"","system_prompt":"","docs":"", |
| - "description":"A friendly helper","available_to_members":true}`) |
| + "description":"A friendly helper","available_to_members":true,"claim_hash":"`+testClaimHash+`"}`) |
| if r.StatusCode != http.StatusOK { |
| t.Fatalf("create agent invite: got %d", r.StatusCode) |
| } |
| @@ -148,7 +148,7 @@ func TestHelpersCatalogHTTP(t *testing.T) { |
| // The sidecar claims the invite and registers its key (as agent/provision.mjs |
| // does) → the helper appears for the unconnected member, badge + blurb intact. |
| - botTok, ok, err := srv.db.claimKeyInvite("inv-bot", "Sunny", "") |
| + botTok, ok, err := srv.db.claimKeyInvite("inv-bot", "Sunny", "", testClaimSecret) |
| if err != nil || !ok { |
| t.Fatalf("bot claim: ok=%v err=%v", ok, err) |
| } |
| @@ -177,8 +177,8 @@ func TestVettedAgentSeesFullRoster(t *testing.T) { |
| ts, srv := newTestServer(t) |
| join := func(label, inv string) (string, string) { |
| uid, _ := srv.db.createUser(label) |
| - srv.db.createKeyInvite(inv, uid, "", 0) |
| - tok, _, _ := srv.db.claimKeyInvite(inv, label, "") |
| + srv.db.createKeyInvite(inv, uid, "", testClaimHash, 0) |
| + tok, _, _ := srv.db.claimKeyInvite(inv, label, "", testClaimSecret) |
| srv.db.setUserPublicKey(uid, "pub-"+label) |
| return uid, tok |
| } |
| @@ -227,8 +227,8 @@ func TestVettedAgentSeesFullRoster(t *testing.T) { |
| func TestBotConfigListen(t *testing.T) { |
| ts, srv := newTestServer(t) |
| uid, _ := srv.db.createUser("Sunny") |
| - srv.db.createKeyInvite("inv-listen", uid, "", 0) |
| - tok, _, _ := srv.db.claimKeyInvite("inv-listen", "Sunny", "") |
| + srv.db.createKeyInvite("inv-listen", uid, "", testClaimHash, 0) |
| + tok, _, _ := srv.db.claimKeyInvite("inv-listen", "Sunny", "", testClaimSecret) |
| srv.db.setUserAgent(uid) |
| if err := srv.db.setBotConfig(uid, "", "", "", "name", "A helper", true); err != nil { |
| t.Fatalf("setBotConfig: %v", err) |
diff --git a/db.go b/db.go| index 57aaa08..2183212 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -222,6 +222,11 @@ CREATE TABLE IF NOT EXISTS bot_configs ( |
| // User invites: the pending DM thread an invite bootstraps the joiner into |
| // (null for admin auth-only / add-device invites). See createUserInvite. |
| "ALTER TABLE key_invites ADD COLUMN thread_id TEXT", |
| + // Proof-of-secret for claiming: hashToken of a value derived from the invite |
| + // secret T (which never reaches the server). Claiming requires presenting the |
| + // preimage, so reading the DB (which stores only the id + this hash) is not |
| + // enough to claim a pending invite. See deriveInvite in crypto.js. |
| + "ALTER TABLE key_invites ADD COLUMN claim_hash TEXT", |
| // Which user a push subscription belongs to, so removing a user can drop their |
| // subscriptions (sign-out clears them by token; removal has only the user id). |
| "ALTER TABLE push_subscriptions ADD COLUMN user_id TEXT", |
| @@ -363,6 +368,17 @@ func hashToken(t string) string { |
| return hex.EncodeToString(h[:]) |
| } |
| +// validClaimSecret reports whether claimSecret is the preimage of the stored |
| +// invite claim_hash, compared in constant time. An invite with no claim_hash |
| +// (created before this migration) is unclaimable — we deliberately do not fall |
| +// back to unauthenticated claiming. |
| +func validClaimSecret(claimHash, claimSecret string) bool { |
| + if claimHash == "" || claimSecret == "" { |
| + return false |
| + } |
| + return subtle.ConstantTimeCompare([]byte(claimHash), []byte(hashToken(claimSecret))) == 1 |
| +} |
| + |
| func randToken() (string, error) { |
| b := make([]byte, 32) |
| if _, err := rand.Read(b); err != nil { |
| @@ -571,14 +587,14 @@ func (db *DB) userExists(id string) bool { |
| } |
| // createKeyInvite stores a client-wrapped key under a client-derived id. |
| -func (db *DB) createKeyInvite(id, userID, wrapped string, expiresAt int64) error { |
| - return db.createKeyInviteWithThread(id, userID, wrapped, "", expiresAt) |
| +func (db *DB) createKeyInvite(id, userID, wrapped, claimHash string, expiresAt int64) error { |
| + return db.createKeyInviteWithThread(id, userID, wrapped, "", claimHash, expiresAt) |
| } |
| // createKeyInviteWithThread is createKeyInvite that also links the invite to a |
| // pending DM thread — used to re-send a user invite (a fresh link for the same |
| // pending invitee), so a lost link can be replaced without spawning a new chat. |
| -func (db *DB) createKeyInviteWithThread(id, userID, wrapped, threadID string, expiresAt int64) error { |
| +func (db *DB) createKeyInviteWithThread(id, userID, wrapped, threadID, claimHash string, expiresAt int64) error { |
| var exp, tid any |
| if expiresAt > 0 { |
| exp = expiresAt |
| @@ -587,8 +603,8 @@ func (db *DB) createKeyInviteWithThread(id, userID, wrapped, threadID string, ex |
| tid = threadID |
| } |
| _, err := db.sql.Exec( |
| - "INSERT INTO key_invites(id, user_id, wrapped, thread_id, created_at, expires_at) VALUES(?, ?, ?, ?, ?, ?)", |
| - id, userID, wrapped, tid, time.Now().Unix(), exp, |
| + "INSERT INTO key_invites(id, user_id, wrapped, thread_id, claim_hash, created_at, expires_at) VALUES(?, ?, ?, ?, ?, ?, ?)", |
| + id, userID, wrapped, tid, claimHash, time.Now().Unix(), exp, |
| ) |
| return err |
| } |
| @@ -602,7 +618,7 @@ func (db *DB) createKeyInviteWithThread(id, userID, wrapped, threadID string, ex |
| // inviter's own public key; bootstrapWrapped is it encrypted to the invite secret |
| // (so the joiner can recover it). The server sees only blobs it can't open. |
| // Returns the new thread id and the placeholder user id. |
| -func (db *DB) createUserInvite(inviterID, inviteID, label, bootstrapWrapped, inviterWrapped string, expiresAt int64) (string, string, error) { |
| +func (db *DB) createUserInvite(inviterID, inviteID, label, bootstrapWrapped, inviterWrapped, claimHash string, expiresAt int64) (string, string, error) { |
| inviteeID, err := randToken() |
| if err != nil { |
| return "", "", err |
| @@ -635,7 +651,7 @@ func (db *DB) createUserInvite(inviterID, inviteID, label, bootstrapWrapped, inv |
| {"INSERT INTO thread_members(thread_id, user_id, created_at) VALUES(?, ?, ?)", []any{threadID, inviteeID, now}}, |
| {"INSERT INTO thread_keys(thread_id, user_id, wrapped) VALUES(?, ?, ?)", []any{threadID, inviterID, inviterWrapped}}, |
| {"INSERT INTO dm_pairs(user_a, user_b, thread_id) VALUES(?, ?, ?)", []any{lo, hi, threadID}}, |
| - {"INSERT INTO key_invites(id, user_id, wrapped, thread_id, created_at, expires_at) VALUES(?, ?, ?, ?, ?, ?)", []any{inviteID, inviteeID, bootstrapWrapped, threadID, now, exp}}, |
| + {"INSERT INTO key_invites(id, user_id, wrapped, thread_id, claim_hash, created_at, expires_at) VALUES(?, ?, ?, ?, ?, ?, ?)", []any{inviteID, inviteeID, bootstrapWrapped, threadID, claimHash, now, exp}}, |
| } |
| for _, s := range stmts { |
| if _, err := tx.Exec(s.q, s.args...); err != nil { |
| @@ -646,21 +662,22 @@ func (db *DB) createUserInvite(inviterID, inviteID, label, bootstrapWrapped, inv |
| } |
| type KeyInvite struct { |
| - ID string |
| - UserID string |
| - Wrapped string |
| - ThreadID string // the pending DM this invite drops the joiner into ("" if none) |
| - Used bool |
| - Expired bool |
| + ID string |
| + UserID string |
| + Wrapped string |
| + ThreadID string // the pending DM this invite drops the joiner into ("" if none) |
| + ClaimHash string // hashToken of the claim secret; "" for pre-migration invites |
| + Used bool |
| + Expired bool |
| } |
| func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) { |
| var ki KeyInvite |
| var used, exp sql.NullInt64 |
| - var threadID sql.NullString |
| + var threadID, claimHash sql.NullString |
| err := db.sql.QueryRow( |
| - "SELECT id, user_id, wrapped, thread_id, used_at, expires_at FROM key_invites WHERE id = ?", id, |
| - ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &used, &exp) |
| + "SELECT id, user_id, wrapped, thread_id, claim_hash, used_at, expires_at FROM key_invites WHERE id = ?", id, |
| + ).Scan(&ki.ID, &ki.UserID, &ki.Wrapped, &threadID, &claimHash, &used, &exp) |
| if err == sql.ErrNoRows { |
| return KeyInvite{}, false, nil |
| } |
| @@ -668,6 +685,7 @@ func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) { |
| return KeyInvite{}, false, err |
| } |
| ki.ThreadID = threadID.String |
| + ki.ClaimHash = claimHash.String |
| ki.Used = used.Valid |
| ki.Expired = exp.Valid && time.Now().Unix() > exp.Int64 |
| return ki, true, nil |
| @@ -681,11 +699,16 @@ func (db *DB) getKeyInvite(id string) (KeyInvite, bool, error) { |
| // key wrapped to the joiner's own public key — we store it as their thread_keys |
| // row so they can decrypt the DM immediately. The joiner IS the invite's user |
| // (the placeholder created at invite time now becomes a real, joined person). |
| -func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf string) (string, bool, error) { |
| +func (db *DB) claimKeyInvite(id, deviceLabel, wrappedSelf, claimSecret string) (string, bool, error) { |
| ki, found, err := db.getKeyInvite(id) |
| if err != nil || !found || ki.Used || ki.Expired { |
| return "", false, err |
| } |
| + // Proof of the invite secret: knowing the id (which the server stores) is not |
| + // enough — the claimer must present the preimage of the stored claim_hash. |
| + if !validClaimSecret(ki.ClaimHash, claimSecret) { |
| + return "", false, nil |
| + } |
| // A user invite (one that bootstraps a pending DM) is for a not-yet-joined |
| // placeholder. Refuse once that user has joined (has a device token): otherwise a |
| // second, still-unused invite for them — e.g. a re-send whose earlier link was |
| @@ -1431,11 +1454,14 @@ func (db *DB) otherThreadMember(threadID, notUserID string) (string, bool) { |
| // messages of its own — is deleted either way. wrappedSelf is the thread key |
| // wrapped to the existing user's public key. Returns the thread to open. ok=false |
| // (no error) if the invite is unusable or lost the single-use race. |
| -func (db *DB) adoptUserInvite(inviteID, existingUserID, wrappedSelf string) (string, bool, error) { |
| +func (db *DB) adoptUserInvite(inviteID, existingUserID, wrappedSelf, claimSecret string) (string, bool, error) { |
| ki, found, err := db.getKeyInvite(inviteID) |
| if err != nil || !found || ki.Used || ki.Expired || ki.ThreadID == "" { |
| return "", false, err |
| } |
| + if !validClaimSecret(ki.ClaimHash, claimSecret) { |
| + return "", false, nil |
| + } |
| placeholder := ki.UserID |
| inviter, ok := db.otherThreadMember(ki.ThreadID, placeholder) |
| if !ok || inviter == existingUserID { |
diff --git a/db_test.go b/db_test.go| index ffd6c55..57643c0 100644 |
| --- a/db_test.go |
| +++ b/db_test.go |
| @@ -198,10 +198,10 @@ func TestSelectRecipientsExcludesAllSenderDevices(t *testing.T) { |
| s := &Server{db: db} |
| mkDevice := func(id, user string) string { |
| - if err := db.createKeyInvite(id, user, "w", 0); err != nil { |
| + if err := db.createKeyInvite(id, user, "w", testClaimHash, 0); err != nil { |
| t.Fatalf("createKeyInvite %s: %v", id, err) |
| } |
| - tok, ok, err := db.claimKeyInvite(id, "dev", "") |
| + tok, ok, err := db.claimKeyInvite(id, "dev", "", testClaimSecret) |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", id, ok, err) |
| } |
| @@ -322,7 +322,7 @@ func TestUserStatus(t *testing.T) { |
| if err != nil { |
| t.Fatalf("createUser: %v", err) |
| } |
| - if err := db.createKeyInvite("inv1", uid, "wrapped", 0); err != nil { |
| + if err := db.createKeyInvite("inv1", uid, "wrapped", testClaimHash, 0); err != nil { |
| t.Fatalf("createKeyInvite: %v", err) |
| } |
| @@ -336,7 +336,7 @@ func TestUserStatus(t *testing.T) { |
| } |
| // Claim it: now joined on one device, no longer pending. |
| - if _, ok, err := db.claimKeyInvite("inv1", "phone", ""); err != nil || !ok { |
| + if _, ok, err := db.claimKeyInvite("inv1", "phone", "", testClaimSecret); err != nil || !ok { |
| t.Fatalf("claim inv1: ok=%v err=%v", ok, err) |
| } |
| users, _ = db.listUsers() |
| @@ -345,10 +345,10 @@ func TestUserStatus(t *testing.T) { |
| } |
| // A second device link, claimed → two devices. |
| - if err := db.createKeyInvite("inv2", uid, "wrapped", 0); err != nil { |
| + if err := db.createKeyInvite("inv2", uid, "wrapped", testClaimHash, 0); err != nil { |
| t.Fatalf("createKeyInvite inv2: %v", err) |
| } |
| - if _, ok, err := db.claimKeyInvite("inv2", "laptop", ""); err != nil || !ok { |
| + if _, ok, err := db.claimKeyInvite("inv2", "laptop", "", testClaimSecret); err != nil || !ok { |
| t.Fatalf("claim inv2: ok=%v err=%v", ok, err) |
| } |
| users, _ = db.listUsers() |
diff --git a/handlers_test.go b/handlers_test.go| index 8e0f0cb..b7dad75 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -176,7 +176,7 @@ func TestKeyInviteFlow(t *testing.T) { |
| // Admin mints a key-invite. The browser would derive `id` and wrap the key; |
| // here we just store opaque strings (the server never interprets them). |
| resp := adminPost(t, ts.URL+"/api/admin/key-invites", |
| - `{"label":"Paul","id":"derived-id-abc","wrapped":"wrapped-blob"}`) |
| + `{"label":"Paul","id":"derived-id-abc","wrapped":"wrapped-blob","claim_hash":"`+testClaimHash+`"}`) |
| if resp.StatusCode != http.StatusOK { |
| t.Fatalf("create key-invite: %d", resp.StatusCode) |
| } |
| @@ -206,7 +206,7 @@ func TestKeyInviteFlow(t *testing.T) { |
| // Claiming mints a device token... |
| r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", |
| - strings.NewReader(`{"label":"laptop"}`)) |
| + strings.NewReader(`{"label":"laptop","claim_secret":"test-claim-secret-vector"}`)) |
| var claim struct { |
| Ok bool `json:"ok"` |
| Token string `json:"token"` |
| @@ -223,7 +223,7 @@ func TestKeyInviteFlow(t *testing.T) { |
| } |
| // Single-use: a second claim fails and the wrapped blob is no longer served. |
| - r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", strings.NewReader(`{}`)) |
| + r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`)) |
| if r.StatusCode == http.StatusOK { |
| t.Fatal("second claim should fail") |
| } |
| @@ -237,8 +237,8 @@ func TestKeyInviteFlow(t *testing.T) { |
| func TestMemberAddsDevice(t *testing.T) { |
| ts, _ := newTestServer(t) |
| // Mint + claim a key-invite to get a member device token. |
| - adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1"}`) |
| - r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{}`)) |
| + adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1","claim_hash":"`+testClaimHash+`"}`) |
| + r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`)) |
| var claim struct { |
| Token string `json:"token"` |
| } |
| @@ -248,7 +248,7 @@ func TestMemberAddsDevice(t *testing.T) { |
| // The member mints a device invite for themselves (no admin token). |
| r, _ = http.Post(ts.URL+"/api/device-invites", "application/json", |
| - strings.NewReader(`{"token":"`+claim.Token+`","label":"Paul","id":"inv-2","wrapped":"w2"}`)) |
| + strings.NewReader(`{"token":"`+claim.Token+`","label":"Paul","id":"inv-2","wrapped":"w2","claim_hash":"`+testClaimHash+`"}`)) |
| if r.StatusCode != http.StatusOK { |
| t.Fatalf("member add-device: %d", r.StatusCode) |
| } |
| @@ -518,10 +518,10 @@ func TestPostThreadDedupesDM(t *testing.T) { |
| if err != nil { |
| t.Fatalf("createUser %s: %v", label, err) |
| } |
| - if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", 0); err != nil { |
| + if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { |
| t.Fatalf("createKeyInvite %s: %v", label, err) |
| } |
| - tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "") |
| + tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| @@ -579,10 +579,10 @@ func TestThreadHiddenAndRemove(t *testing.T) { |
| if err != nil { |
| t.Fatalf("createUser %s: %v", label, err) |
| } |
| - if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", 0); err != nil { |
| + if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { |
| t.Fatalf("createKeyInvite %s: %v", label, err) |
| } |
| - tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "") |
| + tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) |
| } |
| @@ -744,10 +744,10 @@ func joinedMember(t *testing.T, srv *Server, label string) (token, userID, threa |
| if err != nil { |
| t.Fatalf("createUser: %v", err) |
| } |
| - if err := srv.db.createKeyInvite("inv-"+label, uid, "wrap", 0); err != nil { |
| + if err := srv.db.createKeyInvite("inv-"+label, uid, "wrap", testClaimHash, 0); err != nil { |
| t.Fatalf("createKeyInvite: %v", err) |
| } |
| - tok, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "") |
| + tok, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) |
| } |
| @@ -766,9 +766,9 @@ func TestAPNsSubscribe(t *testing.T) { |
| ts, s := newTestServer(t) |
| // Mint a member the same way TestKeyInviteFlow does: admin key-invite + claim. |
| - adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Pat","id":"apns-invite","wrapped":""}`) |
| + adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Pat","id":"apns-invite","wrapped":"","claim_hash":"`+testClaimHash+`"}`) |
| r, _ := http.Post(ts.URL+"/api/key-invite/apns-invite/claim", "application/json", |
| - strings.NewReader(`{"label":"Pat"}`)) |
| + strings.NewReader(`{"label":"Pat","claim_secret":"test-claim-secret-vector"}`)) |
| var claim struct { |
| Ok bool `json:"ok"` |
| Token string `json:"token"` |
diff --git a/invite_claim_test.go b/invite_claim_test.go| new file mode 100644 |
| index 0000000..890a5cc |
| --- /dev/null |
| +++ b/invite_claim_test.go |
| @@ -0,0 +1,69 @@ |
| +package main |
| + |
| +import "testing" |
| + |
| +// Shared across the invite tests: a claim secret and its stored hash. Creation |
| +// stores testClaimHash; claiming presents testClaimSecret. |
| +const testClaimSecret = "test-claim-secret-vector" |
| + |
| +var testClaimHash = hashToken(testClaimSecret) |
| + |
| +// Cross-implementation vector: the browser's deriveInvite("AAAAAAAAAAAAAAAAAAAAAA") |
| +// (T = 16 zero bytes, base64url) produces this claim secret + hash. The server must |
| +// accept that secret against that hash — i.e. the client's sha256hex and the |
| +// server's hashToken agree, so real browser claims verify. (Regenerate via: |
| +// node --input-type=module -e "import('./web/static/crypto.js').then(...)") |
| +func TestClaimHashCrossImpl(t *testing.T) { |
| + const jsClaimSecret = "zgoEhtdOkw8sCcWC7LhWHMItVCulazloEuXRG7hz_Zw" |
| + const jsClaimHash = "a4553e1d5770adf87427741af0fbcf6e42a223a82403edd6752c0c39fa43a7e3" |
| + if got := hashToken(jsClaimSecret); got != jsClaimHash { |
| + t.Fatalf("hashToken(jsClaimSecret) = %q, want %q — JS and Go hashing disagree", got, jsClaimHash) |
| + } |
| + if !validClaimSecret(jsClaimHash, jsClaimSecret) { |
| + t.Fatal("server would reject a browser-derived claim secret") |
| + } |
| +} |
| + |
| +func TestValidClaimSecret(t *testing.T) { |
| + if !validClaimSecret(hashToken("s"), "s") { |
| + t.Error("valid preimage rejected") |
| + } |
| + for _, bad := range [][2]string{{"", ""}, {"", "s"}, {hashToken("s"), ""}, {hashToken("s"), "wrong"}} { |
| + if validClaimSecret(bad[0], bad[1]) { |
| + t.Errorf("validClaimSecret(%q, %q) = true, want false", bad[0], bad[1]) |
| + } |
| + } |
| +} |
| + |
| +// Claiming requires proving knowledge of the invite secret — knowing only the id |
| +// (which the server stores, and a DB thief would have) is not enough. |
| +func TestClaimKeyInviteRequiresSecret(t *testing.T) { |
| + db := newTestDB(t) |
| + if err := db.createKeyInvite("inv1", "u1", "", testClaimHash, 0); err != nil { |
| + t.Fatalf("create: %v", err) |
| + } |
| + // A DB thief who only has the stored id can't produce the preimage. |
| + if _, ok, _ := db.claimKeyInvite("inv1", "dev", "", "wrong-secret"); ok { |
| + t.Fatal("claim succeeded with the wrong secret") |
| + } |
| + if _, ok, _ := db.claimKeyInvite("inv1", "dev", "", ""); ok { |
| + t.Fatal("claim succeeded with an empty secret") |
| + } |
| + // The real invitee (who derived the secret from T in the URL fragment) can. |
| + tok, ok, err := db.claimKeyInvite("inv1", "dev", "", testClaimSecret) |
| + if err != nil || !ok || tok == "" { |
| + t.Fatalf("claim with correct secret: ok=%v err=%v tok=%q", ok, err, tok) |
| + } |
| +} |
| + |
| +// An invite stored without a claim_hash (e.g. created before this migration) is |
| +// unclaimable — we don't fall back to unauthenticated claiming. |
| +func TestClaimKeyInviteRejectsInviteWithoutHash(t *testing.T) { |
| + db := newTestDB(t) |
| + if err := db.createKeyInvite("inv2", "u2", "", "", 0); err != nil { |
| + t.Fatalf("create: %v", err) |
| + } |
| + if _, ok, _ := db.claimKeyInvite("inv2", "dev", "", testClaimSecret); ok { |
| + t.Fatal("claimed an invite that has no claim_hash") |
| + } |
| +} |
diff --git a/invites_test.go b/invites_test.go| index 5452753..97a6d03 100644 |
| --- a/invites_test.go |
| +++ b/invites_test.go |
| @@ -74,7 +74,7 @@ func TestUserInviteFreshJoin(t *testing.T) { |
| t.Fatalf("setUserPublicKey: %v", err) |
| } |
| - threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot-wrapped", "self-wrapped", 0) |
| + threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot-wrapped", "self-wrapped", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| } |
| @@ -107,7 +107,7 @@ func TestUserInviteFreshJoin(t *testing.T) { |
| } |
| // Fresh join: the placeholder becomes them — token minted, DM key stored. |
| - tok, ok, err := db.claimKeyInvite("inv1", "Grandma", "grandma-wrapped") |
| + tok, ok, err := db.claimKeyInvite("inv1", "Grandma", "grandma-wrapped", testClaimSecret) |
| if err != nil || !ok || tok == "" { |
| t.Fatalf("claim: ok=%v tok=%q err=%v", ok, tok, err) |
| } |
| @@ -135,12 +135,12 @@ func TestUserInviteAdoptExisting(t *testing.T) { |
| inviter := mk("Paul") |
| existing := mk("Bob") |
| - threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", 0) |
| + threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| } |
| - tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped") |
| + tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped", testClaimSecret) |
| if err != nil || !ok { |
| t.Fatalf("adopt: ok=%v err=%v", ok, err) |
| } |
| @@ -163,7 +163,7 @@ func TestUserInviteAdoptExisting(t *testing.T) { |
| t.Fatalf("dm pair should rekey to (inviter, existing): %q ok=%v", got, ok) |
| } |
| // Single-use: a second adopt fails. |
| - if _, ok, _ := db.adoptUserInvite("inv1", existing, "x"); ok { |
| + if _, ok, _ := db.adoptUserInvite("inv1", existing, "x", testClaimSecret); ok { |
| t.Fatal("invite should be single-use") |
| } |
| } |
| @@ -189,12 +189,12 @@ func TestUserInviteAdoptDedupesExistingDM(t *testing.T) { |
| t.Fatalf("recordDMPair: %v", err) |
| } |
| - pendingThread, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", 0) |
| + pendingThread, placeholder, err := db.createUserInvite(inviter, "inv1", "Bobby", "boot", "self", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| } |
| - tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped") |
| + tid, ok, err := db.adoptUserInvite("inv1", existing, "bob-wrapped", testClaimSecret) |
| if err != nil || !ok { |
| t.Fatalf("adopt: ok=%v err=%v", ok, err) |
| } |
| @@ -213,7 +213,7 @@ func TestCancelUserInvite(t *testing.T) { |
| db := newTestDB(t) |
| inviter, _ := db.createUser("Paul") |
| db.setUserPublicKey(inviter, "pub-paul") |
| - threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", 0) |
| + threadID, placeholder, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| } |
| @@ -245,20 +245,20 @@ func TestUserInviteClaimBlockedAfterJoin(t *testing.T) { |
| db := newTestDB(t) |
| inviter, _ := db.createUser("Paul") |
| db.setUserPublicKey(inviter, "pub-paul") |
| - threadID, invitee, err := db.createUserInvite(inviter, "invA", "Grandma", "boot", "self", 0) |
| + threadID, invitee, err := db.createUserInvite(inviter, "invA", "Grandma", "boot", "self", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| } |
| // A second, still-unused invite pointing at the same pending DM (a re-send). |
| - if err := db.createKeyInviteWithThread("invB", invitee, "boot2", threadID, 0); err != nil { |
| + if err := db.createKeyInviteWithThread("invB", invitee, "boot2", threadID, testClaimHash, 0); err != nil { |
| t.Fatalf("createKeyInviteWithThread: %v", err) |
| } |
| // Invitee joins via invite A. |
| - if _, ok, err := db.claimKeyInvite("invA", "Grandma", "grandma-key"); err != nil || !ok { |
| + if _, ok, err := db.claimKeyInvite("invA", "Grandma", "grandma-key", testClaimSecret); err != nil || !ok { |
| t.Fatalf("claim A: ok=%v err=%v", ok, err) |
| } |
| // Invite B must now be dead — the invitee already has a device token. |
| - if tok, ok, _ := db.claimKeyInvite("invB", "Attacker", "attacker-key"); ok || tok != "" { |
| + if tok, ok, _ := db.claimKeyInvite("invB", "Attacker", "attacker-key", testClaimSecret); ok || tok != "" { |
| t.Fatal("a second user-invite must not be claimable after the invitee joined") |
| } |
| } |
| @@ -269,7 +269,7 @@ func TestRemoveUserTearsDownPendingInvite(t *testing.T) { |
| db := newTestDB(t) |
| inviter, _ := db.createUser("Paul") |
| db.setUserPublicKey(inviter, "pub-paul") |
| - threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", 0) |
| + threadID, invitee, err := db.createUserInvite(inviter, "inv1", "Grandma", "boot", "self", testClaimHash, 0) |
| if err != nil { |
| t.Fatalf("createUserInvite: %v", err) |
| } |
| @@ -295,13 +295,13 @@ func TestRemoveUserTearsDownPendingInvite(t *testing.T) { |
| func TestUserInviteHTTPGateAndRoster(t *testing.T) { |
| ts, srv := newTestServer(t) |
| uid, _ := srv.db.createUser("Paul") |
| - srv.db.createKeyInvite("inv-paul", uid, "", 0) |
| - tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "") |
| + srv.db.createKeyInvite("inv-paul", uid, "", testClaimHash, 0) |
| + tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "", testClaimSecret) |
| srv.db.setUserPublicKey(uid, "pub-paul") |
| body := func(id string) string { |
| return `{"token":"` + tok + `","label":"Grandma","id":"` + id + |
| - `","wrapped":"boot","self_wrapped":"self"}` |
| + `","wrapped":"boot","self_wrapped":"self","claim_hash":"` + testClaimHash + `"}` |
| } |
| // Setting off (default) → forbidden. |
diff --git a/main.go b/main.go| index 6b9e17f..36cb0f4 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -1207,11 +1207,12 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| } |
| var body struct { |
| Label string `json:"label"` |
| - UserID string `json:"user_id"` |
| - ID string `json:"id"` |
| - Wrapped string `json:"wrapped"` |
| - Expires int64 `json:"expires"` |
| - Agent bool `json:"agent"` // mark the invited user as a bot (badge only) |
| + UserID string `json:"user_id"` |
| + ID string `json:"id"` |
| + Wrapped string `json:"wrapped"` |
| + ClaimHash string `json:"claim_hash"` // proof-of-secret for claiming (see deriveInvite) |
| + Expires int64 `json:"expires"` |
| + Agent bool `json:"agent"` // mark the invited user as a bot (badge only) |
| // Agent config (only when Agent) — the sidecar applies these. |
| Model string `json:"model"` |
| SystemPrompt string `json:"system_prompt"` |
| @@ -1231,7 +1232,8 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // Wrapped is optional now: a fresh-person invite carries no key (the joiner |
| // generates their own user keypair). Only an add-device invite wraps the |
| // user's private key. See docs/threads-and-dms.md. |
| - if body.ID == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 || len(body.Description) > 300 { |
| + if body.ID == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 || len(body.Description) > 300 || |
| + body.ClaimHash == "" || len(body.ClaimHash) > 128 { |
| httpError(w, http.StatusBadRequest, "missing id") |
| return |
| } |
| @@ -1261,7 +1263,7 @@ func (s *Server) createKeyInvite(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| } |
| - if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, body.Expires); err != nil { |
| + if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, body.ClaimHash, body.Expires); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| @@ -1297,8 +1299,9 @@ func (s *Server) getKeyInvite(w http.ResponseWriter, r *http.Request) { |
| // thread id so the client opens it first. |
| func (s *Server) claimKeyInvite(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| - Label string `json:"label"` |
| - Wrapped string `json:"wrapped"` |
| + Label string `json:"label"` |
| + Wrapped string `json:"wrapped"` |
| + ClaimSecret string `json:"claim_secret"` |
| } |
| json.NewDecoder(r.Body).Decode(&body) |
| if len(body.Wrapped) > 4096 { |
| @@ -1308,7 +1311,7 @@ func (s *Server) claimKeyInvite(w http.ResponseWriter, r *http.Request) { |
| id := r.PathValue("id") |
| // Grab the pending DM (if any) before the claim consumes the invite. |
| ki, _, _ := s.db.getKeyInvite(id) |
| - tok, ok, err := s.db.claimKeyInvite(id, strings.TrimSpace(body.Label), body.Wrapped) |
| + tok, ok, err := s.db.claimKeyInvite(id, strings.TrimSpace(body.Label), body.Wrapped, body.ClaimSecret) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1437,10 +1440,11 @@ func (s *Server) removeUser(w http.ResponseWriter, r *http.Request) { |
| // user, or creates one for a legacy member. |
| func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| - Token string `json:"token"` |
| - Label string `json:"label"` |
| - ID string `json:"id"` |
| - Wrapped string `json:"wrapped"` |
| + Token string `json:"token"` |
| + Label string `json:"label"` |
| + ID string `json:"id"` |
| + Wrapped string `json:"wrapped"` |
| + ClaimHash string `json:"claim_hash"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -1450,7 +1454,8 @@ func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "not a member") |
| return |
| } |
| - if body.ID == "" || body.Wrapped == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 { |
| + if body.ID == "" || body.Wrapped == "" || len(body.ID) > 200 || len(body.Wrapped) > 4096 || |
| + body.ClaimHash == "" || len(body.ClaimHash) > 128 { |
| httpError(w, http.StatusBadRequest, "missing id/wrapped") |
| return |
| } |
| @@ -1464,7 +1469,7 @@ func (s *Server) createDeviceInvite(w http.ResponseWriter, r *http.Request) { |
| } |
| userID = id |
| } |
| - if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, 0); err != nil { |
| + if err := s.db.createKeyInvite(body.ID, userID, body.Wrapped, body.ClaimHash, 0); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| @@ -1485,6 +1490,7 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| ID string `json:"id"` |
| Wrapped string `json:"wrapped"` |
| SelfWrapped string `json:"self_wrapped"` |
| + ClaimHash string `json:"claim_hash"` |
| ThreadID string `json:"thread_id"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| @@ -1504,7 +1510,8 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusForbidden, "no user for token") |
| return |
| } |
| - if body.ID == "" || len(body.ID) > 200 || body.Wrapped == "" || len(body.Wrapped) > 4096 { |
| + if body.ID == "" || len(body.ID) > 200 || body.Wrapped == "" || len(body.Wrapped) > 4096 || |
| + body.ClaimHash == "" || len(body.ClaimHash) > 128 { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
| @@ -1528,7 +1535,7 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "they've already joined") |
| return |
| } |
| - if err := s.db.createKeyInviteWithThread(body.ID, invitee, body.Wrapped, body.ThreadID, 0); err != nil { |
| + if err := s.db.createKeyInviteWithThread(body.ID, invitee, body.Wrapped, body.ThreadID, body.ClaimHash, 0); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| @@ -1541,7 +1548,7 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
| - threadID, inviteeID, err := s.db.createUserInvite(uid, body.ID, label, body.Wrapped, body.SelfWrapped, 0) |
| + threadID, inviteeID, err := s.db.createUserInvite(uid, body.ID, label, body.Wrapped, body.SelfWrapped, body.ClaimHash, 0) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| @@ -1555,8 +1562,9 @@ func (s *Server) postUserInvite(w http.ResponseWriter, r *http.Request) { |
| // key wrapped to their existing public key. Auth is their own device token. |
| func (s *Server) adoptKeyInvite(w http.ResponseWriter, r *http.Request) { |
| var body struct { |
| - Token string `json:"token"` |
| - Wrapped string `json:"wrapped"` |
| + Token string `json:"token"` |
| + Wrapped string `json:"wrapped"` |
| + ClaimSecret string `json:"claim_secret"` |
| } |
| if json.NewDecoder(r.Body).Decode(&body) != nil { |
| httpError(w, http.StatusBadRequest, "bad request") |
| @@ -1575,7 +1583,7 @@ func (s *Server) adoptKeyInvite(w http.ResponseWriter, r *http.Request) { |
| httpError(w, http.StatusBadRequest, "bad request") |
| return |
| } |
| - threadID, ok, err := s.db.adoptUserInvite(r.PathValue("id"), uid, body.Wrapped) |
| + threadID, ok, err := s.db.adoptUserInvite(r.PathValue("id"), uid, body.Wrapped, body.ClaimSecret) |
| if err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
diff --git a/push_token_hash_test.go b/push_token_hash_test.go| index ec1745b..6bb6c10 100644 |
| --- a/push_token_hash_test.go |
| +++ b/push_token_hash_test.go |
| @@ -82,10 +82,10 @@ func TestBackfillScrubsPlaintextPushTokens(t *testing.T) { |
| // join that replaced a query referencing a non-existent device_tokens.token column. |
| func TestRemoveUserDropsAPNsTokens(t *testing.T) { |
| db := newTestDB(t) |
| - if err := db.createKeyInvite("k1", "alice", "w", 0); err != nil { |
| + if err := db.createKeyInvite("k1", "alice", "w", testClaimHash, 0); err != nil { |
| t.Fatalf("createKeyInvite: %v", err) |
| } |
| - tok, ok, err := db.claimKeyInvite("k1", "phone", "") |
| + tok, ok, err := db.claimKeyInvite("k1", "phone", "", testClaimSecret) |
| if err != nil || !ok { |
| t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) |
| } |
diff --git a/web/static/admin.js b/web/static/admin.js| index 25a8037..2c1e5f0 100644 |
| --- a/web/static/admin.js |
| +++ b/web/static/admin.js |
| @@ -138,12 +138,13 @@ async function mintInvite(label, userId, agent, config) { |
| // Auth-only invite (threads model): no instance-wide key to wrap — the joiner |
| // generates their own user keypair on join. See docs/threads-and-dms.md. |
| const T = randomSecret(); |
| - const { id } = await deriveInvite(T); |
| + const { id, claimHash } = await deriveInvite(T); |
| const res = await api("POST", "/api/admin/key-invites", { |
| label, |
| user_id: userId || "", |
| id, |
| wrapped: "", |
| + claim_hash: claimHash, |
| agent: !!agent, |
| ...(config || {}), // model, system_prompt, docs — agent only |
| }); |
diff --git a/web/static/chat.js b/web/static/chat.js| index fce9f8e..38c496a 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -3929,7 +3929,7 @@ async function createThread(otherIds, opts = {}) { |
| // stores blobs. Returns the pending thread id; the link lands in inviteLinks. |
| async function inviteSomeone(name) { |
| const T = randomSecret(); |
| - const { id, wrapKey } = await deriveInvite(T); |
| + const { id, wrapKey, claimHash } = await deriveInvite(T); |
| const { key, b64 } = await generateKey(); |
| const me = roster.find((e) => e.user_id === myUserId); |
| if (!me || !me.public_key) throw new Error("your account isn't loaded yet"); |
| @@ -3939,7 +3939,7 @@ async function inviteSomeone(name) { |
| await fetch("/api/invites", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, label: name, id, wrapped, self_wrapped: selfWrapped }), |
| + body: JSON.stringify({ token: session.token, label: name, id, wrapped, self_wrapped: selfWrapped, claim_hash: claimHash }), |
| }) |
| ).json(); |
| if (!r.thread_id) throw new Error("invite failed"); |
| @@ -3964,13 +3964,13 @@ async function resendInvite(threadId) { |
| const other = (t.members || []).find((uid) => uid !== myUserId); |
| const name = rosterLabel(other); |
| const T = randomSecret(); |
| - const { id, wrapKey } = await deriveInvite(T); |
| + const { id, wrapKey, claimHash } = await deriveInvite(T); |
| const wrapped = await encrypt(wrapKey, { threadKey: b64 }); |
| const r = await ( |
| await fetch("/api/invites", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, id, wrapped, thread_id: threadId }), |
| + body: JSON.stringify({ token: session.token, id, wrapped, thread_id: threadId, claim_hash: claimHash }), |
| }) |
| ).json(); |
| if (!r.ok) throw new Error("resend failed"); |
| @@ -4111,13 +4111,13 @@ async function addDevice() { |
| let link; |
| try { |
| const T = randomSecret(); |
| - const { id, wrapKey } = await deriveInvite(T); |
| + const { id, wrapKey, claimHash } = await deriveInvite(T); |
| // Ship this user's keypair (so the new device can read every thread). |
| const wrapped = await encrypt(wrapKey, { pub: session.userPub, priv: session.userPriv }); |
| const res = await fetch("/api/device-invites", { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: session.token, label: session.name, id, wrapped }), |
| + body: JSON.stringify({ token: session.token, label: session.name, id, wrapped, claim_hash: claimHash }), |
| }); |
| if (!res.ok) throw new Error("server"); |
| const slug = (session.name || "device").trim().replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "") || "device"; |
| @@ -4398,7 +4398,7 @@ function doSignout() { |
| // for a new person; unwrap the shipped one for an added device), register the |
| // public key, and store the session. |
| async function claimInvite(T, name) { |
| - const { id, wrapKey } = await deriveInvite(T); |
| + const { id, wrapKey, claimSecret } = await deriveInvite(T); |
| const info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json(); |
| if (!info.ok) throw new Error("invite unavailable"); |
| let pub, priv, wrappedSelf = ""; |
| @@ -4427,7 +4427,7 @@ async function claimInvite(T, name) { |
| await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ label: name, wrapped: wrappedSelf }), |
| + body: JSON.stringify({ label: name, wrapped: wrappedSelf, claim_secret: claimSecret }), |
| }) |
| ).json(); |
| if (!claim.ok || !claim.token) throw new Error("claim failed"); |
diff --git a/web/static/crypto.js b/web/static/crypto.js| index 1223d0b..3f6d3c4 100644 |
| --- a/web/static/crypto.js |
| +++ b/web/static/crypto.js |
| @@ -95,7 +95,18 @@ async function hkdf(tB64, info) { |
| })); |
| } |
| -// deriveInvite returns { id, wrapKey } from the secret T, domain-separated. |
| +// sha256hex is the lowercase hex SHA-256 of a string's UTF-8 bytes — it matches |
| +// the server's hashToken, so a claimHash computed here verifies against the |
| +// claimSecret presented at claim time. |
| +async function sha256hex(str) { |
| + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)); |
| + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); |
| +} |
| + |
| +// deriveInvite returns { id, wrapKey, claimSecret, claimHash } from the secret T, |
| +// each domain-separated. claimSecret proves knowledge of T when claiming; claimHash |
| +// is what the inviter hands the server to store. The server keeps only id + claimHash, |
| +// so reading the DB isn't enough to claim — you still need T to derive claimSecret. |
| export async function deriveInvite(tB64) { |
| const idd = await hkdf(tB64, "lchat-invite-id"); |
| const idBits = await crypto.subtle.deriveBits(idd.params, idd.ikm, 256); |
| @@ -103,7 +114,11 @@ export async function deriveInvite(tB64) { |
| const wrapKey = await crypto.subtle.deriveKey( |
| wkd.params, wkd.ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"] |
| ); |
| - return { id: bytesToB64url(new Uint8Array(idBits)), wrapKey }; |
| + const cld = await hkdf(tB64, "lchat-invite-claim"); |
| + const claimBits = await crypto.subtle.deriveBits(cld.params, cld.ikm, 256); |
| + const claimSecret = bytesToB64url(new Uint8Array(claimBits)); |
| + const claimHash = await sha256hex(claimSecret); |
| + return { id: bytesToB64url(new Uint8Array(idBits)), wrapKey, claimSecret, claimHash }; |
| } |
| // wrap/unwrap the group key (base64url) under a derived wrapping CryptoKey. |
diff --git a/web/static/join.js b/web/static/join.js| index 6933727..3a5a33c 100644 |
| --- a/web/static/join.js |
| +++ b/web/static/join.js |
| @@ -25,10 +25,17 @@ function init() { |
| start(); |
| } |
| +// The claim secret derived from T (proves knowledge of the invite secret when |
| +// claiming/adopting). Set once in start(); read by finish()/adoptExisting(). |
| +let inviteClaimSecret = ""; |
| + |
| async function start() { |
| let id, wrapKey, info; |
| try { |
| - ({ id, wrapKey } = await deriveInvite(T)); |
| + const derived = await deriveInvite(T); |
| + id = derived.id; |
| + wrapKey = derived.wrapKey; |
| + inviteClaimSecret = derived.claimSecret; |
| info = await (await fetch(`/api/key-invite/${encodeURIComponent(id)}`)).json(); |
| } catch { |
| return showError("Couldn't reach the server. Try again."); |
| @@ -87,7 +94,7 @@ async function finish(id, name, pub, priv, wrappedSelf = "") { |
| await fetch(`/api/key-invite/${encodeURIComponent(id)}/claim`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ label: name, wrapped: wrappedSelf }), |
| + body: JSON.stringify({ label: name, wrapped: wrappedSelf, claim_secret: inviteClaimSecret }), |
| }) |
| ).json(); |
| } catch { |
| @@ -132,7 +139,7 @@ async function adoptExisting(id, threadKeyB64, existing) { |
| await fetch(`/api/key-invite/${encodeURIComponent(id)}/adopt`, { |
| method: "POST", |
| headers: { "Content-Type": "application/json" }, |
| - body: JSON.stringify({ token: existing.token, wrapped }), |
| + body: JSON.stringify({ token: existing.token, wrapped, claim_secret: inviteClaimSecret }), |
| }) |
| ).json(); |
| if (!r.ok) { |