multi identity support for metacore
This commit is contained in:
@@ -17,6 +17,22 @@ type identityStatusResponse struct {
|
|||||||
UID string `json:"uid,omitempty"`
|
UID string `json:"uid,omitempty"`
|
||||||
Curve string `json:"curve,omitempty"`
|
Curve string `json:"curve,omitempty"`
|
||||||
CreatedAt int64 `json:"createdAt,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 {
|
type identityGenerateResponse struct {
|
||||||
@@ -37,11 +53,15 @@ type identityImportResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type identityDeleteRequest struct {
|
type identityDeleteRequest struct {
|
||||||
Confirm bool `json:"confirm"`
|
Confirm bool `json:"confirm"`
|
||||||
|
UID string `json:"uid"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type identitySignRequest struct {
|
type identitySignRequest struct {
|
||||||
BytesB64 string `json:"bytesB64"`
|
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 {
|
type identitySignResponse struct {
|
||||||
@@ -52,36 +72,79 @@ type identityAEADKeyResponse struct {
|
|||||||
AEADKeyB64 string `json:"aeadKeyB64"`
|
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) {
|
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 {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if id == nil {
|
if len(list) == 0 {
|
||||||
writeJSON(w, http.StatusOK, identityStatusResponse{HasIdentity: false})
|
writeJSON(w, http.StatusOK, identityStatusResponse{HasIdentity: false})
|
||||||
return
|
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{
|
writeJSON(w, http.StatusOK, identityStatusResponse{
|
||||||
HasIdentity: true,
|
HasIdentity: true,
|
||||||
UID: id.UID,
|
UID: def.UID,
|
||||||
Curve: id.Curve,
|
Curve: def.Curve,
|
||||||
CreatedAt: id.CreatedAt,
|
CreatedAt: def.CreatedAt,
|
||||||
|
AccountCount: len(list),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleIdentityGenerate(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleIdentityAccounts(w http.ResponseWriter, r *http.Request) {
|
||||||
if identity.Exists(s.config.IdentityFilePath()) {
|
list, err := identity.List(s.config.IdentityAccountsDir())
|
||||||
writeErrorSlug(w, http.StatusConflict, "identity_exists",
|
if err != nil {
|
||||||
"identity already exists; delete it first to generate a new one", false)
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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()
|
id, err := identity.Generate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
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
|
var body identityImportRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
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())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -134,7 +198,32 @@ func (s *Server) handleIdentityDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
"identity deletion requires {\"confirm\": true} or ?confirm=true", false)
|
"identity deletion requires {\"confirm\": true} or ?confirm=true", false)
|
||||||
return
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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) {
|
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
|
var body identitySignRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
||||||
return
|
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)
|
raw, err := base64.StdEncoding.DecodeString(body.BytesB64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "bytesB64 is not valid base64")
|
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) {
|
func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := identity.Load(s.config.IdentityFilePath())
|
id, status, slug, msg := s.resolveAccount(r.URL.Query().Get("uid"))
|
||||||
if err != nil {
|
if status != 0 {
|
||||||
writeError(w, http.StatusInternalServerError, err.Error())
|
writeErrorSlug(w, status, slug, msg, false)
|
||||||
return
|
|
||||||
}
|
|
||||||
if id == nil {
|
|
||||||
writeErrorSlug(w, http.StatusNotFound, "no_identity",
|
|
||||||
"no identity is configured", false)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
key, err := identity.DeriveAEADKey(id)
|
key, err := identity.DeriveAEADKey(id)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/metazla/meta-core/internal/config"
|
"github.com/metazla/meta-core/internal/config"
|
||||||
"github.com/metazla/meta-core/internal/discovery"
|
"github.com/metazla/meta-core/internal/discovery"
|
||||||
"github.com/metazla/meta-core/internal/events"
|
"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/leader"
|
||||||
"github.com/metazla/meta-core/internal/mounts"
|
"github.com/metazla/meta-core/internal/mounts"
|
||||||
"github.com/metazla/meta-core/internal/schema"
|
"github.com/metazla/meta-core/internal/schema"
|
||||||
@@ -136,6 +137,14 @@ func NewServer(
|
|||||||
// Initialize WebDAV handler (caching is handled by nginx proxy_cache)
|
// Initialize WebDAV handler (caching is handled by nginx proxy_cache)
|
||||||
s.webdavHandler = webdav.NewHandler(cfg)
|
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()
|
s.setupRoutes()
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -158,6 +167,7 @@ func (s *Server) setupRoutes() {
|
|||||||
// User Data Layer signing identity (see internal/identity). Auth-gated
|
// User Data Layer signing identity (see internal/identity). Auth-gated
|
||||||
// at the perimeter — do NOT add to nginx-hash-lock unauth bypass.
|
// 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", 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/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", s.handleIdentityDelete).Methods("DELETE")
|
s.router.HandleFunc("/api/identity", s.handleIdentityDelete).Methods("DELETE")
|
||||||
|
|||||||
@@ -111,11 +111,20 @@ func (c *Config) IdentityDir() string {
|
|||||||
return c.MetaCorePath + "/identity"
|
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 {
|
func (c *Config) IdentityFilePath() string {
|
||||||
return c.IdentityDir() + "/identity.json"
|
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 {
|
func getEnv(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||||
@@ -158,6 +160,134 @@ func Exists(path string) bool {
|
|||||||
return err == nil
|
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
|
// 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.
|
// is DER-encoded — the wire format we commit to for User Data Layer records.
|
||||||
func Sign(id *Identity, bytes []byte) ([]byte, error) {
|
func Sign(id *Identity, bytes []byte) ([]byte, error) {
|
||||||
|
|||||||
@@ -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 {
|
func bytesEqual(a, b []byte) bool {
|
||||||
if len(a) != len(b) {
|
if len(a) != len(b) {
|
||||||
return false
|
return false
|
||||||
|
|||||||
Reference in New Issue
Block a user