// 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 }