diff options
| author | Fuwn <[email protected]> | 2026-02-26 14:46:02 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-26 14:48:52 -0800 |
| commit | 0099d621e97b6048971fadb5c71918cc9f2b5a09 (patch) | |
| tree | a38ba31585200bacd61f453ef7158de7f0aaf7a3 /internal/verify | |
| parent | Initial commit (diff) | |
| download | plutia-test-0099d621e97b6048971fadb5c71918cc9f2b5a09.tar.xz plutia-test-0099d621e97b6048971fadb5c71918cc9f2b5a09.zip | |
feat: initial Plutia release — verifiable high-performance PLC mirror (mirror + resolver modes)
Diffstat (limited to 'internal/verify')
| -rw-r--r-- | internal/verify/verifier.go | 296 | ||||
| -rw-r--r-- | internal/verify/verifier_test.go | 183 |
2 files changed, 479 insertions, 0 deletions
diff --git a/internal/verify/verifier.go b/internal/verify/verifier.go new file mode 100644 index 0000000..ae648d3 --- /dev/null +++ b/internal/verify/verifier.go @@ -0,0 +1,296 @@ +package verify + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "strings" + + "github.com/Fuwn/plutia/internal/config" + "github.com/Fuwn/plutia/internal/types" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + secpECDSA "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "github.com/fxamacker/cbor/v2" + "github.com/mr-tron/base58" +) + +type Verifier struct { + Policy string +} + +func New(policy string) *Verifier { + return &Verifier{Policy: policy} +} + +func (v *Verifier) ShouldVerify(existing *types.StateV1, seq uint64) bool { + switch v.Policy { + case config.VerifyLazy: + return false + case config.VerifyStateOnly: + if existing == nil { + return true + } + return seq >= existing.LatestOpSeq + default: + return true + } +} + +func (v *Verifier) VerifyOperation(op types.ParsedOperation, existing *types.StateV1) error { + if !v.ShouldVerify(existing, op.Sequence) { + return nil + } + + if existing != nil && existing.ChainTipHash != "" { + if op.Prev == "" { + return errors.New("missing prev on non-genesis operation") + } + if op.Prev != existing.ChainTipHash { + return fmt.Errorf("prev linkage mismatch: got %s want %s", op.Prev, existing.ChainTipHash) + } + } + + sig, ok := findString(op.Payload, "sig", "signature") + if !ok || strings.TrimSpace(sig) == "" { + return errors.New("missing signature") + } + pubKeys := make([]string, 0, 8) + if existing != nil && len(existing.RotationKeys) > 0 { + pubKeys = append(pubKeys, existing.RotationKeys...) + } + pubKeys = append(pubKeys, extractPublicKeys(op.Payload)...) + if len(pubKeys) == 0 { + return errors.New("missing verification public key") + } + sigBytes, err := decodeFlexible(sig) + if err != nil { + return fmt.Errorf("decode signature: %w", err) + } + payload, err := signaturePayload(op.Payload) + if err != nil { + return err + } + for _, key := range pubKeys { + vk, err := decodePublicKey(key) + if err != nil { + continue + } + switch vk.Algo { + case "ed25519": + if ed25519.Verify(vk.Ed25519, payload, sigBytes) { + return nil + } + case "secp256k1": + if verifySecp256k1(vk.Secp256k1, payload, sigBytes) { + return nil + } + case "p256": + if verifyP256(vk.P256, payload, sigBytes) { + return nil + } + } + } + return errors.New("signature verification failed") +} + +func signaturePayload(m map[string]any) ([]byte, error) { + if raw, ok := findString(m, "sigPayload", "signaturePayload"); ok && raw != "" { + decoded, err := decodeFlexible(raw) + if err == nil && json.Valid(decoded) { + return types.CanonicalizeJSON(decoded) + } + } + + clone := make(map[string]any, len(m)) + for k, v := range m { + switch k { + case "sig", "signature", "sigPayload", "signaturePayload": + continue + default: + clone[k] = v + } + } + encMode, err := cbor.CanonicalEncOptions().EncMode() + if err != nil { + return nil, fmt.Errorf("init canonical cbor encoder: %w", err) + } + b, err := encMode.Marshal(clone) + if err != nil { + return nil, fmt.Errorf("marshal signature payload cbor: %w", err) + } + return b, nil +} + +func findString(m map[string]any, keys ...string) (string, bool) { + for _, k := range keys { + if v, ok := m[k].(string); ok { + return v, true + } + } + return "", false +} + +func extractPublicKeys(payload map[string]any) []string { + out := make([]string, 0, 6) + seen := map[string]struct{}{} + add := func(v string) { + v = strings.TrimSpace(v) + if v == "" { + return + } + if _, ok := seen[v]; ok { + return + } + seen[v] = struct{}{} + out = append(out, v) + } + if arr, ok := payload["rotationKeys"].([]any); ok { + for _, v := range arr { + if s, ok := v.(string); ok { + add(s) + } + } + } + if v, ok := findString(payload, "publicKey", "verificationMethod", "signingKey", "recoveryKey"); ok { + add(v) + } + if vm, ok := payload["verificationMethods"].(map[string]any); ok { + if v, ok := vm["atproto"].(string); ok { + add(v) + } + for _, anyV := range vm { + if s, ok := anyV.(string); ok { + add(s) + } + } + } + return out +} + +type verificationKey struct { + Algo string + Ed25519 ed25519.PublicKey + Secp256k1 *secp256k1.PublicKey + P256 *ecdsa.PublicKey +} + +func decodePublicKey(value string) (verificationKey, error) { + if strings.HasPrefix(value, "did:key:") { + mb := strings.TrimPrefix(value, "did:key:") + if mb == "" || mb[0] != 'z' { + return verificationKey{}, errors.New("did:key must be multibase base58btc") + } + decoded, err := base58.Decode(mb[1:]) + if err != nil { + return verificationKey{}, fmt.Errorf("decode did:key base58: %w", err) + } + if len(decoded) < 3 { + return verificationKey{}, errors.New("invalid did:key length") + } + code, n := binary.Uvarint(decoded) + if n <= 0 || n >= len(decoded) { + return verificationKey{}, errors.New("invalid did:key multicodec prefix") + } + keyBytes := decoded[n:] + switch code { + case 0xED: + if len(keyBytes) != ed25519.PublicKeySize { + return verificationKey{}, errors.New("invalid did:key ed25519 length") + } + return verificationKey{ + Algo: "ed25519", + Ed25519: ed25519.PublicKey(keyBytes), + }, nil + case 0xE7: + pub, err := secp256k1.ParsePubKey(keyBytes) + if err != nil { + return verificationKey{}, fmt.Errorf("parse secp256k1 did:key: %w", err) + } + return verificationKey{Algo: "secp256k1", Secp256k1: pub}, nil + case 0x1200: + x, y := elliptic.UnmarshalCompressed(elliptic.P256(), keyBytes) + if x == nil || y == nil { + return verificationKey{}, errors.New("parse p256 did:key: invalid compressed key") + } + return verificationKey{ + Algo: "p256", + P256: &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, + }, nil + default: + return verificationKey{}, errors.New("unsupported did:key multicodec") + } + } + b, err := decodeFlexible(value) + if err != nil { + return verificationKey{}, fmt.Errorf("decode public key: %w", err) + } + if len(b) == ed25519.PublicKeySize { + return verificationKey{Algo: "ed25519", Ed25519: ed25519.PublicKey(b)}, nil + } + pub, err := secp256k1.ParsePubKey(b) + if err == nil { + return verificationKey{Algo: "secp256k1", Secp256k1: pub}, nil + } + if x, y := elliptic.UnmarshalCompressed(elliptic.P256(), b); x != nil && y != nil { + return verificationKey{ + Algo: "p256", + P256: &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}, + }, nil + } + return verificationKey{}, fmt.Errorf("invalid public key length/type: %d", len(b)) +} + +func verifySecp256k1(pub *secp256k1.PublicKey, payload, sig []byte) bool { + if pub == nil { + return false + } + var parsed *secpECDSA.Signature + if len(sig) == 64 { + var r, s secp256k1.ModNScalar + r.SetByteSlice(sig[:32]) + s.SetByteSlice(sig[32:]) + parsed = secpECDSA.NewSignature(&r, &s) + } else { + der, err := secpECDSA.ParseDERSignature(sig) + if err != nil { + return false + } + parsed = der + } + sum := sha256.Sum256(payload) + return parsed.Verify(sum[:], pub) +} + +func verifyP256(pub *ecdsa.PublicKey, payload, sig []byte) bool { + if pub == nil { + return false + } + sum := sha256.Sum256(payload) + if len(sig) == 64 { + r := new(big.Int).SetBytes(sig[:32]) + s := new(big.Int).SetBytes(sig[32:]) + return ecdsa.Verify(pub, sum[:], r, s) + } + return ecdsa.VerifyASN1(pub, sum[:], sig) +} + +func decodeFlexible(v string) ([]byte, error) { + if b, err := base64.RawURLEncoding.DecodeString(v); err == nil { + return b, nil + } + if b, err := base64.StdEncoding.DecodeString(v); err == nil { + return b, nil + } + if b, err := hex.DecodeString(v); err == nil { + return b, nil + } + return nil, errors.New("unsupported encoding") +} diff --git a/internal/verify/verifier_test.go b/internal/verify/verifier_test.go new file mode 100644 index 0000000..0b38411 --- /dev/null +++ b/internal/verify/verifier_test.go @@ -0,0 +1,183 @@ +package verify + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "testing" + + "github.com/Fuwn/plutia/internal/config" + "github.com/Fuwn/plutia/internal/types" + "github.com/decred/dcrd/dcrec/secp256k1/v4" + secpECDSA "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa" + "github.com/fxamacker/cbor/v2" + "github.com/mr-tron/base58" +) + +func TestVerifyOperationValidSignature(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + payloadDoc := []byte(`{"did":"did:plc:alice","didDoc":{"id":"did:plc:alice"}}`) + sig := ed25519.Sign(priv, payloadDoc) + opJSON := map[string]any{ + "did": "did:plc:alice", + "didDoc": map[string]any{"id": "did:plc:alice"}, + "publicKey": base64.RawURLEncoding.EncodeToString(pub), + "sigPayload": base64.RawURLEncoding.EncodeToString(payloadDoc), + "sig": base64.RawURLEncoding.EncodeToString(sig), + } + raw, _ := json.Marshal(opJSON) + op, err := types.ParseOperation(types.ExportRecord{ + Seq: 1, + DID: "did:plc:alice", + Operation: raw, + }) + if err != nil { + t.Fatalf("parse operation: %v", err) + } + v := New(config.VerifyFull) + if err := v.VerifyOperation(op, nil); err != nil { + t.Fatalf("verify operation: %v", err) + } +} + +func TestVerifyOperationPrevMismatch(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + payloadDoc := []byte(`{"did":"did:plc:alice","didDoc":{"id":"did:plc:alice"},"prev":"sha256:wrong"}`) + sig := ed25519.Sign(priv, payloadDoc) + opJSON := map[string]any{ + "did": "did:plc:alice", + "didDoc": map[string]any{"id": "did:plc:alice"}, + "prev": "sha256:wrong", + "publicKey": base64.RawURLEncoding.EncodeToString(pub), + "sigPayload": base64.RawURLEncoding.EncodeToString(payloadDoc), + "sig": base64.RawURLEncoding.EncodeToString(sig), + } + raw, _ := json.Marshal(opJSON) + op, err := types.ParseOperation(types.ExportRecord{Seq: 2, DID: "did:plc:alice", Operation: raw}) + if err != nil { + t.Fatalf("parse operation: %v", err) + } + v := New(config.VerifyFull) + existing := &types.StateV1{DID: "did:plc:alice", ChainTipHash: "sha256:right", LatestOpSeq: 1} + if err := v.VerifyOperation(op, existing); err == nil { + t.Fatalf("expected prev mismatch error") + } +} + +func TestVerifyOperationSecp256k1(t *testing.T) { + priv, err := secp256k1.GeneratePrivateKey() + if err != nil { + t.Fatalf("generate secp256k1 key: %v", err) + } + pubKey := priv.PubKey() + didKeyBytes := append([]byte{0xE7, 0x01}, pubKey.SerializeCompressed()...) + didKey := "did:key:z" + base58.Encode(didKeyBytes) + + unsigned := map[string]any{ + "type": "create", + "prev": nil, + "handle": "alice.example.com", + "service": "https://example.com", + "signingKey": didKey, + } + enc, err := cbor.CanonicalEncOptions().EncMode() + if err != nil { + t.Fatalf("init cbor encoder: %v", err) + } + payload, err := enc.Marshal(unsigned) + if err != nil { + t.Fatalf("marshal cbor: %v", err) + } + sum := sha256.Sum256(payload) + sig := secpECDSA.Sign(priv, sum[:]).Serialize() + + opJSON := map[string]any{ + "type": "create", + "prev": nil, + "handle": "alice.example.com", + "service": "https://example.com", + "signingKey": didKey, + "sig": base64.RawURLEncoding.EncodeToString(sig), + } + raw, _ := json.Marshal(opJSON) + op, err := types.ParseOperation(types.ExportRecord{ + Seq: 1, + DID: "did:plc:alice", + Operation: raw, + }) + if err != nil { + t.Fatalf("parse operation: %v", err) + } + v := New(config.VerifyFull) + if err := v.VerifyOperation(op, nil); err != nil { + t.Fatalf("verify operation: %v", err) + } +} + +func TestVerifyOperationP256(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate p256 key: %v", err) + } + compressed := elliptic.MarshalCompressed(priv.Curve, priv.PublicKey.X, priv.PublicKey.Y) + didKeyBytes := append([]byte{0x80, 0x24}, compressed...) + didKey := "did:key:z" + base58.Encode(didKeyBytes) + + unsigned := map[string]any{ + "type": "create", + "prev": nil, + "handle": "alice.example.com", + "service": "https://example.com", + "signingKey": didKey, + } + enc, err := cbor.CanonicalEncOptions().EncMode() + if err != nil { + t.Fatalf("init cbor encoder: %v", err) + } + payload, err := enc.Marshal(unsigned) + if err != nil { + t.Fatalf("marshal cbor: %v", err) + } + sum := sha256.Sum256(payload) + r, s, err := ecdsa.Sign(rand.Reader, priv, sum[:]) + if err != nil { + t.Fatalf("sign p256: %v", err) + } + sig := make([]byte, 64) + rb := r.Bytes() + sb := s.Bytes() + copy(sig[32-len(rb):32], rb) + copy(sig[64-len(sb):], sb) + + opJSON := map[string]any{ + "type": "create", + "prev": nil, + "handle": "alice.example.com", + "service": "https://example.com", + "signingKey": didKey, + "sig": base64.RawURLEncoding.EncodeToString(sig), + } + raw, _ := json.Marshal(opJSON) + op, err := types.ParseOperation(types.ExportRecord{ + Seq: 1, + DID: "did:plc:alice", + Operation: raw, + }) + if err != nil { + t.Fatalf("parse operation: %v", err) + } + v := New(config.VerifyFull) + if err := v.VerifyOperation(op, nil); err != nil { + t.Fatalf("verify operation: %v", err) + } +} |