279 lines
8.7 KiB
Go
279 lines
8.7 KiB
Go
package identity
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
|
"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
|
)
|
|
|
|
// The whole gate rests on a uid being the public key: verification consults no
|
|
// stored state, so a signature is checked against the account the caller names
|
|
// rather than against whatever key meta-core happens to hold.
|
|
func TestVerify_AcceptsDERFromOurOwnSigner(t *testing.T) {
|
|
id, err := Generate()
|
|
if err != nil {
|
|
t.Fatalf("generate: %v", err)
|
|
}
|
|
payload := []byte(ChallengeDomain + "delete:" + id.UID + ":abcdef")
|
|
|
|
sig, err := Sign(id, payload)
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
if err := Verify(id.UID, payload, sig); err != nil {
|
|
t.Fatalf("DER signature from Sign must verify: %v", err)
|
|
}
|
|
}
|
|
|
|
// The browser is the signer that matters here, and noble-secp256k1 v3 emits
|
|
// 64-byte compact r‖s — it dropped DER entirely. A verifier that took only DER
|
|
// would reject every real proof while passing this package's own tests.
|
|
func TestVerify_AcceptsCompactAsTheBrowserEmitsIt(t *testing.T) {
|
|
id, err := Generate()
|
|
if err != nil {
|
|
t.Fatalf("generate: %v", err)
|
|
}
|
|
payload := []byte("some challenge text")
|
|
|
|
raw, _ := hex.DecodeString(id.PrivateKeyHex)
|
|
priv := secp256k1.PrivKeyFromBytes(raw)
|
|
digest := sha256.Sum256(payload)
|
|
der := ecdsa.Sign(priv, digest[:])
|
|
|
|
// Re-encode the same signature compactly, the way the browser would.
|
|
parsed, err := ecdsa.ParseDERSignature(der.Serialize())
|
|
if err != nil {
|
|
t.Fatalf("parse own DER: %v", err)
|
|
}
|
|
r := parsed.R()
|
|
sc := parsed.S()
|
|
compact := make([]byte, 0, 64)
|
|
rb := r.Bytes()
|
|
sb := sc.Bytes()
|
|
compact = append(compact, rb[:]...)
|
|
compact = append(compact, sb[:]...)
|
|
|
|
if err := Verify(id.UID, payload, compact); err != nil {
|
|
t.Fatalf("compact signature must verify: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerify_RejectsAnotherAccountsSignature(t *testing.T) {
|
|
alice, _ := Generate()
|
|
mallory, _ := Generate()
|
|
payload := []byte("challenge for alice")
|
|
|
|
sig, err := Sign(mallory, payload)
|
|
if err != nil {
|
|
t.Fatalf("sign: %v", err)
|
|
}
|
|
if err := Verify(alice.UID, payload, sig); err == nil {
|
|
t.Fatal("a signature by mallory must not verify as alice")
|
|
}
|
|
}
|
|
|
|
func TestVerify_RejectsTamperedPayload(t *testing.T) {
|
|
id, _ := Generate()
|
|
sig, _ := Sign(id, []byte("delete account A"))
|
|
if err := Verify(id.UID, []byte("delete account B"), sig); err == nil {
|
|
t.Fatal("a signature must not verify over different bytes")
|
|
}
|
|
}
|
|
|
|
func TestVerify_RejectsGarbageSignatureEncodings(t *testing.T) {
|
|
id, _ := Generate()
|
|
for name, sig := range map[string][]byte{
|
|
"empty": {},
|
|
"short": {1, 2, 3},
|
|
"zero compact": make([]byte, 64),
|
|
"not der": []byte("this is not a signature at all......."),
|
|
"63 bytes": make([]byte, 63),
|
|
"65 bytes non-der": make([]byte, 65),
|
|
} {
|
|
if err := Verify(id.UID, []byte("x"), sig); err == nil {
|
|
t.Errorf("%s: expected rejection", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPubkeyFromUID_RejectsMalformedUIDs(t *testing.T) {
|
|
id, _ := Generate()
|
|
for name, uid := range map[string]string{
|
|
"empty": "",
|
|
"no z prefix": id.UID[1:],
|
|
"truncated": id.UID[:20],
|
|
"path traversal": "../../etc/passwd",
|
|
"with slash": "z/" + id.UID,
|
|
"not on curve": "z" + strings.Repeat("1", 45),
|
|
} {
|
|
if _, err := PubkeyFromUID(uid); err == nil {
|
|
t.Errorf("%s (%q): expected rejection", name, uid)
|
|
}
|
|
}
|
|
if _, err := PubkeyFromUID(id.UID); err != nil {
|
|
t.Fatalf("a real uid must parse: %v", err)
|
|
}
|
|
}
|
|
|
|
// The refusal that makes proof-of-possession mean anything on a node holding
|
|
// every private key. If this ever returns false for a challenge, an attacker
|
|
// asks /api/identity/sign to authorise their own delete.
|
|
func TestIsChallengePayload_CatchesAnythingInTheDomain(t *testing.T) {
|
|
yes := [][]byte{
|
|
[]byte(ChallengeDomain),
|
|
[]byte(ChallengeDomain + "delete:zAlice:beef"),
|
|
[]byte(ChallengeDomain + "some-future-action:whatever"),
|
|
[]byte("metamesh-identity-v1:"), // literal, in case the const drifts
|
|
}
|
|
for _, b := range yes {
|
|
if !IsChallengePayload(b) {
|
|
t.Errorf("must refuse to sign %q", b)
|
|
}
|
|
}
|
|
no := [][]byte{
|
|
{},
|
|
[]byte("metamesh-identity-v2:delete:zAlice"), // different domain, not ours to refuse
|
|
[]byte("a record payload"),
|
|
[]byte(" " + ChallengeDomain), // not a prefix
|
|
}
|
|
for _, b := range no {
|
|
if IsChallengePayload(b) {
|
|
t.Errorf("must still sign %q", b)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChallengeMatches_TiesTheTextToUIDAndAction(t *testing.T) {
|
|
text, err := NewChallengeText("zAlice", ActionDelete)
|
|
if err != nil {
|
|
t.Fatalf("mint: %v", err)
|
|
}
|
|
if !ChallengeMatches(text, "zAlice", ActionDelete) {
|
|
t.Fatal("its own uid+action must match")
|
|
}
|
|
if ChallengeMatches(text, "zBob", ActionDelete) {
|
|
t.Fatal("another uid must not match")
|
|
}
|
|
if ChallengeMatches(text, "zAlice", ActionReveal) {
|
|
t.Fatal("another action must not match")
|
|
}
|
|
// A prefix-only uid must not slip through: "zAlic" is a prefix of "zAlice",
|
|
// and a naive HasPrefix on the uid alone would accept it.
|
|
if ChallengeMatches(text, "zAlic", ActionDelete) {
|
|
t.Fatal("a uid that is a prefix of the real one must not match")
|
|
}
|
|
}
|
|
|
|
func TestNewChallengeText_IsUnpredictable(t *testing.T) {
|
|
seen := make(map[string]struct{}, 64)
|
|
for i := 0; i < 64; i++ {
|
|
text, err := NewChallengeText("zAlice", ActionDelete)
|
|
if err != nil {
|
|
t.Fatalf("mint: %v", err)
|
|
}
|
|
if _, dup := seen[text]; dup {
|
|
t.Fatal("challenge repeated — the nonce is not random")
|
|
}
|
|
seen[text] = struct{}{}
|
|
}
|
|
}
|
|
|
|
// --- challenge store --------------------------------------------------------
|
|
|
|
func TestChallengeStore_RedeemIsSingleUse(t *testing.T) {
|
|
s := NewChallengeStore()
|
|
text, _, err := s.Issue("zAlice", ActionDelete)
|
|
if err != nil {
|
|
t.Fatalf("issue: %v", err)
|
|
}
|
|
if err := s.Redeem(text, "zAlice", ActionDelete); err != nil {
|
|
t.Fatalf("first redeem: %v", err)
|
|
}
|
|
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
|
|
t.Fatal("a challenge must not be redeemable twice — that is replay")
|
|
}
|
|
}
|
|
|
|
// A challenge minted to *look at* a key must not delete the account, and one
|
|
// minted for Alice must not act on Bob. Both are inside the signed text, so a
|
|
// store that ignored them would authorise the wrong operation with a perfectly
|
|
// valid signature.
|
|
func TestChallengeStore_RedeemRefusesWrongUIDOrAction(t *testing.T) {
|
|
s := NewChallengeStore()
|
|
|
|
text, _, _ := s.Issue("zAlice", ActionReveal)
|
|
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
|
|
t.Fatal("a reveal challenge must not authorise a delete")
|
|
}
|
|
|
|
text2, _, _ := s.Issue("zAlice", ActionDelete)
|
|
if err := s.Redeem(text2, "zBob", ActionDelete); err == nil {
|
|
t.Fatal("alice's challenge must not authorise deleting bob")
|
|
}
|
|
}
|
|
|
|
// A failed redemption still consumes the challenge, so a wrong guess costs a
|
|
// round trip rather than leaving the nonce up for another attempt.
|
|
func TestChallengeStore_FailedRedeemStillBurnsIt(t *testing.T) {
|
|
s := NewChallengeStore()
|
|
text, _, _ := s.Issue("zAlice", ActionDelete)
|
|
_ = s.Redeem(text, "zBob", ActionDelete)
|
|
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
|
|
t.Fatal("a burnt challenge must stay burnt for its rightful owner too")
|
|
}
|
|
}
|
|
|
|
func TestChallengeStore_Expires(t *testing.T) {
|
|
s := NewChallengeStore()
|
|
now := time.Unix(1_700_000_000, 0)
|
|
s.now = func() time.Time { return now }
|
|
|
|
text, expires, _ := s.Issue("zAlice", ActionDelete)
|
|
if !expires.Equal(now.Add(ChallengeTTL)) {
|
|
t.Fatalf("expiry %v, want %v", expires, now.Add(ChallengeTTL))
|
|
}
|
|
now = now.Add(ChallengeTTL + time.Second)
|
|
if err := s.Redeem(text, "zAlice", ActionDelete); err == nil {
|
|
t.Fatal("an expired challenge must not redeem")
|
|
}
|
|
}
|
|
|
|
// Issuing is unauthenticated, so the map is attacker-growable. The cap is what
|
|
// stops that being a memory-exhaustion lever; expiry sweeping is what stops the
|
|
// cap being reached by honest traffic.
|
|
func TestChallengeStore_CapsInFlightAndSweepsExpired(t *testing.T) {
|
|
s := NewChallengeStore()
|
|
now := time.Unix(1_700_000_000, 0)
|
|
s.now = func() time.Time { return now }
|
|
|
|
for i := 0; i < maxChallenges; i++ {
|
|
if _, _, err := s.Issue("zAlice", ActionDelete); err != nil {
|
|
t.Fatalf("issue %d: %v", i, err)
|
|
}
|
|
}
|
|
if _, _, err := s.Issue("zAlice", ActionDelete); err == nil {
|
|
t.Fatal("expected the store to refuse once full")
|
|
}
|
|
|
|
// Once they age out, the next issue sweeps and succeeds again.
|
|
now = now.Add(ChallengeTTL + time.Second)
|
|
if _, _, err := s.Issue("zAlice", ActionDelete); err != nil {
|
|
t.Fatalf("after expiry the store must accept again: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestChallengeStore_RefusesInvalidInputs(t *testing.T) {
|
|
s := NewChallengeStore()
|
|
if _, _, err := s.Issue("../etc", ActionDelete); err == nil {
|
|
t.Fatal("a uid that is not uid-shaped must be refused")
|
|
}
|
|
if _, _, err := s.Issue("zAlice", Action("sudo")); err == nil {
|
|
t.Fatal("an unknown action must be refused")
|
|
}
|
|
}
|