Implement commit signature validation
This commit is contained in:
parent
f9dde4db39
commit
ff0ea08296
9 changed files with 409 additions and 214 deletions
45
repo/mst.go
45
repo/mst.go
|
@ -9,6 +9,8 @@ import (
|
|||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-car"
|
||||
"github.com/ipld/go-ipld-prime/codec/dagcbor"
|
||||
|
@ -17,7 +19,11 @@ import (
|
|||
"github.com/ipld/go-ipld-prime/node/basicnode"
|
||||
)
|
||||
|
||||
func ExtractRecords(ctx context.Context, b io.Reader) (map[string]json.RawMessage, error) {
|
||||
var ErrInvalidSignature = fmt.Errorf("commit signature is not valid")
|
||||
|
||||
func ExtractRecords(ctx context.Context, b io.Reader, signingKey string) (map[string]json.RawMessage, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
r, err := car.NewCarReader(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to construct CAR reader: %w", err)
|
||||
|
@ -38,20 +44,39 @@ func ExtractRecords(ctx context.Context, b io.Reader) (map[string]json.RawMessag
|
|||
}
|
||||
if c.Equals(block.Cid()) {
|
||||
blocks[block.Cid()] = block.RawData()
|
||||
} else {
|
||||
log.Debug().Str("cid", block.Cid().String()).
|
||||
Msgf("CID doesn't match block content: %s != %s", block.Cid().String(), c.String())
|
||||
}
|
||||
}
|
||||
|
||||
records := map[string]cid.Cid{}
|
||||
for _, root := range r.Header.Roots {
|
||||
// TODO: verify that a root is a commit record and validate signature
|
||||
if len(r.Header.Roots) == 0 {
|
||||
return nil, fmt.Errorf("CAR has zero roots specified")
|
||||
}
|
||||
|
||||
cids, err := findRecords(blocks, root, nil, nil, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range cids {
|
||||
records[k] = v
|
||||
}
|
||||
// https://atproto.com/specs/repository specifies that the first root
|
||||
// must be a commit object. Meaning of subsequent roots is not yet defined.
|
||||
root := r.Header.Roots[0]
|
||||
|
||||
// TODO: verify that a root is a commit record and validate signature
|
||||
if _, found := blocks[root]; !found {
|
||||
return nil, fmt.Errorf("root block is missing")
|
||||
}
|
||||
valid, err := verifyCommitSignature(ctx, blocks[root], signingKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commit signature verification failed: %w", err)
|
||||
}
|
||||
if !valid {
|
||||
return nil, ErrInvalidSignature
|
||||
}
|
||||
|
||||
cids, err := findRecords(blocks, root, nil, nil, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range cids {
|
||||
records[k] = v
|
||||
}
|
||||
|
||||
res := map[string]json.RawMessage{}
|
||||
|
|
|
@ -28,6 +28,7 @@ type Repo struct {
|
|||
LastIndexAttempt time.Time
|
||||
LastError string
|
||||
FailedAttempts int `gorm:"default:0"`
|
||||
LastKnownKey string
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
|
@ -66,7 +67,7 @@ func EnsureExists(ctx context.Context, db *gorm.DB, did string) (*Repo, bool, er
|
|||
// if we do - compare PDS IDs
|
||||
// if they don't match - also reset FirstRevSinceReset
|
||||
|
||||
u, err := resolver.GetPDSEndpoint(ctx, did)
|
||||
u, pubKey, err := resolver.GetPDSEndpointAndPublicKey(ctx, did)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("fetching DID Document: %w", err)
|
||||
}
|
||||
|
@ -75,7 +76,11 @@ func EnsureExists(ctx context.Context, db *gorm.DB, did string) (*Repo, bool, er
|
|||
if err != nil {
|
||||
return nil, false, fmt.Errorf("failed to get PDS record from DB for %q: %w", u.String(), err)
|
||||
}
|
||||
r = Repo{DID: did, PDS: models.ID(remote.ID)}
|
||||
r = Repo{
|
||||
DID: did,
|
||||
PDS: models.ID(remote.ID),
|
||||
LastKnownKey: pubKey,
|
||||
}
|
||||
created := false
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
result := tx.Model(&r).Where(&Repo{DID: r.DID}).FirstOrCreate(&r)
|
||||
|
|
223
repo/signature.go
Normal file
223
repo/signature.go
Normal file
|
@ -0,0 +1,223 @@
|
|||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"gitlab.com/yawning/secp256k1-voi/secec"
|
||||
|
||||
"github.com/ipfs/go-cid"
|
||||
"github.com/ipld/go-ipld-prime/codec/dagcbor"
|
||||
"github.com/ipld/go-ipld-prime/datamodel"
|
||||
"github.com/ipld/go-ipld-prime/node/basicnode"
|
||||
"github.com/multiformats/go-multibase"
|
||||
"github.com/multiformats/go-multicodec"
|
||||
)
|
||||
|
||||
type SignatureValidator func(digest []byte, sig []byte) (bool, error)
|
||||
|
||||
func parseSigningKey(ctx context.Context, key string) (SignatureValidator, error) {
|
||||
log := zerolog.Ctx(ctx)
|
||||
|
||||
// const didKey = "did:key:"
|
||||
|
||||
// if !strings.HasPrefix(key, didKey) {
|
||||
// return nil, fmt.Errorf("expected the key %q to have prefix %q", key, didKey)
|
||||
// }
|
||||
|
||||
// key = strings.TrimPrefix(key, didKey)
|
||||
enc, val, err := multibase.Decode(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode key data: %w", err)
|
||||
}
|
||||
|
||||
if enc != multibase.Base58BTC {
|
||||
log.Info().Msgf("unexpected key encoding: %v", enc)
|
||||
}
|
||||
|
||||
buf := bytes.NewBuffer(val)
|
||||
kind, err := binary.ReadUvarint(buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key type: %w", err)
|
||||
}
|
||||
data, _ := io.ReadAll(buf)
|
||||
|
||||
switch multicodec.Code(kind) {
|
||||
case multicodec.P256Pub:
|
||||
x, y := elliptic.UnmarshalCompressed(elliptic.P256(), data)
|
||||
return func(digest, sig []byte) (bool, error) {
|
||||
pk := &ecdsa.PublicKey{
|
||||
Curve: elliptic.P256(),
|
||||
X: x,
|
||||
Y: y,
|
||||
}
|
||||
|
||||
if len(sig) != 64 {
|
||||
return false, fmt.Errorf("unexpected signature length: %d != 64", len(sig))
|
||||
}
|
||||
r := big.NewInt(0).SetBytes(sig[:32])
|
||||
s := big.NewInt(0).SetBytes(sig[32:])
|
||||
return ecdsa.Verify(pk, digest, r, s), nil
|
||||
}, nil
|
||||
case multicodec.Secp256k1Pub:
|
||||
pk, err := secec.NewPublicKey(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse secp256k public key: %w", err)
|
||||
}
|
||||
return func(digest, sig []byte) (bool, error) {
|
||||
return pk.Verify(digest, sig, &secec.ECDSAOptions{
|
||||
Hash: crypto.SHA256,
|
||||
Encoding: secec.EncodingCompact,
|
||||
RejectMalleable: true,
|
||||
}), nil
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported key type %q", multicodec.Code(kind))
|
||||
}
|
||||
}
|
||||
|
||||
func verifyCommitSignature(ctx context.Context, data []byte, key string) (bool, error) {
|
||||
validateSignature, err := parseSigningKey(ctx, key)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse the key: %w", err)
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
DID string
|
||||
Version int
|
||||
Data cid.Cid
|
||||
Rev string
|
||||
Prev *cid.Cid
|
||||
Sig []byte
|
||||
}
|
||||
|
||||
builder := basicnode.Prototype.Any.NewBuilder()
|
||||
if err := (&dagcbor.DecodeOptions{AllowLinks: true}).Decode(builder, bytes.NewReader(data)); err != nil {
|
||||
return false, fmt.Errorf("unmarshaling commit: %w", err)
|
||||
}
|
||||
node := builder.Build()
|
||||
|
||||
if node.Kind() != datamodel.Kind_Map {
|
||||
return false, fmt.Errorf("commit must be a Map, got %s instead", node.Kind())
|
||||
}
|
||||
|
||||
m, err := parseMap(node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
commit := Commit{}
|
||||
|
||||
if n, found := m["version"]; !found {
|
||||
return false, fmt.Errorf("missing \"version\"")
|
||||
} else {
|
||||
v, err := n.AsInt()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse \"version\": %w", err)
|
||||
}
|
||||
commit.Version = int(v)
|
||||
}
|
||||
|
||||
if n, found := m["did"]; !found {
|
||||
return false, fmt.Errorf("missing \"did\"")
|
||||
} else {
|
||||
v, err := n.AsString()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse \"did\": %w", err)
|
||||
}
|
||||
commit.DID = v
|
||||
}
|
||||
|
||||
if n, found := m["data"]; !found {
|
||||
return false, fmt.Errorf("missing \"data\"")
|
||||
} else {
|
||||
v, err := n.AsLink()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse \"data\": %w", err)
|
||||
}
|
||||
c, err := cid.Parse([]byte(v.Binary()))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to convert \"data\" to CID: %w", err)
|
||||
}
|
||||
commit.Data = c
|
||||
}
|
||||
|
||||
if n, found := m["rev"]; !found {
|
||||
return false, fmt.Errorf("missing \"rev\"")
|
||||
} else {
|
||||
v, err := n.AsString()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse \"rev\": %w", err)
|
||||
}
|
||||
commit.Rev = v
|
||||
}
|
||||
|
||||
if n, found := m["prev"]; !found {
|
||||
return false, fmt.Errorf("missing \"prev\"")
|
||||
} else {
|
||||
if !n.IsNull() {
|
||||
v, err := n.AsLink()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse \"prev\": %w", err)
|
||||
}
|
||||
c, err := cid.Parse([]byte(v.Binary()))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to convert \"prev\" to CID: %w", err)
|
||||
}
|
||||
commit.Prev = &c
|
||||
}
|
||||
}
|
||||
|
||||
if n, found := m["sig"]; !found {
|
||||
return false, fmt.Errorf("missing \"sig\"")
|
||||
} else {
|
||||
v, err := n.AsBytes()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse \"sig\": %w", err)
|
||||
}
|
||||
commit.Sig = v
|
||||
}
|
||||
|
||||
if commit.Version != 3 {
|
||||
return false, fmt.Errorf("unknown commit version %d", commit.Version)
|
||||
}
|
||||
|
||||
unsignedBuilder := basicnode.Prototype.Map.NewBuilder()
|
||||
mb, err := unsignedBuilder.BeginMap(int64(len(m) - 1))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("initializing a map for unsigned commit: %w", err)
|
||||
}
|
||||
// XXX: signature validation depends on this specific order of keys in the map.
|
||||
for _, k := range []string{"did", "rev", "data", "prev", "version"} {
|
||||
if k == "sig" {
|
||||
continue
|
||||
}
|
||||
if err := mb.AssembleKey().AssignString(k); err != nil {
|
||||
return false, fmt.Errorf("failed to assemble key %q: %w", k, err)
|
||||
}
|
||||
if err := mb.AssembleValue().AssignNode(m[k]); err != nil {
|
||||
return false, fmt.Errorf("failed to assemble value for key %q: %w", k, err)
|
||||
}
|
||||
}
|
||||
if err := mb.Finish(); err != nil {
|
||||
return false, fmt.Errorf("failed to finalize the map: %w", err)
|
||||
}
|
||||
unsignedNode := unsignedBuilder.Build()
|
||||
|
||||
buf := bytes.NewBuffer(nil)
|
||||
if err := (&dagcbor.EncodeOptions{AllowLinks: true}).Encode(unsignedNode, buf); err != nil {
|
||||
return false, fmt.Errorf("failed to serialize unsigned commit: %w", err)
|
||||
}
|
||||
unsignedBytes := buf.Bytes()
|
||||
unsignedHash := sha256.Sum256(unsignedBytes)
|
||||
return validateSignature(unsignedHash[:], commit.Sig)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue