eleven / ocho Public

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>
Paul Campbell efbf8b5d181c142aa896526d81e9a35cf8c873db parent a7cfa11
12 files changed, +228 −102
  • bots_test.go +10 −10
  • db.go +44 −18
  • db_test.go +6 −6
  • handlers_test.go +14 −14
  • invite_claim_test.go +69 −0
  • invites_test.go +16 −16
  • main.go +30 −22
  • push_token_hash_test.go +2 −2
  • web/static/admin.js +2 −1
  • web/static/chat.js +8 −8
  • web/static/crypto.js +17 −2
  • web/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) {