| 1 | package movement |
| 2 | |
| 3 | import ( |
| 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 | |
| 25 | const ( |
| 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. |
| 41 | func 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. |
| 48 | type Challenge struct { |
| 49 | Nonce string |
| 50 | IssuedAt int64 |
| 51 | Difficulty int |
| 52 | Seal string |
| 53 | } |
| 54 | |
| 55 | func 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. |
| 64 | func 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. |
| 87 | func 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. |
| 105 | func (c Challenge) Expiry() time.Time { |
| 106 | return time.Unix(c.IssuedAt, 0).Add(MaxAge) |
| 107 | } |
| 108 | |