241 lines
6.6 KiB
Go
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
|
|
}
|