improved identity management
This commit is contained in:
@@ -3,11 +3,14 @@ package api
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/metazla/meta-core/internal/identity"
|
||||
"github.com/metazla/meta-core/internal/storage"
|
||||
)
|
||||
|
||||
// identityStatusResponse is the shape of GET /api/identity. The private key
|
||||
@@ -59,12 +62,40 @@ type identityImportResponse struct {
|
||||
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.
|
||||
// identityChallengeRequest asks for a one-shot challenge to sign.
|
||||
type identityChallengeRequest struct {
|
||||
UID string `json:"uid"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
|
||||
type identityChallengeResponse struct {
|
||||
Challenge string `json:"challenge"`
|
||||
ExpiresAt int64 `json:"expiresAt"`
|
||||
}
|
||||
|
||||
// identityProof is the proof-of-possession carried by reveal and delete: the
|
||||
// challenge text as issued, and a signature over it by the account's key.
|
||||
//
|
||||
// The signature is produced by whoever holds the key — the browser, locally.
|
||||
// It is NOT obtainable from POST /api/identity/sign, which refuses any payload
|
||||
// in the challenge domain; that refusal is what makes this proof mean
|
||||
// something on a node that stores every private key in plaintext.
|
||||
type identityProof struct {
|
||||
Challenge string `json:"challenge"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// identityRevealRequest asks for an existing account's private key back.
|
||||
//
|
||||
// Gated on proof-of-possession, which makes it a "does this node hold the same
|
||||
// key I do" check rather than the recovery path it once was. That is the
|
||||
// deliberate consequence of the rule this file enforces: the private key, and
|
||||
// nothing else, is authority over the account. An operator with root on the box
|
||||
// can still read the file; an operator with only the API cannot.
|
||||
type identityRevealRequest struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
UID string `json:"uid"`
|
||||
identityProof
|
||||
}
|
||||
|
||||
type identityRevealResponse struct {
|
||||
@@ -76,6 +107,15 @@ type identityRevealResponse struct {
|
||||
type identityDeleteRequest struct {
|
||||
Confirm bool `json:"confirm"`
|
||||
UID string `json:"uid"`
|
||||
identityProof
|
||||
}
|
||||
|
||||
// identityDeleteResponse reports what the delete actually removed. The purge
|
||||
// counts are the point: "account removed" alone leaves the caller unable to
|
||||
// tell a clean removal from one that silently stranded a decade of ratings.
|
||||
type identityDeleteResponse struct {
|
||||
UID string `json:"uid"`
|
||||
Purged storage.UDLUserStats `json:"purged"`
|
||||
}
|
||||
|
||||
type identitySignRequest struct {
|
||||
@@ -114,6 +154,16 @@ type identitySignBatchResponse struct {
|
||||
// should page. Sized well above the longest real series (Supernatural, 327).
|
||||
const maxSignBatch = 1000
|
||||
|
||||
// reservedPayloadMessage explains the one refusal the signing endpoints make.
|
||||
//
|
||||
// This is the load-bearing half of proof-of-possession on a node that holds
|
||||
// every private key: without it, a caller wanting to delete someone else's
|
||||
// account just asks meta-core to sign the challenge for them, and the gate on
|
||||
// reveal/delete becomes a formality. See identity.ChallengeDomain.
|
||||
const reservedPayloadMessage = "payload is in the reserved account-authorisation domain (" +
|
||||
identity.ChallengeDomain + "…); meta-core will not sign one. " +
|
||||
"Signing a challenge is what proves you hold the key, so it must be done where the key is."
|
||||
|
||||
type identityAEADKeyResponse struct {
|
||||
AEADKeyB64 string `json:"aeadKeyB64"`
|
||||
}
|
||||
@@ -243,23 +293,105 @@ func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// 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)
|
||||
// handleIdentityChallenge issues the one-shot text a client signs to prove it
|
||||
// holds an account's private key.
|
||||
//
|
||||
// Unauthenticated by design: a challenge is useless without the key, and
|
||||
// GET /api/identity/accounts already publishes every uid, so refusing to mint
|
||||
// one for an unknown caller would protect nothing. Requiring the account to
|
||||
// exist is for a clear error, not secrecy.
|
||||
func (s *Server) handleIdentityChallenge(w http.ResponseWriter, r *http.Request) {
|
||||
var body identityChallengeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
uid := strings.TrimSpace(body.UID)
|
||||
if uid == "" {
|
||||
writeErrorSlug(w, http.StatusBadRequest, "uid_required", "uid is required", false)
|
||||
return
|
||||
}
|
||||
action := identity.Action(strings.TrimSpace(body.Action))
|
||||
if !identity.ValidAction(action) {
|
||||
writeErrorSlug(w, http.StatusBadRequest, "bad_action",
|
||||
`action must be "reveal" or "delete"`, false)
|
||||
return
|
||||
}
|
||||
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
|
||||
writeErrorSlug(w, http.StatusNotFound, "no_identity", "no identity for the given uid", false)
|
||||
return
|
||||
}
|
||||
text, expires, err := s.challenges.Issue(uid, action)
|
||||
if err != nil {
|
||||
if errors.Is(err, identity.ErrChallengeFull) {
|
||||
writeErrorSlug(w, http.StatusServiceUnavailable, "challenges_full", err.Error(), true)
|
||||
return
|
||||
}
|
||||
writeErrorSlug(w, http.StatusBadRequest, "bad_uid", err.Error(), false)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, identityChallengeResponse{
|
||||
Challenge: text,
|
||||
ExpiresAt: expires.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// requireProof resolves the target account and verifies the caller's
|
||||
// proof-of-possession for `action`. status == 0 means the proof held.
|
||||
//
|
||||
// The uid is mandatory here, unlike elsewhere in this file: the old "sole
|
||||
// account" fallback would have this pick a target the signature does not name,
|
||||
// and a proof that authorises an unnamed account authorises the wrong one as
|
||||
// soon as a second profile exists.
|
||||
func (s *Server) requireProof(uid string, proof identityProof, action identity.Action) (
|
||||
id *identity.Identity, status int, slug, msg string,
|
||||
) {
|
||||
uid = strings.TrimSpace(uid)
|
||||
if uid == "" {
|
||||
return nil, http.StatusBadRequest, "uid_required",
|
||||
"uid is required: the signature authorises one specific account"
|
||||
}
|
||||
if proof.Challenge == "" || proof.Signature == "" {
|
||||
return nil, http.StatusUnauthorized, "signature_required",
|
||||
"this operation requires proof you hold the account's private key: " +
|
||||
"POST /api/identity/challenge, sign the returned text, and resend it as {challenge, signature}"
|
||||
}
|
||||
sig, err := base64.StdEncoding.DecodeString(proof.Signature)
|
||||
if err != nil {
|
||||
return nil, http.StatusBadRequest, "bad_signature", "signature is not valid base64"
|
||||
}
|
||||
// Resolve before redeeming so a request naming a missing account does not
|
||||
// burn a challenge it could not have used anyway.
|
||||
id, status, slug, msg = s.resolveAccount(uid)
|
||||
if status != 0 {
|
||||
return nil, status, slug, msg
|
||||
}
|
||||
// Redeem first, verify second: the challenge is single-use either way, so a
|
||||
// wrong signature costs the attacker a fresh round trip per guess rather
|
||||
// than letting them grind one challenge offline against the same nonce.
|
||||
if err := s.challenges.Redeem(proof.Challenge, uid, action); err != nil {
|
||||
return nil, http.StatusUnauthorized, "bad_challenge", err.Error()
|
||||
}
|
||||
if err := identity.Verify(uid, []byte(proof.Challenge), sig); err != nil {
|
||||
return nil, http.StatusUnauthorized, "bad_signature", err.Error()
|
||||
}
|
||||
return id, 0, "", ""
|
||||
}
|
||||
|
||||
// handleIdentityReveal returns an existing account's private key to a caller
|
||||
// that proves it already holds that key.
|
||||
//
|
||||
// Deliberately POST (never a bare GET) so it cannot be triggered by a link or
|
||||
// an image tag. The proof is what actually guards it: before this gate existed,
|
||||
// anything that could reach the port could read every private key on the node.
|
||||
func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
|
||||
var body identityRevealRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
uid := strings.TrimSpace(body.UID)
|
||||
if uid == "" {
|
||||
uid = strings.TrimSpace(r.URL.Query().Get("uid"))
|
||||
}
|
||||
id, status, slug, msg := s.resolveAccount(uid)
|
||||
id, status, slug, msg := s.requireProof(uid, body.identityProof, identity.ActionReveal)
|
||||
if status != 0 {
|
||||
writeErrorSlug(w, status, slug, msg, false)
|
||||
return
|
||||
@@ -271,46 +403,62 @@ func (s *Server) handleIdentityReveal(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// handleIdentityDelete removes one account and everything it wrote.
|
||||
//
|
||||
// Two things happen, in this order and no other:
|
||||
//
|
||||
// 1. the account's User Data Layer rows are purged (cells plus every index
|
||||
// that names the uid, including the cid-scoped ones shared with other
|
||||
// accounts);
|
||||
// 2. the keypair file is removed.
|
||||
//
|
||||
// Purge-then-delete so a failure never strands data: if the purge errors the
|
||||
// key is still on disk, the account still exists, and the caller can retry.
|
||||
// The reverse order would leave rows nobody can ever attribute, reach or
|
||||
// rewrite — the orphaning this endpoint exists to prevent.
|
||||
//
|
||||
// What deletion still does NOT do, and cannot: records already signed under
|
||||
// this uid and replicated to other peers keep verifying there. Removing a key
|
||||
// is not a recall.
|
||||
func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var body identityDeleteRequest
|
||||
// Tolerate empty body — confirm flag in a query param is also acceptable
|
||||
// for shells where sending a JSON body with DELETE is awkward.
|
||||
// Tolerate an empty body so the uid may ride in the query — meta-watch's
|
||||
// proxy puts it there. The proof itself must be in the body: a signature in
|
||||
// a URL ends up in every access log between here and the browser.
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
if !body.Confirm && r.URL.Query().Get("confirm") != "true" {
|
||||
writeErrorSlug(w, http.StatusBadRequest, "confirm_required",
|
||||
"identity deletion requires {\"confirm\": true} or ?confirm=true", false)
|
||||
return
|
||||
}
|
||||
dir := s.config.IdentityAccountsDir()
|
||||
uid := strings.TrimSpace(body.UID)
|
||||
if uid == "" {
|
||||
uid = strings.TrimSpace(r.URL.Query().Get("uid"))
|
||||
}
|
||||
if uid == "" {
|
||||
// Back-compat: with no uid, delete the sole account. Ambiguous (and so
|
||||
// refused) once more than one account exists.
|
||||
list, err := identity.List(dir)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
switch len(list) {
|
||||
case 0:
|
||||
w.WriteHeader(http.StatusNoContent) // idempotent
|
||||
return
|
||||
case 1:
|
||||
uid = list[0].UID
|
||||
default:
|
||||
writeErrorSlug(w, http.StatusBadRequest, "uid_required",
|
||||
"multiple accounts exist; specify uid to delete", false)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := identity.DeleteByUID(dir, uid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
id, status, slug, msg := s.requireProof(uid, body.identityProof, identity.ActionDelete)
|
||||
if status != 0 {
|
||||
writeErrorSlug(w, status, slug, msg, false)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
// A purge needs storage. Refusing here — rather than deleting the key and
|
||||
// reporting the purge as "0 records" — is the difference between a retry
|
||||
// and permanent orphaned data: once the key is gone the account cannot be
|
||||
// named for a second attempt through this API.
|
||||
if !s.storage.IsConnected() {
|
||||
writeErrorSlug(w, http.StatusServiceUnavailable, "storage_unavailable",
|
||||
"storage is not connected; refusing to delete the key while its data cannot be purged", true)
|
||||
return
|
||||
}
|
||||
purged, err := s.storage.UDLPurgeUser(id.UID)
|
||||
if err != nil {
|
||||
writeErrorSlug(w, http.StatusInternalServerError, "purge_failed",
|
||||
"could not purge this account's data; the account was NOT deleted: "+err.Error(), true)
|
||||
return
|
||||
}
|
||||
|
||||
if err := identity.DeleteByUID(s.config.IdentityAccountsDir(), id.UID); err != nil {
|
||||
writeErrorSlug(w, http.StatusInternalServerError, "delete_failed", err.Error(), false)
|
||||
return
|
||||
}
|
||||
log.Printf("[API] Deleted account %s and purged %d User Data Layer records across %d cids",
|
||||
id.UID, purged.Records, purged.Cids)
|
||||
writeJSON(w, http.StatusOK, identityDeleteResponse{UID: id.UID, Purged: purged})
|
||||
}
|
||||
|
||||
func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -333,6 +481,10 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64")
|
||||
return
|
||||
}
|
||||
if identity.IsChallengePayload(raw) {
|
||||
writeErrorSlug(w, http.StatusForbidden, "reserved_payload", reservedPayloadMessage, false)
|
||||
return
|
||||
}
|
||||
sig, err := identity.Sign(id, raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -380,6 +532,11 @@ func (s *Server) handleIdentitySignBatch(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "payloadsB64["+strconv.Itoa(i)+"] is not valid base64")
|
||||
return
|
||||
}
|
||||
if identity.IsChallengePayload(raw) {
|
||||
writeErrorSlug(w, http.StatusForbidden, "reserved_payload",
|
||||
"payloadsB64["+strconv.Itoa(i)+"]: "+reservedPayloadMessage, false)
|
||||
return
|
||||
}
|
||||
sig, err := identity.Sign(id, raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package api
|
||||
|
||||
// End-to-end exercise of the account gate against a live router + miniredis:
|
||||
// generate -> write UDL rows -> stats -> challenge -> sign -> delete -> verify
|
||||
// both the key and every row are gone. Deliberately drives the real HTTP
|
||||
// handlers rather than the packages beneath them, because the bugs that matter
|
||||
// here (a challenge accepted for the wrong action, a purge that runs after the
|
||||
// key is gone) live in the wiring.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/metazla/meta-core/internal/config"
|
||||
"github.com/metazla/meta-core/internal/identity"
|
||||
"github.com/metazla/meta-core/internal/storage"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
stor := storage.NewClient("")
|
||||
if err := stor.Connect("redis://" + mr.Addr()); err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = stor.Close() })
|
||||
|
||||
s := &Server{
|
||||
config: &config.Config{MetaCorePath: t.TempDir()},
|
||||
storage: stor,
|
||||
challenges: identity.NewChallengeStore(),
|
||||
router: mux.NewRouter(),
|
||||
}
|
||||
s.setupIdentityTestRoutes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Only the routes under test, so this does not depend on every other subsystem
|
||||
// booting.
|
||||
func (s *Server) setupIdentityTestRoutes() {
|
||||
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
|
||||
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).Methods("POST")
|
||||
s.router.HandleFunc("/api/identity/generate", s.handleIdentityGenerate).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/sign", s.handleIdentitySign).Methods("POST")
|
||||
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
|
||||
}
|
||||
|
||||
func do(t *testing.T, s *Server, method, path string, body any) (*httptest.ResponseRecorder, map[string]any) {
|
||||
t.Helper()
|
||||
var rdr *bytes.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rdr = bytes.NewReader(b)
|
||||
} else {
|
||||
rdr = bytes.NewReader(nil)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, rdr)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, req)
|
||||
out := map[string]any{}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
return rec, out
|
||||
}
|
||||
|
||||
// signAs produces the proof a browser would, from the raw key.
|
||||
func signAs(t *testing.T, privHex, challenge string) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := hex.DecodeString(privHex)
|
||||
if err != nil {
|
||||
t.Fatalf("decode key: %v", err)
|
||||
}
|
||||
id, err := identity.Import(hex.EncodeToString(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
sig, err := identity.Sign(id, []byte(challenge))
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return map[string]any{
|
||||
"challenge": challenge,
|
||||
"signature": base64.StdEncoding.EncodeToString(sig),
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_DeleteRequiresProofAndPurgesEverything(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
|
||||
rec, gen := do(t, s, "POST", "/api/identity/generate", nil)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("generate: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
uid := gen["uid"].(string)
|
||||
priv := gen["privateKeyHex"].(string)
|
||||
|
||||
// Some data to lose.
|
||||
for _, cell := range [][2]string{{"cidA", "like"}, {"cidA", "rating"}, {"cidB", "seek"}} {
|
||||
if ok, err := s.storage.UDLUpsertIfNewer(uid, cell[0], cell[1], 1, 100, "rec", "", false, false); err != nil || !ok {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The count the UI shows in the confirmation.
|
||||
rec, stats := do(t, s, "GET", "/api/udl/users/stats", nil)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("stats: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
users := stats["users"].(map[string]any)
|
||||
mine := users[uid].(map[string]any)
|
||||
if mine["records"].(float64) != 3 || mine["cids"].(float64) != 2 {
|
||||
t.Fatalf("stats = %v, want 3 records / 2 cids", mine)
|
||||
}
|
||||
|
||||
// Unsigned delete must be refused, and must not have touched anything.
|
||||
rec, body := do(t, s, "DELETE", "/api/identity", map[string]any{"uid": uid, "confirm": true})
|
||||
if rec.Code != http.StatusUnauthorized || body["error"] != "signature_required" {
|
||||
t.Fatalf("unsigned delete = %d %v, want 401 signature_required", rec.Code, body)
|
||||
}
|
||||
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
|
||||
t.Fatal("a refused delete removed the key anyway")
|
||||
}
|
||||
|
||||
// meta-core must refuse to sign the caller's authorisation for them —
|
||||
// without this the gate is decoration on a node holding every key.
|
||||
ch, _, err := s.challenges.Issue(uid, identity.ActionDelete)
|
||||
if err != nil {
|
||||
t.Fatalf("issue: %v", err)
|
||||
}
|
||||
rec, body = do(t, s, "POST", "/api/identity/sign", map[string]any{
|
||||
"uid": uid,
|
||||
"bytesB64": base64.StdEncoding.EncodeToString([]byte(ch)),
|
||||
})
|
||||
if rec.Code != http.StatusForbidden || body["error"] != "reserved_payload" {
|
||||
t.Fatalf("sign of a challenge = %d %v, want 403 reserved_payload", rec.Code, body)
|
||||
}
|
||||
|
||||
// A challenge minted for reveal must not authorise a delete.
|
||||
rec, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "reveal"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("challenge: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
proof := signAs(t, priv, chBody["challenge"].(string))
|
||||
req := map[string]any{"uid": uid, "confirm": true}
|
||||
for k, v := range proof {
|
||||
req[k] = v
|
||||
}
|
||||
rec, body = do(t, s, "DELETE", "/api/identity", req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("reveal challenge used for delete = %d %v, want 401", rec.Code, body)
|
||||
}
|
||||
|
||||
// The real thing.
|
||||
rec, chBody = do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "delete"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("challenge: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
proof = signAs(t, priv, chBody["challenge"].(string))
|
||||
req = map[string]any{"uid": uid, "confirm": true}
|
||||
for k, v := range proof {
|
||||
req[k] = v
|
||||
}
|
||||
rec, body = do(t, s, "DELETE", "/api/identity", req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("signed delete = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
purged := body["purged"].(map[string]any)
|
||||
if purged["records"].(float64) != 3 || purged["cids"].(float64) != 2 {
|
||||
t.Fatalf("purged = %v, want 3 records / 2 cids", purged)
|
||||
}
|
||||
|
||||
// Key gone.
|
||||
if identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
|
||||
t.Fatal("the account file survived the delete")
|
||||
}
|
||||
// Data gone — the orphaning this whole change exists to prevent.
|
||||
after, err := s.storage.UDLAllUserStats()
|
||||
if err != nil {
|
||||
t.Fatalf("stats after: %v", err)
|
||||
}
|
||||
if got, present := after[uid]; present {
|
||||
t.Fatalf("deleted account still holds %+v", got)
|
||||
}
|
||||
for _, cid := range []string{"cidA", "cidB"} {
|
||||
uids, err := s.storage.UDLCidUsers(cid)
|
||||
if err != nil {
|
||||
t.Fatalf("cid users: %v", err)
|
||||
}
|
||||
if len(uids) != 0 {
|
||||
t.Fatalf("%s still names %v after the purge", cid, uids)
|
||||
}
|
||||
}
|
||||
|
||||
// The challenge is single-use, so replaying the same proof does nothing.
|
||||
rec, _ = do(t, s, "DELETE", "/api/identity", req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("a replayed proof must not succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_RevealRequiresProof(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
_, gen := do(t, s, "POST", "/api/identity/generate", nil)
|
||||
uid := gen["uid"].(string)
|
||||
priv := gen["privateKeyHex"].(string)
|
||||
|
||||
rec, body := do(t, s, "POST", "/api/identity/reveal", map[string]any{"uid": uid, "confirm": true})
|
||||
if rec.Code != http.StatusUnauthorized || body["error"] != "signature_required" {
|
||||
t.Fatalf("unsigned reveal = %d %v, want 401 signature_required", rec.Code, body)
|
||||
}
|
||||
|
||||
_, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "reveal"})
|
||||
proof := signAs(t, priv, chBody["challenge"].(string))
|
||||
req := map[string]any{"uid": uid, "confirm": true}
|
||||
for k, v := range proof {
|
||||
req[k] = v
|
||||
}
|
||||
rec, body = do(t, s, "POST", "/api/identity/reveal", req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("signed reveal = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if body["privateKeyHex"] != priv {
|
||||
t.Fatal("reveal returned a different key than generate did")
|
||||
}
|
||||
}
|
||||
|
||||
// Another account's key must not authorise this one, even with a challenge
|
||||
// minted for the right uid and action.
|
||||
func TestE2E_AnotherAccountsKeyIsRefused(t *testing.T) {
|
||||
s := newTestServer(t)
|
||||
_, alice := do(t, s, "POST", "/api/identity/generate", nil)
|
||||
_, mallory := do(t, s, "POST", "/api/identity/generate", nil)
|
||||
|
||||
uid := alice["uid"].(string)
|
||||
_, chBody := do(t, s, "POST", "/api/identity/challenge", map[string]any{"uid": uid, "action": "delete"})
|
||||
proof := signAs(t, mallory["privateKeyHex"].(string), chBody["challenge"].(string))
|
||||
req := map[string]any{"uid": uid, "confirm": true}
|
||||
for k, v := range proof {
|
||||
req[k] = v
|
||||
}
|
||||
rec, body := do(t, s, "DELETE", "/api/identity", req)
|
||||
if rec.Code != http.StatusUnauthorized || body["error"] != "bad_signature" {
|
||||
t.Fatalf("mallory deleting alice = %d %v, want 401 bad_signature", rec.Code, body)
|
||||
}
|
||||
if !identity.ExistsByUID(s.config.IdentityAccountsDir(), uid) {
|
||||
t.Fatal("alice's account was deleted by mallory's signature")
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,11 @@ type Server struct {
|
||||
schemaIndexer *schema.Indexer
|
||||
webdavHandler *webdav.Handler
|
||||
search *searchIndex
|
||||
router *mux.Router
|
||||
server *http.Server
|
||||
// challenges backs proof-of-possession on reveal/delete. In-process, so a
|
||||
// restart invalidates the ones in flight — see identity/challenge.go.
|
||||
challenges *identity.ChallengeStore
|
||||
router *mux.Router
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// NewServer creates a new API server
|
||||
@@ -139,6 +142,8 @@ func NewServer(
|
||||
// Initialize WebDAV handler (caching is handled by nginx proxy_cache)
|
||||
s.webdavHandler = webdav.NewHandler(cfg)
|
||||
|
||||
s.challenges = identity.NewChallengeStore()
|
||||
|
||||
// One-time migration: fold a pre-multi-account identity.json into the
|
||||
// per-account keystore so existing single-identity hosts keep their uid.
|
||||
if migrated, err := identity.MigrateLegacy(cfg.IdentityFilePath(), cfg.IdentityAccountsDir()); err != nil {
|
||||
@@ -170,6 +175,7 @@ func (s *Server) setupRoutes() {
|
||||
// at the perimeter — do NOT add to nginx-hash-lock unauth bypass.
|
||||
s.router.HandleFunc("/api/identity", s.handleIdentityGet).Methods("GET")
|
||||
s.router.HandleFunc("/api/identity/accounts", s.handleIdentityAccounts).Methods("GET")
|
||||
s.router.HandleFunc("/api/identity/challenge", s.handleIdentityChallenge).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/reveal", s.handleIdentityReveal).Methods("POST")
|
||||
@@ -212,6 +218,7 @@ func (s *Server) setupRoutes() {
|
||||
s.router.HandleFunc("/api/udl/user/{uid}/cid/{cid}", s.handleUDLUserCid).Methods("GET")
|
||||
s.router.HandleFunc("/api/udl/cid/{cid}/users", s.handleUDLCidUsers).Methods("GET")
|
||||
s.router.HandleFunc("/api/udl/cid/{cid}/aggregate", s.handleUDLAggregate).Methods("GET")
|
||||
s.router.HandleFunc("/api/udl/users/stats", s.handleUDLUserStats).Methods("GET")
|
||||
|
||||
// Metadata operations - base endpoints
|
||||
s.router.HandleFunc("/meta/{hash}", s.handleGetMeta).Methods("GET")
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/metazla/meta-core/internal/storage"
|
||||
)
|
||||
|
||||
// UDLPutRequest is the body of PUT /api/udl/record.
|
||||
@@ -349,3 +350,35 @@ func aggregatePublicValues(key string, values []string) UDLAggregate {
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// udlUserStatsResponse is the shape of GET /api/udl/users/stats.
|
||||
type udlUserStatsResponse struct {
|
||||
// Users maps uid -> counts. Accounts holding nothing are absent rather than
|
||||
// present with zeroes; a caller rendering a list treats "missing" as 0.
|
||||
Users map[string]storage.UDLUserStats `json:"users"`
|
||||
}
|
||||
|
||||
// handleUDLUserStats handles GET /api/udl/users/stats — how much every account
|
||||
// holds, in one keyspace walk.
|
||||
//
|
||||
// Bulk rather than per-uid because the only caller is the accounts page, which
|
||||
// needs a number for every row: the counts come from a SCAN (there is no
|
||||
// per-user cid index — see storage/udl_admin.go), and MATCH filters without
|
||||
// indexing, so N per-uid calls would walk the whole keyspace N times.
|
||||
//
|
||||
// Deliberately not on the critical path of anything. It is an admin read whose
|
||||
// cost grows with the keyspace, so callers should fetch it after rendering,
|
||||
// never before.
|
||||
func (s *Server) handleUDLUserStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.storage.IsConnected() {
|
||||
writeErrorSlug(w, http.StatusServiceUnavailable, "storage_unavailable",
|
||||
"storage not connected", true)
|
||||
return
|
||||
}
|
||||
stats, err := s.storage.UDLAllUserStats()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, udlUserStatsResponse{Users: stats})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user