package main // The primitives: hashing, signing, cookies, one time links, TOTP, push // tokens, and rate limits. import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/sha256" "crypto/subtle" "encoding/base32" "encoding/base64" "encoding/binary" "encoding/hex" "errors" "fmt" "log" "net/http" "net/url" "strconv" "strings" "sync" "time" "golang.org/x/crypto/argon2" ) // Password hashing parameters, stored in every hash, so old hashes keep // verifying. const ( argonTime = 3 argonMemory = 64 * 1024 // KiB argonThreads = 4 argonSaltLen = 16 argonKeyLen = 32 minPassword = 8 ) const ( sessionCookie = "session" sessionLifetime = 30 * 24 * time.Hour ) // What a form may weigh; field limits are the handlers' own. const ( // Percent encoding can treble a byte, and the largest field is maxBody. maxForm = 4*maxBody + 64<<10 // Both images at their limit plus one a little over, so it still gets the // sentence saying so. maxUpload = 3 * maxImage ) // argon2id is expensive in memory by design, so concurrent hashes are bounded: // otherwise an unauthenticated POST to /login sets the peak. var hashing = make(chan struct{}, 2) func hashSlot() func() { hashing <- struct{}{} return func() { <-hashing } } // hashPassword returns a PHC string: $argon2id$v=19$m=...,t=...,p=...$salt$hash. func hashPassword(password string) string { defer hashSlot()() salt := make([]byte, argonSaltLen) rand.Read(salt) // crypto/rand does not fail, it panics if the kernel does key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen) b64 := base64.RawStdEncoding.EncodeToString return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads, b64(salt), b64(key)) } // passwordOK hashes even when the encoding is unusable, so a wrong account // name costs the same as a wrong password. func passwordOK(encoded, password string) bool { defer hashSlot()() salt, want, time, memory, threads, err := parseHash(encoded) if err != nil { salt, want, time, memory, threads = make([]byte, argonSaltLen), make([]byte, argonKeyLen), argonTime, argonMemory, argonThreads } got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want))) return err == nil && subtle.ConstantTimeCompare(got, want) == 1 } func parseHash(encoded string) (salt, key []byte, time, memory uint32, threads uint8, err error) { fail := func(why string) ([]byte, []byte, uint32, uint32, uint8, error) { return nil, nil, 0, 0, 0, fmt.Errorf("argon2id hash: %s", why) } parts := strings.Split(encoded, "$") if len(parts) != 6 || parts[0] != "" || parts[1] != "argon2id" { return fail("not a $argon2id$ string") } var version int if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { return fail("unsupported version") } var m, t uint32 var p uint8 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil { return fail("unreadable parameters") } salt, err = base64.RawStdEncoding.DecodeString(parts[4]) if err != nil { return fail("unreadable salt") } key, err = base64.RawStdEncoding.DecodeString(parts[5]) if err != nil || len(key) == 0 { return fail("unreadable hash") } return salt, key, t, m, p, nil } // mac signs fields for one purpose, so a session cookie is never a reset link; // the separator keeps two field lists apart. func (f *Forge) mac(purpose string, fields ...string) string { h := hmac.New(sha256.New, f.store.cookieKey) h.Write([]byte(purpose)) for _, s := range fields { h.Write([]byte{0}) h.Write([]byte(s)) } return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) } // There is no session store: the cookie carries the name and expiry, and the // epoch is mixed into the signature, so bumping it invalidates every cookie. func (f *Forge) setSession(w http.ResponseWriter, name string, epoch int) string { expiry := time.Now().Add(sessionLifetime) unix := strconv.FormatInt(expiry.Unix(), 10) value := base64.RawURLEncoding.EncodeToString([]byte(name)) + "." + unix + "." + f.mac("session", name, unix, strconv.Itoa(epoch)) http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: value, Path: "/", Expires: expiry, MaxAge: int(sessionLifetime / time.Second), HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"), }) return value } func (f *Forge) clearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: strings.HasPrefix(f.cfg.BaseURL, "https://"), }) } func (f *Forge) user(r *http.Request) (string, User, bool) { c, err := r.Cookie(sessionCookie) if err != nil { return "", User{}, false } parts := strings.Split(c.Value, ".") if len(parts) != 3 { return "", User{}, false } raw, err := base64.RawURLEncoding.DecodeString(parts[0]) if err != nil { return "", User{}, false } unix, err := strconv.ParseInt(parts[1], 10, 64) if err != nil || time.Now().After(time.Unix(unix, 0)) { return "", User{}, false } name := string(raw) u, ok := f.store.User(name) if !ok { return "", User{}, false } want := f.mac("session", name, parts[1], strconv.Itoa(u.Epoch)) if !hmac.Equal([]byte(parts[2]), []byte(want)) { return "", User{}, false } return name, u, true } // csrf is the form token, bound to the session cookie. func (f *Forge) csrf(r *http.Request) string { c, err := r.Cookie(sessionCookie) if err != nil { return f.mac("csrf", "") } return f.mac("csrf", c.Value) } // checkPost gates the HTML forms, not the git endpoints. Signed in, the form // token is required; signed out, only the Origin header can say, and a client // sending none is let through. func (f *Forge) checkPost(w http.ResponseWriter, r *http.Request) bool { // ParseMultipartForm's argument bounds memory only; the rest goes to a // temporary file, so MaxBytesReader is what bounds the disk. var err error if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { r.Body = http.MaxBytesReader(w, r.Body, maxUpload) err = r.ParseMultipartForm(maxImage) } else { r.Body = http.MaxBytesReader(w, r.Body, maxForm) err = r.ParseForm() } if err != nil { f.fail(w, r, http.StatusBadRequest, "unreadable form, or one too big to be one") return false } if origin := r.Header.Get("Origin"); origin != "" && origin != f.cfg.BaseURL { f.fail(w, r, http.StatusForbidden, "cross-site post refused") return false } if _, _, ok := f.user(r); ok { if !hmac.Equal([]byte(r.PostFormValue("csrf")), []byte(f.csrf(r))) { f.fail(w, r, http.StatusForbidden, "stale form, reload the page and try again") return false } } return true } // maxTokens bounds users.json and the audit log, and keeps a three byte id // unambiguous. const maxTokens = 32 // Only the digest is kept. func mintToken(repo string) (secret string, token Token) { secret = randomToken(24) return secret, Token{ ID: randomToken(3), Repo: repo, Hash: tokenHash(secret), Created: today(), } } // today is the granularity every date on a token is kept at. func today() string { return time.Now().UTC().Format("2006-01-02") } // tokenHash is SHA-256, not argon2id: a token is high entropy, and this runs // on every git request. func tokenHash(secret string) string { sum := sha256.Sum256([]byte(secret)) return hex.EncodeToString(sum[:]) } // checkToken returns the account's own name, not the one typed: grants and // REMOTE_USER key off it. func (f *Forge) checkToken(given, secret, repo string) (string, User, bool) { name := accountName(given) u, ok := f.store.User(name) if !ok { return "", User{}, false } want := tokenHash(secret) for _, t := range u.Tokens { if t.Repo == repo && hmac.Equal([]byte(t.Hash), []byte(want)) { f.touchToken(name, t) return name, u, true } } return "", User{}, false } // touchToken writes once a day: git makes several requests per clone. func (f *Forge) touchToken(name string, used Token) { if used.Used == today() { return } err := f.store.touchUser(name, func(acct *User) error { for i := range acct.Tokens { if acct.Tokens[i].ID == used.ID { acct.Tokens[i].Used = today() } } return nil }) if err != nil { log.Printf("tokens: %v", err) // not worth failing a push over } } // TOTP is RFC 6238 with the parameters every authenticator assumes: HMAC-SHA1, // six digits, thirty seconds. const ( totpDigits = 6 totpPeriod = 30 * time.Second totpSkew = 1 // steps either side totpSecretLen = 20 recoveryCodes = 10 totpPendLife = 5 * time.Minute ) // totpSecret is base32 without padding, as an otpauth URI carries it. func totpSecret() string { raw := make([]byte, totpSecretLen) rand.Read(raw) return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(raw) } func totpCode(secret string, at time.Time) string { raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret)) if err != nil { return "" } counter := make([]byte, 8) binary.BigEndian.PutUint64(counter, uint64(at.Unix())/uint64(totpPeriod.Seconds())) mac := hmac.New(sha1.New, raw) mac.Write(counter) sum := mac.Sum(nil) offset := sum[len(sum)-1] & 0x0f value := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff return fmt.Sprintf("%0*d", totpDigits, value%1000000) } // validSecret is base32 of exactly the bytes this forge mints. The secret // arrives in a form, so its strength is checked here. func validSecret(secret string) bool { raw, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) return err == nil && len(raw) == totpSecretLen } // totpStep is the time step the code is right for, or -1. One step either // side of now is accepted, because clocks disagree. func totpStep(secret, code string, now time.Time) int64 { code = strings.TrimSpace(code) for skew := -totpSkew; skew <= totpSkew; skew++ { at := now.Add(time.Duration(skew) * totpPeriod) want := totpCode(secret, at) if want != "" && hmac.Equal([]byte(code), []byte(want)) { return at.Unix() / int64(totpPeriod.Seconds()) } } return -1 } var errBadCode = errors.New("that code is not right, or was used already") // codeOK accepts a code once: the step it was accepted at is recorded on the // account and a step at or below it is refused, so a code seen over a shoulder // cannot be replayed within its window. Recorded without a commit, like a // token's last use. A record that cannot be written refuses the code. func (f *Forge) codeOK(name string, u User, code string) bool { if u.TOTP == nil { return false } step := totpStep(u.TOTP.Secret, code, time.Now()) if step < 0 || step <= u.TOTP.Used { return false } err := f.store.touchUser(name, func(acct *User) error { if acct.TOTP == nil || step <= acct.TOTP.Used { return errBadCode } acct.TOTP.Used = step return nil }) if err != nil && !errors.Is(err, errBadCode) { log.Printf("totp: %v", err) } return err == nil } // totpURI is given as text: a QR code is useless in a terminal browser. func (f *Forge) totpURI(name, secret string) string { label := url.PathEscape(f.siteName() + ":" + name) q := url.Values{ "secret": {secret}, "issuer": {f.siteName()}, "algorithm": {"SHA1"}, "digits": {strconv.Itoa(totpDigits)}, "period": {strconv.Itoa(int(totpPeriod.Seconds()))}, } return "otpauth://totp/" + label + "?" + q.Encode() } // newRecoveryCodes returns the codes to show once and the digests to keep. func newRecoveryCodes() (show []string, keep []string) { for range recoveryCodes { code := randomToken(5) show = append(show, code) keep = append(keep, tokenHash(code)) } return show, keep } func randomToken(n int) string { b := make([]byte, n) rand.Read(b) return hex.EncodeToString(b) } // A first password and a reset are the same link with different lives. const ( setPasswordLife = 48 * time.Hour resetLife = time.Hour ) // passwordLink is stateless: a MAC over the account, an expiry, and the // current password hash, so it dies when the password changes. func (f *Forge) passwordLink(name string, u User, life time.Duration) string { expires := strconv.FormatInt(time.Now().Add(life).Unix(), 10) q := url.Values{ "name": {name}, "expires": {expires}, "t": {f.mac("set-password", name, expires, u.Password)}, } return f.cfg.BaseURL + "/set-password?" + q.Encode() } func (f *Forge) checkSetPassword(v url.Values) (string, User, bool) { name, expires := v.Get("name"), v.Get("expires") unix, err := strconv.ParseInt(expires, 10, 64) if err != nil || time.Now().After(time.Unix(unix, 0)) { return "", User{}, false } u, ok := f.store.User(name) if !ok { return "", User{}, false } want := f.mac("set-password", name, expires, u.Password) if !hmac.Equal([]byte(v.Get("t")), []byte(want)) { return "", User{}, false } return name, u, true } const confirmEmailLife = 24 * time.Hour // confirmEmailLink is bound to the address, so changing it kills the link. func (f *Forge) confirmEmailLink(name string, u User) string { expires := strconv.FormatInt(time.Now().Add(confirmEmailLife).Unix(), 10) q := url.Values{ "name": {name}, "expires": {expires}, "t": {f.mac("confirm-email", name, expires, u.Email)}, } return f.cfg.BaseURL + "/confirm-email?" + q.Encode() } // Every budget, per hour. Constants: nobody tunes these. const ( loginsPerHour = 60 totpPerHour = 30 resetsPerHour = 5 issuesPerHour = 20 commentsPerHour = 60 // maxTokens bounds what is kept; this bounds what is written to get there. tokensPerHour = 20 ) // limiter is a fixed window counter, per account and per address. In memory, // so it resets on restart. type limiter struct { mu sync.Mutex opened time.Time counts map[string]int } func newLimiter() *limiter { return &limiter{opened: time.Now(), counts: map[string]int{}} } func (l *limiter) hit(key string, perHour int) bool { l.charge(key) return !l.over(key, perHour) } // charge counts; over asks without counting, for budgets spent only by // failures. func (l *limiter) charge(key string) { l.mu.Lock() defer l.mu.Unlock() l.rollLocked() l.counts[key]++ } func (l *limiter) over(key string, perHour int) bool { l.mu.Lock() defer l.mu.Unlock() l.rollLocked() return l.counts[key] > perHour } // Both callers roll the window: a budget spent only by failures never reaches // charge once it has run out. func (l *limiter) rollLocked() { if time.Since(l.opened) >= time.Hour { l.opened, l.counts = time.Now(), map[string]int{} } } // allow charges both the account and the address. func (l *limiter) allow(addr string, v Viewer, perHour int) bool { ok := l.hit("ip:"+addr, perHour) if v.SignedIn() { ok = l.hit(accountKey(v.Name), perHour) && ok } return ok } // accountKey is one account's budget, wherever it is spent from. func accountKey(name string) string { return "account:" + name }