remove in metadata cid_* to put in the cids/ set
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
// 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"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,203 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
||||
)
|
||||
|
||||
func TestGenerateUIDIsDeterministicFromPubkey(t *testing.T) {
|
||||
id, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(id.UID, "z") {
|
||||
t.Fatalf("uid missing multibase 'z' prefix: %q", id.UID)
|
||||
}
|
||||
if id.Curve != CurveSecp256k1 {
|
||||
t.Fatalf("curve = %q", id.Curve)
|
||||
}
|
||||
if len(id.PrivateKeyHex) != 64 {
|
||||
t.Fatalf("privkey hex len = %d", len(id.PrivateKeyHex))
|
||||
}
|
||||
if len(id.PublicKeyHex) != 66 {
|
||||
t.Fatalf("compressed pubkey hex len = %d", len(id.PublicKeyHex))
|
||||
}
|
||||
|
||||
// Reimporting yields the same uid.
|
||||
imported, err := Import(id.PrivateKeyHex)
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v", err)
|
||||
}
|
||||
if imported.UID != id.UID {
|
||||
t.Fatalf("uid changed after import roundtrip: %q vs %q", id.UID, imported.UID)
|
||||
}
|
||||
if imported.PublicKeyHex != id.PublicKeyHex {
|
||||
t.Fatalf("pubkey changed after import roundtrip")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsBadInput(t *testing.T) {
|
||||
if _, err := Import(""); err == nil {
|
||||
t.Fatal("empty hex should error")
|
||||
}
|
||||
if _, err := Import("zz"); err == nil {
|
||||
t.Fatal("non-hex should error")
|
||||
}
|
||||
if _, err := Import("00"); err == nil {
|
||||
t.Fatal("wrong-length hex should error")
|
||||
}
|
||||
if _, err := Import(strings.Repeat("00", 32)); err == nil {
|
||||
t.Fatal("zero scalar should error")
|
||||
}
|
||||
// Hex with 0x prefix should work.
|
||||
id1, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id2, err := Import("0x" + id1.PrivateKeyHex)
|
||||
if err != nil {
|
||||
t.Fatalf("0x-prefixed hex: %v", err)
|
||||
}
|
||||
if id1.UID != id2.UID {
|
||||
t.Fatal("uid mismatch through 0x prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadRoundtrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "identity.json")
|
||||
|
||||
if Exists(path) {
|
||||
t.Fatal("file should not exist yet")
|
||||
}
|
||||
if got, _ := Load(path); got != nil {
|
||||
t.Fatal("Load on missing file should return nil")
|
||||
}
|
||||
|
||||
id, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Save(path, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !Exists(path) {
|
||||
t.Fatal("file should exist after Save")
|
||||
}
|
||||
|
||||
loaded, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded == nil || loaded.UID != id.UID || loaded.PrivateKeyHex != id.PrivateKeyHex {
|
||||
t.Fatalf("roundtrip mismatch: %+v vs %+v", id, loaded)
|
||||
}
|
||||
|
||||
// File should be 0600 (private key on disk).
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Errorf("identity file mode = %o, want 0600", info.Mode().Perm())
|
||||
}
|
||||
|
||||
if err := Delete(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if Exists(path) {
|
||||
t.Fatal("file should not exist after Delete")
|
||||
}
|
||||
// Delete is idempotent.
|
||||
if err := Delete(path); err != nil {
|
||||
t.Fatalf("Delete on missing file should be nil: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignAndVerify(t *testing.T) {
|
||||
id, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := []byte("hello user data layer")
|
||||
sig, err := Sign(id, msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sig) == 0 {
|
||||
t.Fatal("empty sig")
|
||||
}
|
||||
|
||||
// Verify externally with the public key, matching what meta-watch will do.
|
||||
pubBytes, err := hex.DecodeString(id.PublicKeyHex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := secp256k1.ParsePubKey(pubBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedSig, err := ecdsa.ParseDERSignature(sig)
|
||||
if err != nil {
|
||||
t.Fatalf("parse DER sig: %v", err)
|
||||
}
|
||||
digest := sha256.Sum256(msg)
|
||||
if !parsedSig.Verify(digest[:], pub) {
|
||||
t.Fatal("signature did not verify")
|
||||
}
|
||||
|
||||
// Flipping a byte must break verification.
|
||||
tampered := append([]byte{}, msg...)
|
||||
tampered[0] ^= 0x01
|
||||
digest2 := sha256.Sum256(tampered)
|
||||
if parsedSig.Verify(digest2[:], pub) {
|
||||
t.Fatal("signature verified on tampered message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveAEADKeyIsDeterministic(t *testing.T) {
|
||||
id, err := Generate()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k1, err := DeriveAEADKey(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
k2, err := DeriveAEADKey(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytesEqual(k1, k2) {
|
||||
t.Fatal("AEAD key derivation is non-deterministic")
|
||||
}
|
||||
if len(k1) != aeadKeyLen {
|
||||
t.Fatalf("AEAD key len = %d, want %d", len(k1), aeadKeyLen)
|
||||
}
|
||||
|
||||
// Different identity → different key.
|
||||
other, _ := Generate()
|
||||
k3, _ := DeriveAEADKey(other)
|
||||
if bytesEqual(k1, k3) {
|
||||
t.Fatal("two distinct identities derived the same AEAD key")
|
||||
}
|
||||
}
|
||||
|
||||
func bytesEqual(a, b []byte) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user