Files
verdande/internal/auth/token.go
T
Kristian WindandClaude Opus 5 b44db32bf7 feat(auth): Argon2id passwords, opaque tokens, TOTP and recovery codes
Password hashes are self-describing PHC strings, so the cost parameters
can be raised later without locking anyone out: NeedsRehash reports which
hashes to upgrade at the next login, when the plaintext is briefly at hand.

Session, invite, reset and API tokens are stored only as SHA-256 hashes;
the plaintext exists once, in the link or the reply. Unsalted is correct
here and only here — the input is 256 bits of randomness that has to be
found by value in a WHERE clause.

feat(store): project roles with viewer/editor/owner and explicit denial

Roles are strictly ordered so every check is "at least X". Access to a
task is access to its project: being the assignee or the author confers
nothing, or removing someone from a project would leave them holding
tasks inside it. Checks take a Querier so they can run in the same
transaction as the write they guard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 23:00:28 +02:00

58 lines
2.1 KiB
Go

package auth
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
)
// tokenBytes is 32 bytes — 256 bits — of randomness. These tokens sit in emailed
// links and in scripts' config files, where they may live for a long time and be
// guessed at without rate limiting, so they are sized to be unguessable rather than
// convenient.
const tokenBytes = 32
// NewToken returns a URL-safe random token. This is the only time the plaintext
// exists: store HashToken(t), put t in the link, and never write it down anywhere
// else. A database dump then contains nothing an attacker can present.
func NewToken() (string, error) {
b := make([]byte, tokenBytes)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("auth: read random: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// HashToken is the lookup key for a token.
//
// SHA-256 with no salt, deliberately — unlike a password, this has to be *found* by
// its value in a WHERE clause, which a per-row salt makes impossible. That is safe
// here only because the input is 256 bits of uniform randomness: there is no
// dictionary to run against it and no work factor worth adding.
func HashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// apiTokenPrefix marks verdande's own tokens in logs and config files, and lets
// secret scanners recognise one that has been committed by accident.
const apiTokenPrefix = "vrd_"
// NewAPIToken returns a personal API token and the short prefix shown in the UI so
// somebody can tell two of their tokens apart without being shown either in full.
func NewAPIToken() (token, prefix string, err error) {
raw, err := NewToken()
if err != nil {
return "", "", err
}
token = apiTokenPrefix + raw
return token, token[:len(apiTokenPrefix)+6], nil
}
// IsAPIToken reports whether a credential looks like one of verdande's API tokens
// rather than a session id, so the two are never checked against the wrong table.
func IsAPIToken(s string) bool { return strings.HasPrefix(s, apiTokenPrefix) }