carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1package movement
2
3import (
4 "crypto/hmac"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "fmt"
9 "time"
10)
11
12// A challenge is minted when a stance page renders and presented back when
13// the form is submitted. It carries a nonce, the moment it was issued, and
14// the difficulty the solver had to meet — all three sealed, so none of them
15// can be edited on the way back.
16//
17// Nothing is written to the database when one is minted. That is deliberate
18// and it is the second design this file has had: persisting a row per
19// challenge made every page view a serialised write against a single-writer
20// SQLite file, so a crawler, a link scanner or a prefetcher would have been
21// a denial of service against the one resource every signup needs. Nonces
22// are recorded as *spent* instead, on acceptance, where each row has already
23// cost an attacker a proof-of-work solve.
24
25const (
26 // MinAge is a claim about people, not throughput: nobody reads three
27 // positions on AI ethics, picks one, and writes a sentence about
28 // themselves in under three seconds.
29 MinAge = 3 * time.Second
30
31 // MaxAge bounds how long a minted challenge stays good, and therefore
32 // how long a spent nonce must be remembered.
33 MaxAge = 2 * time.Hour
34)
35
36// ChallengeKey derives the HMAC key for seals from the instance key.
37//
38// The instance key is shared with rastrillo/auth, which hashes it with a
39// label of its own. Deriving here with a different label is what stops a
40// token minted by one subsystem from ever verifying in the other.
41func ChallengeKey(instanceKey string) []byte {
42 sum := sha256.Sum256([]byte("movement/challenge\x00" + instanceKey))
43 return sum[:]
44}
45
46// Challenge travels to the browser as four hidden fields and comes back the
47// same way.
48type Challenge struct {
49 Nonce string
50 IssuedAt int64
51 Difficulty int
52 Seal string
53}
54
55func sealOf(key []byte, nonce string, issuedAt int64, difficulty int) string {
56 m := hmac.New(sha256.New, key)
57 fmt.Fprintf(m, "%s\x00%d\x00%d", nonce, issuedAt, difficulty)
58 return hex.EncodeToString(m.Sum(nil))
59}
60
61// NewChallenge mints one. The seal is a signature, not encryption: nothing
62// in a challenge needs to be secret from the person holding it, it only
63// needs to be impossible for them to alter.
64func NewChallenge(key []byte, now time.Time, difficulty int) Challenge {
65 var raw [16]byte
66 if _, err := rand.Read(raw[:]); err != nil {
67 // crypto/rand failing is not a condition to degrade through: every
68 // anti-abuse property below rests on this nonce being unguessable.
69 panic("movement: crypto/rand unavailable")
70 }
71 nonce := hex.EncodeToString(raw[:])
72 issued := now.Unix()
73 return Challenge{
74 Nonce: nonce,
75 IssuedAt: issued,
76 Difficulty: difficulty,
77 Seal: sealOf(key, nonce, issued, difficulty),
78 }
79}
80
81// VerifySeal reports why a challenge is unacceptable, and whether it is
82// acceptable at all.
83//
84// The order matters: the signature is checked before the timestamp, because
85// until the signature holds, IssuedAt is a number the submitter chose. A
86// check of an unverified timestamp is not a check.
87func VerifySeal(key []byte, c Challenge, now time.Time) (Reason, bool) {
88 want := sealOf(key, c.Nonce, c.IssuedAt, c.Difficulty)
89 if !hmac.Equal([]byte(want), []byte(c.Seal)) {
90 return ReasonSealInvalid, false
91 }
92 age := now.Sub(time.Unix(c.IssuedAt, 0))
93 switch {
94 case age < MinAge:
95 return ReasonTooFast, false
96 case age > MaxAge:
97 return ReasonTooOld, false
98 }
99 return "", true
100}
101
102// Expiry is when a spent nonce stops needing to be remembered: a challenge
103// older than MaxAge is refused on age alone, so a record beyond that point
104// protects nothing.
105func (c Challenge) Expiry() time.Time {
106 return time.Unix(c.IssuedAt, 0).Add(MaxAge)
107}
108