278 lines
8.4 KiB
Go
278 lines
8.4 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/metazla/meta-core/internal/identity"
|
|
)
|
|
|
|
// identityStatusResponse is the shape of GET /api/identity. The private key
|
|
// is intentionally absent — it is only ever returned once, by
|
|
// POST /api/identity/generate, immediately after creation.
|
|
type identityStatusResponse struct {
|
|
HasIdentity bool `json:"hasIdentity"`
|
|
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 {
|
|
UID string `json:"uid"`
|
|
Curve string `json:"curve"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
PrivateKeyHex string `json:"privateKeyHex"`
|
|
}
|
|
|
|
type identityImportRequest struct {
|
|
PrivateKeyHex string `json:"privateKeyHex"`
|
|
}
|
|
|
|
type identityImportResponse struct {
|
|
UID string `json:"uid"`
|
|
Curve string `json:"curve"`
|
|
CreatedAt int64 `json:"createdAt"`
|
|
}
|
|
|
|
type identityDeleteRequest struct {
|
|
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 {
|
|
SigB64 string `json:"sigB64"`
|
|
}
|
|
|
|
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) {
|
|
list, err := identity.List(s.config.IdentityAccountsDir())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
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: def.UID,
|
|
Curve: def.Curve,
|
|
CreatedAt: def.CreatedAt,
|
|
AccountCount: len(list),
|
|
})
|
|
}
|
|
|
|
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.SaveAccount(s.config.IdentityAccountsDir(), id); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, identityGenerateResponse{
|
|
UID: id.UID,
|
|
Curve: id.Curve,
|
|
CreatedAt: id.CreatedAt,
|
|
PrivateKeyHex: id.PrivateKeyHex,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleIdentityImport(w http.ResponseWriter, r *http.Request) {
|
|
var body identityImportRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.PrivateKeyHex) == "" {
|
|
writeError(w, http.StatusBadRequest, "privateKeyHex is required")
|
|
return
|
|
}
|
|
id, err := identity.Import(strings.TrimSpace(body.PrivateKeyHex))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
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
|
|
}
|
|
writeJSON(w, http.StatusCreated, identityImportResponse{
|
|
UID: id.UID,
|
|
Curve: id.Curve,
|
|
CreatedAt: id.CreatedAt,
|
|
})
|
|
}
|
|
|
|
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.
|
|
_ = 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())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleIdentitySign(w http.ResponseWriter, r *http.Request) {
|
|
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")
|
|
return
|
|
}
|
|
sig, err := identity.Sign(id, raw)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, identitySignResponse{
|
|
SigB64: base64.StdEncoding.EncodeToString(sig),
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleIdentityAEADKey(w http.ResponseWriter, r *http.Request) {
|
|
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)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, identityAEADKeyResponse{
|
|
AEADKeyB64: base64.StdEncoding.EncodeToString(key),
|
|
})
|
|
}
|