161 lines
5.9 KiB
Go
161 lines
5.9 KiB
Go
package identity
|
|
|
|
// Signature verification against a uid, and the challenge vocabulary that
|
|
// makes a signature mean "I hold this account's private key" rather than
|
|
// "meta-core signed something for me".
|
|
//
|
|
// The asymmetry that motivates this file: meta-core *holds* every private key,
|
|
// so it can sign as anybody. A signature is therefore only proof of possession
|
|
// if the payload is one meta-core refuses to sign on request — hence
|
|
// ChallengeDomain, which POST /api/identity/sign rejects outright. Without that
|
|
// refusal an attacker mints their own authorisation in one extra call and the
|
|
// whole gate is decoration.
|
|
//
|
|
// Verification itself needs no stored state: a uid *is* the public key
|
|
// ("z" + base58btc(33-byte compressed sec1)), so the pubkey is recovered from
|
|
// the uid the caller names. Mirrors `verify_local` / `verify_local_compact` in
|
|
// meta-watch's src/record/sign.rs — same curve, same digest, both signature
|
|
// encodings.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
|
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
|
"github.com/mr-tron/base58"
|
|
)
|
|
|
|
// ChallengeDomain prefixes every byte string that authorises an account
|
|
// operation. POST /api/identity/sign and /sign-batch refuse payloads carrying
|
|
// it, so the only way to produce one is to hold the key yourself.
|
|
//
|
|
// Versioned because it is a wire commitment: changing it invalidates every
|
|
// challenge in flight, which is harmless, but changing it *silently* would let
|
|
// an old-domain payload keep working. Bump it deliberately.
|
|
const ChallengeDomain = "metamesh-identity-v1:"
|
|
|
|
// Action is the operation a challenge authorises. A challenge minted to reveal
|
|
// a key must not also delete the account, so the action is inside the signed
|
|
// bytes and is re-checked at redemption.
|
|
type Action string
|
|
|
|
const (
|
|
ActionReveal Action = "reveal"
|
|
ActionDelete Action = "delete"
|
|
)
|
|
|
|
// ValidAction reports whether a is an action a challenge may be issued for.
|
|
func ValidAction(a Action) bool {
|
|
return a == ActionReveal || a == ActionDelete
|
|
}
|
|
|
|
// ValidUID exposes the uid shape check to callers outside this package (the
|
|
// API layer, which must reject a hostile uid before it reaches a key-range read
|
|
// pattern or a file path).
|
|
func ValidUID(uid string) bool { return validUID(uid) }
|
|
|
|
// IsChallengePayload reports whether b is (or claims to be) an account
|
|
// authorisation payload. Used by the signing endpoints to refuse minting one.
|
|
//
|
|
// Deliberately a prefix test on raw bytes rather than a parse: anything that
|
|
// merely *looks* like a challenge is refused, so a malformed or future-version
|
|
// payload fails closed instead of being signed.
|
|
func IsChallengePayload(b []byte) bool {
|
|
return strings.HasPrefix(string(b), ChallengeDomain)
|
|
}
|
|
|
|
// PubkeyFromUID recovers the compressed public key a uid encodes.
|
|
func PubkeyFromUID(uid string) (*secp256k1.PublicKey, error) {
|
|
if !validUID(uid) {
|
|
return nil, fmt.Errorf("invalid uid: %q", uid)
|
|
}
|
|
if !strings.HasPrefix(uid, "z") {
|
|
return nil, errors.New("uid is not multibase base58btc (missing 'z' prefix)")
|
|
}
|
|
raw, err := base58.Decode(uid[1:])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("uid is not valid base58: %w", err)
|
|
}
|
|
if len(raw) != 33 {
|
|
return nil, fmt.Errorf("uid decodes to %d bytes, want a 33-byte compressed pubkey", len(raw))
|
|
}
|
|
pub, err := secp256k1.ParsePubKey(raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("uid is not a point on secp256k1: %w", err)
|
|
}
|
|
return pub, nil
|
|
}
|
|
|
|
// Verify checks sig over SHA-256(payload) against the public key inside uid.
|
|
//
|
|
// Accepts both encodings in circulation: DER (what Sign emits, and what User
|
|
// Data Layer records carry) and 64-byte compact r‖s (what the browser emits —
|
|
// noble-secp256k1 v3 dropped DER, and meta-watch's sign-in already sends
|
|
// compact). Rejecting either would break one of the two callers.
|
|
func Verify(uid string, payload, sig []byte) error {
|
|
pub, err := PubkeyFromUID(uid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
parsed, err := parseSignature(sig)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
digest := sha256.Sum256(payload)
|
|
if !parsed.Verify(digest[:], pub) {
|
|
return errors.New("signature does not verify for this uid")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseSignature(sig []byte) (*ecdsa.Signature, error) {
|
|
if len(sig) == 64 {
|
|
var r, s secp256k1.ModNScalar
|
|
// SetByteSlice reports overflow (>= curve order), which is not a valid
|
|
// scalar. A zero r or s is likewise never produced by a real signer.
|
|
if r.SetByteSlice(sig[:32]) || r.IsZero() {
|
|
return nil, errors.New("compact signature has an invalid r")
|
|
}
|
|
if s.SetByteSlice(sig[32:]) || s.IsZero() {
|
|
return nil, errors.New("compact signature has an invalid s")
|
|
}
|
|
return ecdsa.NewSignature(&r, &s), nil
|
|
}
|
|
parsed, err := ecdsa.ParseDERSignature(sig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("signature is neither 64-byte compact nor valid DER: %w", err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
// NewChallengeText mints the literal string a client signs. The uid and action
|
|
// are inside it, so a signature cannot be lifted from one account or operation
|
|
// onto another even if the nonce leaks.
|
|
func NewChallengeText(uid string, action Action) (string, error) {
|
|
nonce := make([]byte, 32)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", fmt.Errorf("read nonce: %w", err)
|
|
}
|
|
return ChallengeDomain + string(action) + ":" + uid + ":" + hex.EncodeToString(nonce), nil
|
|
}
|
|
|
|
// ChallengeMatches reports whether text is a well-formed challenge for exactly
|
|
// this uid and action. The store already keys on the full text, so this is a
|
|
// belt-and-braces re-read of the signed bytes themselves: it is the signed
|
|
// content, not the lookup key, that a verifier must agree with.
|
|
//
|
|
// Compared in constant time — the text is a bearer secret until redeemed.
|
|
func ChallengeMatches(text, uid string, action Action) bool {
|
|
want := ChallengeDomain + string(action) + ":" + uid + ":"
|
|
if len(text) <= len(want) {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(text[:len(want)]), []byte(want)) == 1
|
|
}
|