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
Build and Push Docker Image / build (push) Successful in 5m1s
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user