366 lines
10 KiB
Go
366 lines
10 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
|
|
}
|
|
|
|
// SignCompact is Sign with the 64-byte compact r||s encoding MMUR2 claims
|
|
// use. decred's RFC 6979 signer always emits low-S, which MMUR2 requires.
|
|
func SignCompact(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[:])
|
|
r, s := sig.R(), sig.S()
|
|
out := make([]byte, 64)
|
|
rb, sb := r.Bytes(), s.Bytes()
|
|
copy(out[:32], rb[:])
|
|
copy(out[32:], sb[:])
|
|
return out, 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
|