Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55d08e77ed | ||
|
|
1f084b817c | ||
|
|
80050cd2a8 |
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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,7 +22,7 @@
|
|||||||
"25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable",
|
"25": "nzb-posting - content-derived (Message-ID set digest), but not itself fetchable",
|
||||||
"20": "BitTorrent infohashes - swarm interop",
|
"20": "BitTorrent infohashes - swarm interop",
|
||||||
"10": "midhash256 - the record address",
|
"10": "midhash256 - the record address",
|
||||||
"5": "opaque locators (nzb-release, url) - never outrank a real digest",
|
"5": "opaque locators (nzb-release, url, card) - never outrank a real digest",
|
||||||
"0": "weak/unknown digests, unparseable input"
|
"0": "weak/unknown digests, unparseable input"
|
||||||
},
|
},
|
||||||
"tieBreak": "lexicographically smaller cid wins",
|
"tieBreak": "lexicographically smaller cid wins",
|
||||||
@@ -110,6 +110,12 @@
|
|||||||
"rank": 5,
|
"rank": 5,
|
||||||
"note": "codec 0x1006 + IDENTITY multihash 0x00. Same tier/reasoning as nzb-release."
|
"note": "codec 0x1006 + IDENTITY multihash 0x00. Same tier/reasoning as nzb-release."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "card-locator",
|
||||||
|
"cid": "bagdsaaanar2g2zdcor3duojvgq3ts",
|
||||||
|
"rank": 5,
|
||||||
|
"note": "codec 0x1007 + IDENTITY multihash 0x00, digest = varint(len(source)) || source || id — here (\"tmdb\",\"tv:95479\"). Same codec-slot rule as nzb-release/url: match on the CODEC, never the mh. The limit case of the family — the only CID in MetaMesh with NO bytes behind it anywhere, ever: it addresses a *work* (a series, a film), not a file, so the byte path 404s by design and bitswap never offers it (seeding is gated on sha2-256). Tier 5 is load-bearing for the planned cross-source merge: one card record may carry BOTH a TMDB and a MyAnimeList locator, and if a real card body is ever added (see card-tier-search.md §4.3) its sha2-256 must win the canonical election over both locators without any addressing change. Ranking it 0 instead would still lose to a digest, but would put the implementations back out of agreement — which is the exact failure this fixture exists to prevent."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "sha1",
|
"name": "sha1",
|
||||||
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
|
"cid": "baeircff3eqvxxaqqot2lt242stkdtk3gq4c2vra",
|
||||||
|
|||||||
+10
-2
@@ -49,6 +49,14 @@ const (
|
|||||||
// these — matching on mh would see 0x00 and rank them 0.
|
// these — matching on mh would see 0x00 and rank them 0.
|
||||||
CodeNzbRelease = 0x1005 // custom — self-describing Newznab release locator
|
CodeNzbRelease = 0x1005 // custom — self-describing Newznab release locator
|
||||||
CodeURL = 0x1006 // custom — identity-multihash CID wrapping an http(s) URL
|
CodeURL = 0x1006 // custom — identity-multihash CID wrapping an http(s) URL
|
||||||
|
// CodeCard is the limit case of the family: the only CID with NO bytes
|
||||||
|
// behind it anywhere, ever. It addresses a *work* (a series, a film) via
|
||||||
|
// varint(len(source))‖source‖id — ("tmdb","tv:95479") — so `/api/file/{cid}`
|
||||||
|
// correctly 404s and bitswap never offers it. Ranking it here (rather than
|
||||||
|
// letting it fall to RankUnknown) is what makes the planned "one card, two
|
||||||
|
// CIDs" cross-source merge safe: a TMDB and a MyAnimeList locator can sit on
|
||||||
|
// one record, and a real digest added later still wins the election.
|
||||||
|
CodeCard = 0x1007 // custom — identity-multihash CID wrapping a card (work) id
|
||||||
|
|
||||||
// CodeNzbPosting is the INVERSE trap of the two above: its multihash is a
|
// CodeNzbPosting is the INVERSE trap of the two above: its multihash is a
|
||||||
// REAL sha2-256 digest (over the normalised, sorted article Message-ID
|
// REAL sha2-256 digest (over the normalised, sorted article Message-ID
|
||||||
@@ -134,7 +142,7 @@ func Decode(cidStr string) (codec uint64, mhCode uint64, err error) {
|
|||||||
// swarm interop, not strength.
|
// swarm interop, not strength.
|
||||||
// - midhash256 is the floor among real *digests*: internally meaningful,
|
// - midhash256 is the floor among real *digests*: internally meaningful,
|
||||||
// externally invisible.
|
// externally invisible.
|
||||||
// - opaque locators (nzb-release, url) sit BELOW midhash. They are not
|
// - opaque locators (nzb-release, url, card) sit BELOW midhash. They are not
|
||||||
// content-derived — two URLs to the same bytes yield two different locator
|
// content-derived — two URLs to the same bytes yield two different locator
|
||||||
// CIDs — so a locator must never displace a real digest as the identity a
|
// CIDs — so a locator must never displace a real digest as the identity a
|
||||||
// record is advertised under. Tier 5 means a locator wins the election only
|
// record is advertised under. Tier 5 means a locator wins the election only
|
||||||
@@ -157,7 +165,7 @@ func Rank(cidStr string) int {
|
|||||||
}
|
}
|
||||||
// Opaque locators. Codec-only: their multihash is identity (0x00), so
|
// Opaque locators. Codec-only: their multihash is identity (0x00), so
|
||||||
// there is nothing to match on the mh side.
|
// there is nothing to match on the mh side.
|
||||||
if codec == CodeNzbRelease || codec == CodeURL {
|
if codec == CodeNzbRelease || codec == CodeURL || codec == CodeCard {
|
||||||
return RankLocator
|
return RankLocator
|
||||||
}
|
}
|
||||||
// nzb-posting: codec-only, checked BEFORE the mh switch below — its
|
// nzb-posting: codec-only, checked BEFORE the mh switch below — its
|
||||||
|
|||||||
+22
-4
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user