improved identity management
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
package identity
|
||||
|
||||
// In-memory, single-use challenge store backing proof-of-possession on the
|
||||
// account operations that need it (reveal, delete).
|
||||
//
|
||||
// In memory on purpose: a challenge is worthless once redeemed and worthless
|
||||
// once expired, so persisting it would only widen the window in which a leaked
|
||||
// one is useful. A meta-core restart invalidates every challenge in flight,
|
||||
// which costs a client one retry.
|
||||
//
|
||||
// Mirrors the sign-in challenge in meta-watch's src/auth.rs, down to the TTL —
|
||||
// the browser signs immediately, so seconds is the right order and a narrow
|
||||
// window bounds replay if one ever surfaces in a proxy log.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChallengeTTL is how long an issued challenge stays answerable.
|
||||
const ChallengeTTL = 120 * time.Second
|
||||
|
||||
// maxChallenges caps the store. An unauthenticated caller can mint challenges
|
||||
// for any uid it can name, so the map is attacker-growable; without a ceiling
|
||||
// that is a memory-exhaustion lever. At the cap, issuing fails rather than
|
||||
// evicting: dropping someone else's live challenge on demand would let an
|
||||
// attacker deny sign-outs, which is worse than making them retry.
|
||||
const maxChallenges = 4096
|
||||
|
||||
var (
|
||||
// ErrChallengeUnknown covers absent, already-redeemed and expired alike.
|
||||
// One error for all three deliberately: distinguishing them tells a prober
|
||||
// whether a challenge string was ever real.
|
||||
ErrChallengeUnknown = errors.New("challenge is unknown, already used, or expired")
|
||||
ErrChallengeFull = errors.New("too many challenges in flight; try again shortly")
|
||||
)
|
||||
|
||||
type challengeEntry struct {
|
||||
uid string
|
||||
action Action
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// ChallengeStore issues and redeems challenges. Safe for concurrent use.
|
||||
type ChallengeStore struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]challengeEntry
|
||||
ttl time.Duration
|
||||
// now is swappable so the tests can age entries without sleeping.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewChallengeStore() *ChallengeStore {
|
||||
return &ChallengeStore{
|
||||
entries: make(map[string]challengeEntry),
|
||||
ttl: ChallengeTTL,
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// Issue mints a challenge for (uid, action) and remembers it until redeemed or
|
||||
// expired. Returns the literal text the client signs and its expiry.
|
||||
func (s *ChallengeStore) Issue(uid string, action Action) (string, time.Time, error) {
|
||||
if !validUID(uid) {
|
||||
return "", time.Time{}, errors.New("invalid uid")
|
||||
}
|
||||
if !ValidAction(action) {
|
||||
return "", time.Time{}, errors.New("unknown action")
|
||||
}
|
||||
text, err := NewChallengeText(uid, action)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sweepLocked()
|
||||
if len(s.entries) >= maxChallenges {
|
||||
return "", time.Time{}, ErrChallengeFull
|
||||
}
|
||||
expires := s.now().Add(s.ttl)
|
||||
s.entries[text] = challengeEntry{uid: uid, action: action, expires: expires}
|
||||
return text, expires, nil
|
||||
}
|
||||
|
||||
// Redeem consumes a challenge, asserting it was issued for this exact uid and
|
||||
// action. Single-use: the entry is dropped on any lookup that finds it, so a
|
||||
// mismatched redemption burns the challenge rather than leaving it to be
|
||||
// retried against a different account.
|
||||
func (s *ChallengeStore) Redeem(text, uid string, action Action) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
entry, ok := s.entries[text]
|
||||
if !ok {
|
||||
return ErrChallengeUnknown
|
||||
}
|
||||
delete(s.entries, text)
|
||||
if s.now().After(entry.expires) {
|
||||
return ErrChallengeUnknown
|
||||
}
|
||||
if entry.uid != uid || entry.action != action {
|
||||
return ErrChallengeUnknown
|
||||
}
|
||||
// Re-read the signed bytes rather than trusting the map key alone: the
|
||||
// signature covers `text`, so `text` is what has to name this uid+action.
|
||||
if !ChallengeMatches(text, uid, action) {
|
||||
return ErrChallengeUnknown
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sweepLocked drops expired entries. Caller holds the lock.
|
||||
//
|
||||
// Called only from Issue: with no background goroutine, expiry is enforced
|
||||
// lazily on the one path that grows the map, which is exactly where the bound
|
||||
// matters.
|
||||
func (s *ChallengeStore) sweepLocked() {
|
||||
now := s.now()
|
||||
for text, e := range s.entries {
|
||||
if now.After(e.expires) {
|
||||
delete(s.entries, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A real signature produced by @noble/secp256k1 v3 — the library the dashboard
|
||||
// and meta-watch both sign with — pinned as a fixture and verified here.
|
||||
//
|
||||
// This is the one seam neither side's own tests can cover: Go signs DER, the
|
||||
// browser signs 64-byte compact, and every unit test above verifies signatures
|
||||
// this package produced itself. If the encodings ever disagree, the gate fails
|
||||
// closed in production (nobody can delete their own account) while every other
|
||||
// test stays green. Regenerate with:
|
||||
//
|
||||
// node -e 'import("@noble/secp256k1").then(async s => { … })'
|
||||
//
|
||||
// against the same fixed scalar (32 zero bytes with 42 in the last).
|
||||
func TestVerify_AcceptsARealNobleV3Signature(t *testing.T) {
|
||||
const (
|
||||
uid = "ztbJ71eLvfmGXw2ydaSPHavfwjva5Bzadutw2tvYDVFVY"
|
||||
challenge = "metamesh-identity-v1:delete:ztbJ71eLvfmGXw2ydaSPHavfwjva5Bzadutw2tvYDVFVY:deadbeef"
|
||||
sigB64 = "lIiZGL0j9l/w2WXwzwe56cICnusuYdD3oaTuYfFTtNJBR9MolXFPk3014WQeAlvmbX4LfnW+TQz4asqoSwmSAQ=="
|
||||
)
|
||||
|
||||
sig, err := base64.StdEncoding.DecodeString(sigB64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode fixture: %v", err)
|
||||
}
|
||||
if len(sig) != 64 {
|
||||
t.Fatalf("fixture is %d bytes, expected noble's 64-byte compact form", len(sig))
|
||||
}
|
||||
if err := Verify(uid, []byte(challenge), sig); err != nil {
|
||||
t.Fatalf("a genuine browser signature must verify: %v", err)
|
||||
}
|
||||
|
||||
// And the same signature must not verify over a different challenge — a
|
||||
// verifier that ignored the payload would pass the line above too.
|
||||
if err := Verify(uid, []byte(challenge+"x"), sig); err == nil {
|
||||
t.Fatal("the fixture verified over the wrong payload")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
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 Redis SCAN
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user