From 52f5b5cedcfb5fe7e4304090c38c3bfb352e16dd Mon Sep 17 00:00:00 2001 From: Pierre Date: Sat, 6 Jun 2026 18:44:52 +0200 Subject: [PATCH] multi identity support for metacore --- internal/api/handlers_identity.go | 157 ++++++++++++++++++++++------- internal/api/server.go | 10 ++ internal/config/config.go | 11 +- internal/identity/identity.go | 130 ++++++++++++++++++++++++ internal/identity/identity_test.go | 105 +++++++++++++++++++ 5 files changed, 375 insertions(+), 38 deletions(-) diff --git a/internal/api/handlers_identity.go b/internal/api/handlers_identity.go index e94dc31..1cd43ce 100644 --- a/internal/api/handlers_identity.go +++ b/internal/api/handlers_identity.go @@ -17,6 +17,22 @@ type identityStatusResponse struct { UID string `json:"uid,omitempty"` Curve string `json:"curve,omitempty"` CreatedAt int64 `json:"createdAt,omitempty"` + // AccountCount is the total number of accounts in the keystore. Lets + // multi-account clients (meta-watch) tell "single identity" from "pick a + // profile" without a second call. Older single-identity clients ignore it. + AccountCount int `json:"accountCount,omitempty"` +} + +// identityAccountInfo is one entry in GET /api/identity/accounts. Never carries +// the private key — that is only ever returned once, by generate. +type identityAccountInfo struct { + UID string `json:"uid"` + Curve string `json:"curve"` + CreatedAt int64 `json:"createdAt"` +} + +type identityAccountsResponse struct { + Accounts []identityAccountInfo `json:"accounts"` } type identityGenerateResponse struct { @@ -37,11 +53,15 @@ type identityImportResponse struct { } type identityDeleteRequest struct { - Confirm bool `json:"confirm"` + Confirm bool `json:"confirm"` + UID string `json:"uid"` } type identitySignRequest struct { BytesB64 string `json:"bytesB64"` + // UID selects which account signs. Optional — when omitted and exactly one + // account exists, that account is used (single-identity back-compat). + UID string `json:"uid,omitempty"` } type identitySignResponse struct { @@ -52,36 +72,79 @@ type identityAEADKeyResponse struct { AEADKeyB64 string `json:"aeadKeyB64"` } +// resolveAccount picks the account an operation targets. A non-empty uid must +// match an existing account. An empty uid falls back to the sole account +// (single-identity back-compat); with zero or many accounts and no uid it +// returns a non-zero status for the caller to surface. status == 0 means ok. +func (s *Server) resolveAccount(uid string) (id *identity.Identity, status int, slug, msg string) { + dir := s.config.IdentityAccountsDir() + uid = strings.TrimSpace(uid) + if uid != "" { + got, err := identity.LoadByUID(dir, uid) + if err != nil { + return nil, http.StatusBadRequest, "bad_uid", err.Error() + } + if got == nil { + return nil, http.StatusNotFound, "no_identity", "no identity for the given uid" + } + return got, 0, "", "" + } + list, err := identity.List(dir) + if err != nil { + return nil, http.StatusInternalServerError, "list_failed", err.Error() + } + switch len(list) { + case 0: + return nil, http.StatusNotFound, "no_identity", "no identity is configured" + case 1: + return list[0], 0, "", "" + default: + return nil, http.StatusBadRequest, "uid_required", "multiple accounts exist; specify uid" + } +} + func (s *Server) handleIdentityGet(w http.ResponseWriter, r *http.Request) { - id, err := identity.Load(s.config.IdentityFilePath()) + list, err := identity.List(s.config.IdentityAccountsDir()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - if id == nil { + if len(list) == 0 { writeJSON(w, http.StatusOK, identityStatusResponse{HasIdentity: false}) return } + // Back-compat: report the oldest account as "the" identity. Multi-account + // clients enumerate via GET /api/identity/accounts instead. + def := list[0] writeJSON(w, http.StatusOK, identityStatusResponse{ - HasIdentity: true, - UID: id.UID, - Curve: id.Curve, - CreatedAt: id.CreatedAt, + HasIdentity: true, + UID: def.UID, + Curve: def.Curve, + CreatedAt: def.CreatedAt, + AccountCount: len(list), }) } -func (s *Server) handleIdentityGenerate(w http.ResponseWriter, r *http.Request) { - if identity.Exists(s.config.IdentityFilePath()) { - writeErrorSlug(w, http.StatusConflict, "identity_exists", - "identity already exists; delete it first to generate a new one", false) +func (s *Server) handleIdentityAccounts(w http.ResponseWriter, r *http.Request) { + list, err := identity.List(s.config.IdentityAccountsDir()) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) return } + out := make([]identityAccountInfo, 0, len(list)) + for _, id := range list { + out = append(out, identityAccountInfo{UID: id.UID, Curve: id.Curve, CreatedAt: id.CreatedAt}) + } + writeJSON(w, http.StatusOK, identityAccountsResponse{Accounts: out}) +} + +func (s *Server) handleIdentityGenerate(w http.ResponseWriter, r *http.Request) { id, err := identity.Generate() if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - if err := identity.Save(s.config.IdentityFilePath(), id); err != nil { + if err := identity.SaveAccount(s.config.IdentityAccountsDir(), id); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } @@ -94,11 +157,6 @@ func (s *Server) handleIdentityGenerate(w http.ResponseWriter, r *http.Request) } func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) { - if identity.Exists(s.config.IdentityFilePath()) { - writeErrorSlug(w, http.StatusConflict, "identity_exists", - "identity already exists; delete it first to import a new one", false) - return - } var body identityImportRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") @@ -113,7 +171,13 @@ func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - if err := identity.Save(s.config.IdentityFilePath(), id); err != nil { + dir := s.config.IdentityAccountsDir() + if identity.ExistsByUID(dir, id.UID) { + writeErrorSlug(w, http.StatusConflict, "identity_exists", + "an account with this key already exists", false) + return + } + if err := identity.SaveAccount(dir, id); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } @@ -134,7 +198,32 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) { "identity deletion requires {\"confirm\": true} or ?confirm=true", false) return } - if err := identity.Delete(s.config.IdentityFilePath()); err != nil { + 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()) return } @@ -142,21 +231,20 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) { - id, err := identity.Load(s.config.IdentityFilePath()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if id == nil { - writeErrorSlug(w, http.StatusNotFound, "no_identity", - "no identity is configured", false) - return - } var body identitySignRequest if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON body") return } + uid := body.UID + if strings.TrimSpace(uid) == "" { + uid = r.URL.Query().Get("uid") + } + id, status, slug, msg := s.resolveAccount(uid) + if status != 0 { + writeErrorSlug(w, status, slug, msg, false) + return + } raw, err := base64.StdEncoding.DecodeString(body.BytesB64) if err != nil { writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64") @@ -173,14 +261,9 @@ func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) { - id, err := identity.Load(s.config.IdentityFilePath()) - if err != nil { - writeError(w, http.StatusInternalServerError, err.Error()) - return - } - if id == nil { - writeErrorSlug(w, http.StatusNotFound, "no_identity", - "no identity is configured", false) + id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid")) + if status != 0 { + writeErrorSlug(w, status, slug, msg, false) return } key, err := identity.DeriveAEADKey(id) diff --git a/internal/api/server.go b/internal/api/server.go index 6acc9a1..5d40ec3 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -11,6 +11,7 @@ import ( "github.com/metazla/meta-core/internal/config" "github.com/metazla/meta-core/internal/discovery" "github.com/metazla/meta-core/internal/events" + "github.com/metazla/meta-core/internal/identity" "github.com/metazla/meta-core/internal/leader" "github.com/metazla/meta-core/internal/mounts" "github.com/metazla/meta-core/internal/schema" @@ -136,6 +137,14 @@ func NewServer( // Initialize WebDAV handler (caching is handled by nginx proxy_cache) s.webdavHandler = webdav.NewHandler(cfg) + // 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 { + log.Printf("[API] Warning: identity migration failed: %v", err) + } else if migrated { + log.Printf("[API] Migrated legacy identity.json into multi-account keystore") + } + s.setupRoutes() return s } @@ -158,6 +167,7 @@ func (s *Server) setupRoutes() { // User Data Layer signing identity (see internal/identity). Auth-gated // 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/generate", s.handleIdentityGenerate).Methods("POST") s.router.HandleFunc("/api/identity/import", s.handleIdentityImport).Methods("POST") s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE") diff --git a/internal/config/config.go b/internal/config/config.go index f6f3033..7c3d0d7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -111,11 +111,20 @@ func (c *Config) IdentityDir() string { return c.MetaCorePath + "/identity" } -// IdentityFilePath returns the path to the persisted identity JSON. +// IdentityFilePath returns the path to the legacy single-identity JSON. It is +// only read once, by the multi-account migration on startup; new accounts live +// under IdentityAccountsDir. func (c *Config) IdentityFilePath() string { return c.IdentityDir() + "/identity.json" } +// IdentityAccountsDir returns the directory holding one JSON file per account +// in the multi-account keystore ({uid}.json). meta-core is a pure multi-key +// signer — display name / avatar live in the User Data Layer, not here. +func (c *Config) IdentityAccountsDir() string { + return c.IdentityDir() + "/accounts" +} + func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value diff --git a/internal/identity/identity.go b/internal/identity/identity.go index ffbbe07..7e4335e 100644 --- a/internal/identity/identity.go +++ b/internal/identity/identity.go @@ -21,6 +21,8 @@ import ( "io" "os" "path/filepath" + "sort" + "strings" "time" "github.com/decred/dcrd/dcrec/secp256k1/v4" @@ -158,6 +160,134 @@ func Exists(path string) bool { return err == nil } +// --- Multi-account keystore ------------------------------------------------- +// +// Accounts live as one file per keypair at {dir}/{uid}.json. The uid is a +// multibase base58btc string, so it is filename-safe; validUID still refuses +// anything that could escape the accounts directory. + +// validUID accepts the alphanumeric multibase/base58btc shape we mint for uids +// and rejects path separators or traversal sequences. +func validUID(uid string) bool { + if uid == "" || len(uid) > 128 || uid != filepath.Base(uid) { + return false + } + for _, r := range uid { + switch { + case r >= 'a' && r <= 'z': + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + default: + return false + } + } + return true +} + +func accountFilePath(dir, uid string) (string, error) { + if !validUID(uid) { + return "", fmt.Errorf("invalid uid: %q", uid) + } + return filepath.Join(dir, uid+".json"), nil +} + +// SaveAccount writes an identity into the multi-account store. It does not +// check for prior existence — callers wanting append-only semantics check +// ExistsByUID first. +func SaveAccount(dir string, id *Identity) error { + if id == nil { + return errors.New("nil identity") + } + path, err := accountFilePath(dir, id.UID) + if err != nil { + return err + } + return Save(path, id) +} + +// LoadByUID reads one account. Returns (nil, nil) when absent. +func LoadByUID(dir, uid string) (*Identity, error) { + path, err := accountFilePath(dir, uid) + if err != nil { + return nil, err + } + return Load(path) +} + +// ExistsByUID reports whether an account file exists for uid. +func ExistsByUID(dir, uid string) bool { + path, err := accountFilePath(dir, uid) + if err != nil { + return false + } + return Exists(path) +} + +// DeleteByUID removes one account. Idempotent. +func DeleteByUID(dir, uid string) error { + path, err := accountFilePath(dir, uid) + if err != nil { + return err + } + return Delete(path) +} + +// List returns every account, sorted by CreatedAt ascending (then UID, for a +// stable order among same-second creations). Returns nil when the directory +// does not exist yet. +func List(dir string) ([]*Identity, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("read accounts dir: %w", err) + } + var out []*Identity + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") { + continue + } + id, err := Load(filepath.Join(dir, e.Name())) + if err != nil { + return nil, err + } + if id != nil { + out = append(out, id) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].CreatedAt != out[j].CreatedAt { + return out[i].CreatedAt < out[j].CreatedAt + } + return out[i].UID < out[j].UID + }) + return out, nil +} + +// MigrateLegacy folds a pre-multi-account identity.json into the accounts +// store as an entry, then removes the legacy file. Idempotent and a no-op when +// the legacy file is absent. Returns true when a migration ran. +func MigrateLegacy(legacyPath, accountsDir string) (bool, error) { + id, err := Load(legacyPath) + if err != nil { + return false, err + } + if id == nil { + return false, nil + } + // Don't clobber an account already present for this uid. + if !ExistsByUID(accountsDir, id.UID) { + if err := SaveAccount(accountsDir, id); err != nil { + return false, err + } + } + if err := Delete(legacyPath); err != nil { + return false, fmt.Errorf("remove legacy identity after migration: %w", err) + } + return true, nil +} + // Sign produces an ECDSA-secp256k1 signature over SHA-256(bytes). The output // is DER-encoded — the wire format we commit to for User Data Layer records. func Sign(id *Identity, bytes []byte) ([]byte, error) { diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go index 4877612..5ab9047 100644 --- a/internal/identity/identity_test.go +++ b/internal/identity/identity_test.go @@ -190,6 +190,111 @@ func TestDeriveAEADKeyIsDeterministic(t *testing.T) { } } +func TestAccountStoreRoundtrip(t *testing.T) { + dir := filepath.Join(t.TempDir(), "accounts") + + // Empty store. + list, err := List(dir) + if err != nil { + t.Fatalf("List on missing dir: %v", err) + } + if len(list) != 0 { + t.Fatalf("expected empty store, got %d", len(list)) + } + + a, _ := Generate() + b, _ := Generate() + if err := SaveAccount(dir, a); err != nil { + t.Fatal(err) + } + if err := SaveAccount(dir, b); err != nil { + t.Fatal(err) + } + + if !ExistsByUID(dir, a.UID) || !ExistsByUID(dir, b.UID) { + t.Fatal("both accounts should exist") + } + + list, err = List(dir) + if err != nil { + t.Fatal(err) + } + if len(list) != 2 { + t.Fatalf("expected 2 accounts, got %d", len(list)) + } + + got, err := LoadByUID(dir, a.UID) + if err != nil || got == nil || got.PrivateKeyHex != a.PrivateKeyHex { + t.Fatalf("LoadByUID roundtrip failed: %+v err=%v", got, err) + } + + // Missing uid → (nil, nil). + if got, err := LoadByUID(dir, "zdoesnotexist"); err != nil || got != nil { + t.Fatalf("LoadByUID on missing should be (nil,nil): %+v %v", got, err) + } + + // Delete one; the other remains. Delete is idempotent. + if err := DeleteByUID(dir, a.UID); err != nil { + t.Fatal(err) + } + if ExistsByUID(dir, a.UID) { + t.Fatal("account should be gone after delete") + } + if err := DeleteByUID(dir, a.UID); err != nil { + t.Fatalf("delete should be idempotent: %v", err) + } + list, _ = List(dir) + if len(list) != 1 || list[0].UID != b.UID { + t.Fatalf("expected only b remaining, got %+v", list) + } +} + +func TestAccountFilePathRejectsTraversal(t *testing.T) { + for _, bad := range []string{"", "../escape", "a/b", "z..", strings.Repeat("z", 200)} { + if _, err := accountFilePath("/tmp/x", bad); err == nil { + t.Fatalf("expected rejection of uid %q", bad) + } + } + // A real uid is accepted. + id, _ := Generate() + if _, err := accountFilePath("/tmp/x", id.UID); err != nil { + t.Fatalf("real uid rejected: %v", err) + } +} + +func TestMigrateLegacy(t *testing.T) { + base := t.TempDir() + legacy := filepath.Join(base, "identity.json") + accounts := filepath.Join(base, "accounts") + + // No legacy file → no-op. + if migrated, err := MigrateLegacy(legacy, accounts); err != nil || migrated { + t.Fatalf("expected no-op migration, got migrated=%v err=%v", migrated, err) + } + + id, _ := Generate() + if err := Save(legacy, id); err != nil { + t.Fatal(err) + } + + migrated, err := MigrateLegacy(legacy, accounts) + if err != nil || !migrated { + t.Fatalf("expected migration to run: migrated=%v err=%v", migrated, err) + } + if Exists(legacy) { + t.Fatal("legacy file should be removed after migration") + } + got, err := LoadByUID(accounts, id.UID) + if err != nil || got == nil || got.UID != id.UID { + t.Fatalf("migrated account not found: %+v err=%v", got, err) + } + + // Second run is a clean no-op (legacy already gone). + if migrated, err := MigrateLegacy(legacy, accounts); err != nil || migrated { + t.Fatalf("second migration should no-op: migrated=%v err=%v", migrated, err) + } +} + func bytesEqual(a, b []byte) bool { if len(a) != len(b) { return false