Compare commits

..
2 Commits
Author SHA1 Message Date
Worph 55d08e77ed improvement on user management
Build and Push Docker Image / build (push) Failing after 14s
2026-08-02 21:35:39 +02:00
Worph 1f084b817c improve identity handling
Build and Push Docker Image / build (push) Failing after 14s
2026-08-01 23:31:49 +02:00
5 changed files with 172 additions and 9 deletions
+60 -3
View File
@@ -50,6 +50,26 @@ type identityImportResponse struct {
UID string `json:"uid"` UID string `json:"uid"`
Curve string `json:"curve"` Curve string `json:"curve"`
CreatedAt int64 `json:"createdAt"` CreatedAt int64 `json:"createdAt"`
// Created distinguishes "this key was new here" (201) from "this key was
// already in the keystore" (200). Import is the sign-in primitive for
// clients: presenting the secret key is the proof of ownership, so a
// re-import must succeed rather than conflict. Clients use the flag only
// to word the confirmation ("new profile" vs "welcome back").
Created bool `json:"created"`
}
// identityRevealRequest asks for an existing account's private key back. The
// key was shown once at generate; without a way to see it again, a client with
// a real sign-out has no recovery path for a user who never saved it.
type identityRevealRequest struct {
Confirm bool `json:"confirm"`
UID string `json:"uid"`
}
type identityRevealResponse struct {
UID string `json:"uid"`
Curve string `json:"curve"`
PrivateKeyHex string `json:"privateKeyHex"`
} }
type identityDeleteRequest struct { type identityDeleteRequest struct {
@@ -172,9 +192,17 @@ func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
return return
} }
dir := s.config.IdentityAccountsDir() dir := s.config.IdentityAccountsDir()
if identity.ExistsByUID(dir, id.UID) { // Idempotent: importing a key that is already in the keystore is how a
writeErrorSlug(w, http.StatusConflict, "identity_exists", // second device signs in to an existing account. Answer 200 with the
"an account with this key already exists", false) // stored account (preserving its original createdAt) instead of 409 —
// the caller proved ownership by presenting the key.
if existing, err := identity.LoadByUID(dir, id.UID); err == nil && existing != nil {
writeJSON(w, http.StatusOK, identityImportResponse{
UID: existing.UID,
Curve: existing.Curve,
CreatedAt: existing.CreatedAt,
Created: false,
})
return return
} }
if err := identity.SaveAccount(dir, id); err != nil { if err := identity.SaveAccount(dir, id); err != nil {
@@ -185,6 +213,35 @@ func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
UID: id.UID, UID: id.UID,
Curve: id.Curve, Curve: id.Curve,
CreatedAt: id.CreatedAt, CreatedAt: id.CreatedAt,
Created: true,
})
}
// handleIdentityReveal returns an existing account's private key. Deliberately
// POST + explicit confirm (never a bare GET) so it cannot be triggered by a
// link or an image tag, and so it reads as the deliberate act it is. The
// endpoint is auth-gated at the perimeter like the rest of /api/identity/*.
func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
var body identityRevealRequest
_ = json.NewDecoder(r.Body).Decode(&body)
if !body.Confirm && r.URL.Query().Get("confirm") != "true" {
writeErrorSlug(w, http.StatusBadRequest, "confirm_required",
"revealing a private key requires {\"confirm\": true} or ?confirm=true", false)
return
}
uid := strings.TrimSpace(body.UID)
if uid == "" {
uid = strings.TrimSpace(r.URL.Query().Get("uid"))
}
id, status, slug, msg := s.resolveAccount(uid)
if status != 0 {
writeErrorSlug(w, status, slug, msg, false)
return
}
writeJSON(w, http.StatusOK, identityRevealResponse{
UID: id.UID,
Curve: id.Curve,
PrivateKeyHex: id.PrivateKeyHex,
}) })
} }
+1
View File
@@ -172,6 +172,7 @@ func (s *Server) setupRoutes() {
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET") s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST") s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).Methods("POST")
s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST") s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST")
s.router.HandleFunc("/api/identity/reveal", s.handleIdentityReveal).Methods("POST")
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE") s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST") s.router.HandleFunc("/api/identity/sign", s.handleIdentitySign).Methods("POST")
s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET") s.router.HandleFunc("/api/identity/aead-key", s.handleIdentityAEADKey).Methods("GET")
+9 -1
View File
@@ -27,6 +27,14 @@ type UDLPutRequest struct {
Ts int64 `json:"ts"` Ts int64 `json:"ts"`
Record string `json:"record"` // base64 CBOR, opaque + signed Record string `json:"record"` // base64 CBOR, opaque + signed
Value *json.RawMessage `json:"value,omitempty"` Value *json.RawMessage `json:"value,omitempty"`
// Tombstone marks this write as a deletion of (uid, cid, key). The record
// itself is opaque here — meta-core never decodes the CBOR, and a private-
// tier value and a tombstone are equally "no plaintext value" — so the
// writer has to say. Without it the cid is never removed from
// udl:idx:user:<uid>:key:<key>, which then grows for the life of the
// account and is returned in full on every My List / Continue Watching
// read. Omitted by older writers, which keeps the previous behaviour.
Tombstone bool `json:"tombstone,omitempty"`
} }
// UDLPutResponse is the response of PUT /api/udl/record. // UDLPutResponse is the response of PUT /api/udl/record.
@@ -104,7 +112,7 @@ func (s *Server) handleUDLRecordPut(w http.ResponseWriter, r *http.Request) {
hasValue = true hasValue = true
} }
accepted, err := s.storage.UDLUpsertIfNewer(req.Uid, req.Cid, req.Key, req.Version, req.Ts, req.Record, publicValue, hasValue) accepted, err := s.storage.UDLUpsertIfNewer(req.Uid, req.Cid, req.Key, req.Version, req.Ts, req.Record, publicValue, hasValue, req.Tombstone)
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
+22 -4
View File
@@ -72,7 +72,15 @@ func udlIdxCidUsers(cid string) string { return fmt.Sprintf("udl:idx:cid:%s
// //
// KEYS: 1=cell 2=idx:user:key 3=idx:user:cid 4=idx:cid:key 5=idx:cid:users // KEYS: 1=cell 2=idx:user:key 3=idx:user:cid 4=idx:cid:key 5=idx:cid:users
// ARGV: 1=version 2=ts 3=record 4=value 5=hasValue("1"/"0") // ARGV: 1=version 2=ts 3=record 4=value 5=hasValue("1"/"0")
// 6=cid 7=key 8=uid // 6=cid 7=key 8=uid 9=tombstone("1"/"0")
//
// A tombstone still writes the cell — that is the CRDT state, and dropping it
// would let an older write resurrect the value — but it SREMs the cid from the
// user+key index. That index is what `active_for_user_key` returns, so leaving
// deleted cids in it means every My List / Continue Watching read carries every
// title ever un-liked or finished, forever. The other three indexes are left
// alone: they answer "who has an opinion about this cid", where a tombstone is
// still an answer.
var udlUpsertScript = redis.NewScript(` var udlUpsertScript = redis.NewScript(`
local stored = redis.call('HGET', KEYS[1], 'version') local stored = redis.call('HGET', KEYS[1], 'version')
if stored and tonumber(stored) >= tonumber(ARGV[1]) then if stored and tonumber(stored) >= tonumber(ARGV[1]) then
@@ -84,7 +92,11 @@ if ARGV[5] == '1' then
else else
redis.call('HDEL', KEYS[1], 'value') redis.call('HDEL', KEYS[1], 'value')
end end
if ARGV[9] == '1' then
redis.call('SREM', KEYS[2], ARGV[6])
else
redis.call('SADD', KEYS[2], ARGV[6]) redis.call('SADD', KEYS[2], ARGV[6])
end
redis.call('SADD', KEYS[3], ARGV[7]) redis.call('SADD', KEYS[3], ARGV[7])
redis.call('SADD', KEYS[4], ARGV[8]) redis.call('SADD', KEYS[4], ARGV[8])
redis.call('SADD', KEYS[5], ARGV[8]) redis.call('SADD', KEYS[5], ARGV[8])
@@ -94,8 +106,10 @@ return 1
// UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored // UDLUpsertIfNewer stores a record cell iff version strictly exceeds the stored
// one. publicValue/hasValue carry the plaintext JSON scalar for public keys // one. publicValue/hasValue carry the plaintext JSON scalar for public keys
// (used by aggregation); pass hasValue=false for private-tier keys and // (used by aggregation); pass hasValue=false for private-tier keys and
// tombstones. Returns accepted=false when the write is stale. // tombstones. Set tombstone=true when the record deletes the cell, which also
func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, record, publicValue string, hasValue bool) (bool, error) { // prunes the cid from the user+key index. Returns accepted=false when the write
// is stale.
func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, record, publicValue string, hasValue, tombstone bool) (bool, error) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -117,7 +131,11 @@ func (c *Client) UDLUpsertIfNewer(uid, cid, key string, version, ts int64, recor
if hasValue { if hasValue {
hv = "1" hv = "1"
} }
res, err := udlUpsertScript.Run(ctx, c.client, keys, version, ts, record, publicValue, hv, cid, key, uid).Result() tomb := "0"
if tombstone {
tomb = "1"
}
res, err := udlUpsertScript.Run(ctx, c.client, keys, version, ts, record, publicValue, hv, cid, key, uid, tomb).Result()
if err != nil { if err != nil {
return false, fmt.Errorf("udl upsert failed: %w", err) return false, fmt.Errorf("udl upsert failed: %w", err)
} }
+79
View File
@@ -0,0 +1,79 @@
package storage
import (
"testing"
)
// A tombstone must leave the cell in place but drop the cid from the user+key
// index.
//
// The cell has to stay: it *is* the CRDT state, and deleting it would let an
// older write resurrect the value the tombstone retracted. The index entry has
// to go: `active_for_user_key` returns every member of that set, so a set that
// only ever grows means each My List / Continue Watching read carries every
// title the user has ever un-liked or finished — with its full record blob —
// forever. The reader filters them out again, so the cost is invisible until
// the account is old.
func TestUDLUpsert_TombstonePrunesUserKeyIndexButKeepsCell(t *testing.T) {
c, _ := newTestClient(t, "")
const (
uid = "zAlice"
cid = "bagcsaaalocator"
key = "seek"
)
// A live position: indexed, readable.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 100, "rec-v1", "", false, false); err != nil || !ok {
t.Fatalf("live upsert: ok=%v err=%v", ok, err)
}
entries, err := c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key: %v", err)
}
if len(entries) != 1 || entries[0].Cid != cid {
t.Fatalf("live position should be indexed, got %+v", entries)
}
// Finishing it tombstones the seek.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 2, 200, "rec-v2-tombstone", "", false, true); err != nil || !ok {
t.Fatalf("tombstone upsert: ok=%v err=%v", ok, err)
}
entries, err = c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key after tombstone: %v", err)
}
if len(entries) != 0 {
t.Fatalf("tombstoned cid must leave the user+key index, got %+v", entries)
}
// ...but the cell itself survives, at the tombstone's version.
cell, exists, err := c.UDLGetCell(uid, cid, key)
if err != nil {
t.Fatalf("get cell: %v", err)
}
if !exists {
t.Fatal("tombstone must not delete the cell — an older write could then resurrect the value")
}
if cell.Version != 2 || cell.Record != "rec-v2-tombstone" {
t.Fatalf("cell should hold the tombstone record at v2, got %+v", cell)
}
// And the high-water mark still rejects the superseded write.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 1, 300, "rec-v1-replay", "", false, false); err != nil || ok {
t.Fatalf("stale replay must be rejected: ok=%v err=%v", ok, err)
}
// Re-watching re-establishes the cell and puts it back in the index.
if ok, err := c.UDLUpsertIfNewer(uid, cid, key, 3, 400, "rec-v3", "", false, false); err != nil || !ok {
t.Fatalf("re-establish upsert: ok=%v err=%v", ok, err)
}
entries, err = c.UDLListUserKey(uid, key)
if err != nil {
t.Fatalf("active_for_user_key after re-establish: %v", err)
}
if len(entries) != 1 || entries[0].Cid != cid {
t.Fatalf("re-established cid must return to the index, got %+v", entries)
}
}