v2.0.0: claims model - signed MMUR2 votes in a truth DB, merged view in a rebuildable cache DB; POST /api/claims, claims and merge routes, unsigned writes answer 410
Build and Push Docker Image / build (push) Successful in 5m1s

This commit is contained in:
Worph
2026-10-25 17:36:00 +00:00
parent b0adefb97b
commit 84af298792
78 changed files with 7203 additions and 5617 deletions
+240
View File
@@ -0,0 +1,240 @@
// 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
}
+207
View File
@@ -0,0 +1,207 @@
package claim
import (
"encoding/hex"
"encoding/json"
"errors"
"os"
"strings"
"testing"
"github.com/decred/dcrd/dcrec/secp256k1/v4"
"github.com/metazla/meta-core/internal/identity"
)
// The golden file every MMUR2 implementation is pinned to. Regenerate with
// CLAIM_VECTORS_UPDATE=1 go test ./internal/claim/ — then re-sync the vendored
// copies (scripts/check-claim-vectors.sh --fix). A change here is a wire break.
const vectorsPath = "testdata/claim-vectors.json"
// Fixed test key: the scalar 0x0101…01. Never used outside tests.
const testKeyHex = "0101010101010101010101010101010101010101010101010101010101010101"
type vector struct {
Name string `json:"name"`
CID string `json:"cid"`
UID string `json:"uid"`
Key string `json:"key"`
Value *string `json:"value"` // text value, or null
ValueB64 *string `json:"valueBytesHex,omitempty"` // bytes value as hex
TS uint64 `json:"ts"`
Signed string `json:"signedBytesHex"`
Sig string `json:"sigHex"` // deterministic RFC 6979, low-S
}
type vectorFile struct {
Comment string `json:"_comment"`
PrivateKeyHex string `json:"privateKeyHex"`
UID string `json:"uid"`
Vectors []vector `json:"vectors"`
HighS struct {
Note string `json:"note"`
Vector string `json:"vector"`
SigHex string `json:"sigHex"`
} `json:"highS"`
}
func (v vector) value(t *testing.T) Value {
t.Helper()
switch {
case v.ValueB64 != nil:
b, err := hex.DecodeString(*v.ValueB64)
if err != nil {
t.Fatal(err)
}
return Bytes(b)
case v.Value != nil:
return Text(*v.Value)
}
return Null()
}
func strp(s string) *string { return &s }
func testIdentity(t *testing.T) *identity.Identity {
t.Helper()
id, err := identity.Import(testKeyHex)
if err != nil {
t.Fatal(err)
}
return id
}
func cases(uid string) []vector {
return []vector{
{Name: "text", CID: "bafkreih5kapbjzq", UID: uid, Key: "title", Value: strp("The Matrix"), TS: 1793001234567},
{Name: "empty-text", CID: "bafkreih5kapbjzq", UID: uid, Key: "title", Value: strp(""), TS: 1},
{Name: "unicode", CID: "bafkreih5kapbjzq", UID: uid, Key: "titles/jpn/攻殻機動隊", Value: strp("true"), TS: 1793001234567},
{Name: "keyset-member", CID: "bagacbabaec7v3", UID: uid, Key: "cids/bafkreih5kapbjzq", Value: strp("true"), TS: 23},
{Name: "bytes", CID: "bafkreih5kapbjzq", UID: uid, Key: "seek", ValueB64: strp("00ff10ab"), TS: 24},
{Name: "retraction", CID: "bafkreih5kapbjzq", UID: uid, Key: "genres/Drama", TS: 255},
{Name: "long-value", CID: "bafkreih5kapbjzq", UID: uid, Key: "plot/eng", Value: strp(strings.Repeat("a", 300)), TS: 65536},
{Name: "ts-64bit", CID: "bafkreih5kapbjzq", UID: uid, Key: "year", Value: strp("1999"), TS: 1 << 40},
}
}
func TestVectors(t *testing.T) {
id := testIdentity(t)
if os.Getenv("CLAIM_VECTORS_UPDATE") == "1" {
writeVectors(t, id)
}
raw, err := os.ReadFile(vectorsPath)
if err != nil {
t.Fatalf("read vectors: %v (generate with CLAIM_VECTORS_UPDATE=1)", err)
}
var f vectorFile
if err := json.Unmarshal(raw, &f); err != nil {
t.Fatal(err)
}
if f.UID != id.UID {
t.Fatalf("vector uid %s != test key uid %s", f.UID, id.UID)
}
for _, v := range f.Vectors {
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS}
if got := hex.EncodeToString(c.SignedBytes()); got != v.Signed {
t.Errorf("%s: signed bytes\n got %s\nwant %s", v.Name, got, v.Signed)
}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
if got := hex.EncodeToString(c.Sig); got != v.Sig {
t.Errorf("%s: sig\n got %s\nwant %s", v.Name, got, v.Sig)
}
if err := c.Verify(); err != nil {
t.Errorf("%s: verify: %v", v.Name, err)
}
}
// The high-S twin of a valid signature must be refused.
for _, v := range f.Vectors {
if v.Name != f.HighS.Vector {
continue
}
sig, _ := hex.DecodeString(f.HighS.SigHex)
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS, Sig: sig}
if err := c.Verify(); !errors.Is(err, ErrBadSignature) {
t.Errorf("high-S signature accepted: %v", err)
}
}
}
func writeVectors(t *testing.T, id *identity.Identity) {
t.Helper()
f := vectorFile{
Comment: "MMUR2 golden vectors. Canonical copy: meta-core internal/claim/testdata. Do not edit by hand; see claim_test.go.",
PrivateKeyHex: testKeyHex,
UID: id.UID,
}
for _, v := range cases(id.UID) {
c := Claim{CID: v.CID, UID: v.UID, Key: v.Key, Value: v.value(t), TS: v.TS}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
v.Signed = hex.EncodeToString(c.SignedBytes())
v.Sig = hex.EncodeToString(c.Sig)
f.Vectors = append(f.Vectors, v)
}
first := f.Vectors[0]
sig, _ := hex.DecodeString(first.Sig)
f.HighS.Note = "same claim as `vector`, S replaced by n-S: valid ECDSA, but MMUR2 verifiers must refuse it"
f.HighS.Vector = first.Name
f.HighS.SigHex = hex.EncodeToString(highS(sig))
out, err := json.MarshalIndent(f, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll("testdata", 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(vectorsPath, append(out, '\n'), 0o644); err != nil {
t.Fatal(err)
}
}
func highS(sig []byte) []byte {
var s secp256k1.ModNScalar
s.SetByteSlice(sig[32:])
s.Negate()
out := append([]byte{}, sig[:32]...)
b := s.Bytes()
return append(out, b[:]...)
}
func TestJSONRoundTrip(t *testing.T) {
id := testIdentity(t)
for _, v := range []Value{Text("x"), Bytes([]byte{0, 1, 2}), Null()} {
c := Claim{CID: "bafy", UID: id.UID, Key: "k", Value: v, TS: 42}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
b, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
var back Claim
if err := json.Unmarshal(b, &back); err != nil {
t.Fatalf("%s: %v", b, err)
}
if !back.Value.Equal(v) || back.TS != 42 || string(back.Sig) != string(c.Sig) {
t.Errorf("round trip changed the claim: %s", b)
}
if err := back.Verify(); err != nil {
t.Errorf("%s: %v", b, err)
}
}
}
func TestTamperRejected(t *testing.T) {
id := testIdentity(t)
c := Claim{CID: "bafy", UID: id.UID, Key: "title", Value: Text("A"), TS: 1}
if err := c.Sign(id); err != nil {
t.Fatal(err)
}
c.Value = Text("B")
if err := c.Verify(); !errors.Is(err, ErrBadSignature) {
t.Fatalf("tampered value verified: %v", err)
}
}
+93
View File
@@ -0,0 +1,93 @@
{
"_comment": "MMUR2 golden vectors. Canonical copy: meta-core internal/claim/testdata. Do not edit by hand; see claim_test.go.",
"privateKeyHex": "0101010101010101010101010101010101010101010101010101010101010101",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"vectors": [
{
"name": "text",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "title",
"value": "The Matrix",
"ts": 1793001234567,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a657469746c656a546865204d61747269781b000001a17733a087",
"sigHex": "3ecac653f63cfc981564b5a5cc0db0459f2b4f19b810b6391d8f487216da0f3d2e560a2b61f4a81e320287cde753e1990d7949f4f518127f6505b8fb698ee8a8"
},
{
"name": "empty-text",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "title",
"value": "",
"ts": 1,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a657469746c656001",
"sigHex": "6eeb9ace8afe3987006c166eb0ec564b71e23d6a1c6211fdd55e8b4779c12dbb4f38c443faa3255c2ce7c6592e9d024e4ea57b5fa23446d1d4df647947e37642"
},
{
"name": "unicode",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "titles/jpn/攻殻機動隊",
"value": "true",
"ts": 1793001234567,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a781a7469746c65732f6a706e2fe694bbe6aebbe6a99fe58b95e99a8a64747275651b000001a17733a087",
"sigHex": "64ec6342c598782df278b49636a34da4f4c009423a584454a3284efad5426aa517d83873b09a4fdd3fd32a5e9a5fef0880126edd11db773045caeb6e634bc207"
},
{
"name": "keyset-member",
"cid": "bagacbabaec7v3",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "cids/bafkreih5kapbjzq",
"value": "true",
"ts": 23,
"signedBytesHex": "4d4d5552320a856e6261676163626162616563377633782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a75636964732f6261666b72656968356b6170626a7a71647472756517",
"sigHex": "8f707847895ccb522f1507dbab23330bd2c567d6fd1882df3bfae65bef6601024832f0b181146dcfb089aae91f5beac47da970373ab14370c4ac41410f3bf761"
},
{
"name": "bytes",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "seek",
"value": null,
"valueBytesHex": "00ff10ab",
"ts": 24,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a647365656b4400ff10ab1818",
"sigHex": "09671b27c03edd510029ec550b35e95e3900dd37a5d688ea04c3f0409979c7236bc4a1b5fa9d1b76265da85df24693a51f0ced528a6171c90a70b92db9f246e4"
},
{
"name": "retraction",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "genres/Drama",
"value": null,
"ts": 255,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a6c67656e7265732f4472616d61f618ff",
"sigHex": "d0c29d8edac4b78005eaf895cd9d534cb080b22ba9f7e79a5abf4d9bd906dc494464a2b388653452bca16e351132a9a92370953fa047e693459e55b7965449ac"
},
{
"name": "long-value",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "plot/eng",
"value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"ts": 65536,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a68706c6f742f656e6779012c6161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161611a00010000",
"sigHex": "383b2e40f913eb5240e1eae2cfa392f967321d3701623c2b3df344984686d888198bce1c78847d1898b35f991ec3465c382c45e03b9a77f2ae7d1fd740391dd2"
},
{
"name": "ts-64bit",
"cid": "bafkreih5kapbjzq",
"uid": "zvYNYVRtXSSDCi1rZtPP3ieuoh8cG5AscesGPYESa4VpJ",
"key": "year",
"value": "1999",
"ts": 1099511627776,
"signedBytesHex": "4d4d5552320a85706261666b72656968356b6170626a7a71782d7a76594e5956527458535344436931725a745050336965756f683863473541736365734750594553613456704a647965617264313939391b0000010000000000",
"sigHex": "2b9b42ef4ebd7fc8d3ca0a514679ca4104f2e627da3a84c7b9664b27ebbdf73946cc37a04e4ea15dbed23a0e48e8797c92fcba5598b012305a314bf776398e0d"
}
],
"highS": {
"note": "same claim as `vector`, S replaced by n-S: valid ECDSA, but MMUR2 verifiers must refuse it",
"vector": "text",
"sigHex": "3ecac653f63cfc981564b5a5cc0db0459f2b4f19b810b6391d8f487216da0f3dd1a9f5d49e0b57e1cdfd783218ac1e65ad3592f1ba308dbc5acca59166a75899"
}
}