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

241 lines
6.6 KiB
Go

// Package claim is the MMUR2 signed claim: signer uid says key = value about
// content cid, at ts (milliseconds, also the version).
//
// Wire spec (load-bearing for every implementation — Go here, Rust in
// meta-feeder-sdk's `claims` feature and the clients' record/ mirror, TS in
// the plugins' meta-core-client.ts; all pinned by testdata/claim-vectors.json):
//
// domain = "MMUR2\n"
// payload = canonical CBOR array [cid, uid, key, value, ts]
// bytes_to_sign = domain || payload
// sig = ECDSA-secp256k1(SHA-256(bytes_to_sign)), compact r||s, low-S
//
// value is CBOR text (public), bytes (private envelope) or null (retraction).
// Canonical = definite lengths, smallest integer heads — the encoder below is
// hand-rolled because those five shapes are all MMUR2 ever needs.
package claim
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/metazla/meta-core/internal/identity"
)
// Domain separates MMUR2 bytes from every other signed payload (MMUR1 records,
// identity challenges). Changing it invalidates every claim everywhere.
const Domain = "MMUR2\n"
// Kind of a claim value. The SQLite storage type mirrors it: TEXT, BLOB, NULL.
type Kind uint8
const (
KindNull Kind = iota
KindText
KindBytes
)
// Value is a claim's value. The zero Value is a retraction (null).
type Value struct {
Kind Kind
Text string
Bytes []byte
}
func Text(s string) Value { return Value{Kind: KindText, Text: s} }
func Bytes(b []byte) Value { return Value{Kind: KindBytes, Bytes: b} }
func Null() Value { return Value{} }
func (v Value) IsNull() bool { return v.Kind == KindNull }
// Equal reports byte-for-byte equality, kind included.
func (v Value) Equal(o Value) bool {
if v.Kind != o.Kind {
return false
}
switch v.Kind {
case KindText:
return v.Text == o.Text
case KindBytes:
return string(v.Bytes) == string(o.Bytes)
}
return true
}
// Claim is one signed vote.
type Claim struct {
CID string
UID string
Key string
Value Value
TS uint64
Sig []byte // 64-byte compact r||s
}
// SignedBytes returns the exact bytes the signature covers.
func SignedBytes(cid, uid, key string, v Value, ts uint64) []byte {
b := make([]byte, 0, len(Domain)+len(cid)+len(uid)+len(key)+len(v.Text)+len(v.Bytes)+32)
b = append(b, Domain...)
b = appendHead(b, 4, 5) // array(5)
b = appendText(b, cid)
b = appendText(b, uid)
b = appendText(b, key)
switch v.Kind {
case KindText:
b = appendText(b, v.Text)
case KindBytes:
b = appendHead(b, 2, uint64(len(v.Bytes)))
b = append(b, v.Bytes...)
default:
b = append(b, 0xf6) // null
}
return appendHead(b, 0, ts)
}
// SignedBytes of c.
func (c *Claim) SignedBytes() []byte {
return SignedBytes(c.CID, c.UID, c.Key, c.Value, c.TS)
}
func appendText(b []byte, s string) []byte {
b = appendHead(b, 3, uint64(len(s)))
return append(b, s...)
}
// appendHead writes a CBOR major type + argument in its smallest form.
func appendHead(b []byte, major byte, n uint64) []byte {
m := major << 5
switch {
case n < 24:
return append(b, m|byte(n))
case n <= 0xff:
return append(b, m|24, byte(n))
case n <= 0xffff:
return append(b, m|25, byte(n>>8), byte(n))
case n <= 0xffffffff:
return append(b, m|26, byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
default:
return append(b, m|27, byte(n>>56), byte(n>>48), byte(n>>40), byte(n>>32),
byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
}
}
// ErrBadSignature is returned for a signature that is malformed, high-S, or
// does not verify against the uid.
var ErrBadSignature = errors.New("bad signature")
// IsLowS reports whether a compact signature's S is in the lower half.
func IsLowS(sig []byte) bool {
if len(sig) != 64 {
return false
}
var s secp256k1.ModNScalar
if overflow := s.SetByteSlice(sig[32:]); overflow {
return false
}
return !s.IsOverHalfOrder()
}
// Verify checks the claim's shape and signature.
func (c *Claim) Verify() error {
if c.CID == "" || c.Key == "" || !identity.ValidUID(c.UID) {
return fmt.Errorf("%w: missing cid, key or valid uid", ErrBadSignature)
}
if len(c.Sig) != 64 || !IsLowS(c.Sig) {
return fmt.Errorf("%w: want a 64-byte low-S compact signature", ErrBadSignature)
}
if err := identity.Verify(c.UID, c.SignedBytes(), c.Sig); err != nil {
return fmt.Errorf("%w: %v", ErrBadSignature, err)
}
return nil
}
// Sign fills c.Sig with id's compact low-S signature. Used by the account-key
// service (users who sign through meta-core until its separate ticket removes
// it), tests and the one-off migration tool — never by the claims engine.
func (c *Claim) Sign(id *identity.Identity) error {
if id.UID != c.UID {
return fmt.Errorf("claim uid %s is not the signing identity %s", c.UID, id.UID)
}
sig, err := identity.SignCompact(id, c.SignedBytes())
if err != nil {
return err
}
c.Sig = sig
return nil
}
// jsonClaim is the §3.4 HTTP form.
type jsonClaim struct {
CID string `json:"cid"`
UID string `json:"uid"`
Key string `json:"key"`
Value json.RawMessage `json:"value"`
TS uint64 `json:"ts"`
Sig string `json:"sig"`
}
type jsonBytes struct {
B64 string `json:"b64"`
}
// MarshalJSON emits {"cid","uid","key","value","ts","sig"}; bytes values as
// {"b64": "..."}, retractions as null.
func (c Claim) MarshalJSON() ([]byte, error) {
var v json.RawMessage
var err error
switch c.Value.Kind {
case KindText:
v, err = json.Marshal(c.Value.Text)
case KindBytes:
v, err = json.Marshal(jsonBytes{B64: base64.StdEncoding.EncodeToString(c.Value.Bytes)})
default:
v = json.RawMessage("null")
}
if err != nil {
return nil, err
}
return json.Marshal(jsonClaim{
CID: c.CID, UID: c.UID, Key: c.Key, Value: v, TS: c.TS,
Sig: base64.StdEncoding.EncodeToString(c.Sig),
})
}
// UnmarshalJSON parses the §3.4 form.
func (c *Claim) UnmarshalJSON(data []byte) error {
var j jsonClaim
if err := json.Unmarshal(data, &j); err != nil {
return err
}
sig, err := base64.StdEncoding.DecodeString(j.Sig)
if err != nil {
return fmt.Errorf("sig: %w", err)
}
*c = Claim{CID: j.CID, UID: j.UID, Key: j.Key, TS: j.TS, Sig: sig}
raw := j.Value
if len(raw) == 0 || string(raw) == "null" {
return nil
}
if raw[0] == '"' {
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return err
}
c.Value = Text(s)
return nil
}
var b jsonBytes
if err := json.Unmarshal(raw, &b); err != nil || b.B64 == "" && string(raw) != `{"b64":""}` {
return fmt.Errorf("value must be a string, {\"b64\": ...} or null")
}
bs, err := base64.StdEncoding.DecodeString(b.B64)
if err != nil {
return fmt.Errorf("value.b64: %w", err)
}
c.Value = Bytes(bs)
return nil
}