Files
meta-core/internal/identity/identity.go
T

342 lines
9.8 KiB
Go

// Package identity manages the user-controlled signing identity used by the
// User Data Layer (see docs/project-architecture/user-data-layer.md).
//
// The keypair is secp256k1 (D1: wallet forward-compat). The uid is the
// multibase base58btc encoding of the 33-byte compressed pubkey, prefixed
// with "z" per multibase convention.
//
// Storage is plaintext JSON at {META_CORE_PATH}/identity/identity.json,
// matching the trust model of the rest of /meta-core. Signing happens
// in-process; meta-watch calls POST /api/identity/sign rather than holding
// the private key itself.
package identity
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
"github.com/mr-tron/base58"
"golang.org/x/crypto/hkdf"
)
const (
CurveSecp256k1 = "secp256k1"
// HKDF parameters for deriving the AEAD key from the private key.
// Stable across versions — changing these is a breaking change for
// encrypted records on disk.
aeadKDFSalt = "metamesh-uds-v1"
aeadKDFInfo = "aead-key"
aeadKeyLen = 32
)
// Identity is the persisted form of a user's keypair.
//
// PrivateKeyHex is the 32-byte secp256k1 scalar, lower-case hex, no prefix.
// PublicKeyHex is the 33-byte compressed pubkey, lower-case hex, no prefix.
type Identity struct {
UID string `json:"uid"`
Curve string `json:"curve"`
PublicKeyHex string `json:"publicKeyHex"`
PrivateKeyHex string `json:"privateKeyHex"`
CreatedAt int64 `json:"createdAt"`
}
// Generate creates a fresh secp256k1 identity.
func Generate() (*Identity, error) {
priv, err := secp256k1.GeneratePrivateKey()
if err != nil {
return nil, fmt.Errorf("generate keypair: %w", err)
}
return identityFromPrivKey(priv), nil
}
// Import parses an externally-provided 32-byte secp256k1 scalar and derives
// the matching pubkey. Accepts hex with or without a "0x" prefix.
func Import(privateKeyHex string) (*Identity, error) {
if len(privateKeyHex) >= 2 && (privateKeyHex[:2] == "0x" || privateKeyHex[:2] == "0X") {
privateKeyHex = privateKeyHex[2:]
}
raw, err := hex.DecodeString(privateKeyHex)
if err != nil {
return nil, fmt.Errorf("invalid hex: %w", err)
}
if len(raw) != 32 {
return nil, fmt.Errorf("private key must be 32 bytes, got %d", len(raw))
}
priv := secp256k1.PrivKeyFromBytes(raw)
// Reject the zero scalar (secp256k1 lib does not reject it on its own).
pubBytes := priv.PubKey().SerializeCompressed()
if isAllZero(raw) || isAllZero(pubBytes) {
return nil, errors.New("private key is the curve identity (zero); refusing")
}
return identityFromPrivKey(priv), nil
}
func identityFromPrivKey(priv *secp256k1.PrivateKey) *Identity {
pubBytes := priv.PubKey().SerializeCompressed()
return &Identity{
UID: uidFromCompressedPubkey(pubBytes),
Curve: CurveSecp256k1,
PublicKeyHex: hex.EncodeToString(pubBytes),
PrivateKeyHex: hex.EncodeToString(priv.Serialize()),
CreatedAt: time.Now().UTC().Unix(),
}
}
// uidFromCompressedPubkey encodes the pubkey as a multibase base58btc
// string ("z" prefix). The encoding is deterministic and matches MetaMesh
// CID conventions.
func uidFromCompressedPubkey(pub []byte) string {
return "z" + base58.Encode(pub)
}
// Load reads the identity file. Returns (nil, nil) if it does not exist —
// callers distinguish "no identity" from real errors.
func Load(path string) (*Identity, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, fmt.Errorf("read identity file: %w", err)
}
var id Identity
if err := json.Unmarshal(data, &id); err != nil {
return nil, fmt.Errorf("parse identity file: %w", err)
}
if id.Curve != CurveSecp256k1 {
return nil, fmt.Errorf("unsupported curve on disk: %q", id.Curve)
}
return &id, nil
}
// Save writes the identity atomically (tmp + rename), matching the pattern
// used by discovery/service.go.
func Save(path string, id *Identity) error {
if id == nil {
return errors.New("nil identity")
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return fmt.Errorf("mkdir identity dir: %w", err)
}
data, err := json.MarshalIndent(id, "", " ")
if err != nil {
return fmt.Errorf("marshal identity: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
return fmt.Errorf("write tmp identity: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("rename tmp identity: %w", err)
}
return nil
}
// Delete removes the identity file. Idempotent.
func Delete(path string) error {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
return nil
}
// Exists reports whether an identity file is present.
func Exists(path string) bool {
_, err := os.Stat(path)
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) {
if id == nil {
return nil, errors.New("no identity loaded")
}
raw, err := hex.DecodeString(id.PrivateKeyHex)
if err != nil {
return nil, fmt.Errorf("decode private key: %w", err)
}
if len(raw) != 32 {
return nil, fmt.Errorf("private key length %d", len(raw))
}
priv := secp256k1.PrivKeyFromBytes(raw)
digest := sha256.Sum256(bytes)
sig := ecdsa.Sign(priv, digest[:])
return sig.Serialize(), nil
}
// DeriveAEADKey derives the symmetric key used to encrypt private-tier
// User Data Layer records. The KDF parameters (salt, info, length) are
// fixed for the v1 wire format.
func DeriveAEADKey(id *Identity) ([]byte, error) {
if id == nil {
return nil, errors.New("no identity loaded")
}
raw, err := hex.DecodeString(id.PrivateKeyHex)
if err != nil {
return nil, fmt.Errorf("decode private key: %w", err)
}
r := hkdf.New(sha256.New, raw, []byte(aeadKDFSalt), []byte(aeadKDFInfo))
out := make([]byte, aeadKeyLen)
if _, err := io.ReadFull(r, out); err != nil {
return nil, fmt.Errorf("hkdf: %w", err)
}
return out, nil
}
func isAllZero(b []byte) bool {
for _, x := range b {
if x != 0 {
return false
}
}
return true
}
// Ensure rand.Reader is referenced so dependency-stripped builds don't
// accidentally remove the crypto/rand import that secp256k1.GeneratePrivateKey
// relies on transitively.
var _ = rand.Reader