// Package auth provides bearer-token issuance and validation for // zddc-server. Tokens are persisted as YAML files under // /.zddc.d/tokens/, keyed by the SHA256 hash of the token // value (filename = hash, never the plaintext token). The directory is // shielded from public serving by the standard dot-prefix guard in the // dispatcher and the listing filter in fs.ListDirectory. // // Format of a token file: // // email: user@example.com // created: 2026-05-08T10:23:00Z // expires: 2026-08-08T10:23:00Z # zero/missing = no expiry // description: Field laptop # free-form, optional // // Token validation: client sends `Authorization: Bearer `, // server hashes, looks up /. If the file exists, parses // cleanly, and is not expired, the token is valid and the request runs // as the file's email. package auth import ( "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "os" "path/filepath" "sort" "strings" "sync" "time" "gopkg.in/yaml.v3" ) // DirName is the leaf-directory name under /.zddc.d/ where // token files live. Kept here so callers don't repeat the literal. const DirName = "tokens" // HashLen is the hex-encoded length of a SHA256 hash (the filename // length for token files). const HashLen = 64 // IDLen is the length of the short identifier used in the management UI // and revocation API. First IDLen hex chars of the SHA256 hash. const IDLen = 8 // ErrInvalidToken is returned when the token does not exist, fails to // parse, or is expired. Distinct from other errors so callers can map // it to a 401 without leaking which case occurred. var ErrInvalidToken = errors.New("invalid token") // ErrNotFound is returned by List/Revoke when a requested token does // not exist. Revoke also returns this when the token exists but does // not belong to the requesting email — same error to avoid leaking // existence of other users' tokens. var ErrNotFound = errors.New("token not found") // Token holds the metadata persisted alongside a token. The token // value itself (the bearer string the client sends) is NOT carried in // this struct — it's only known at generation time, returned once to // the user, and never re-derivable from the stored data. type Token struct { // Hash is the hex-encoded SHA256 of the plaintext token. Equal // to the filename of the on-disk YAML file. Hash string `yaml:"-"` Email string `yaml:"email"` Created time.Time `yaml:"created"` Expires time.Time `yaml:"expires,omitempty"` Description string `yaml:"description,omitempty"` } // ID returns the short identifier (first IDLen hex chars of Hash) used // in the management UI and the revocation endpoint. Distinct from the // secret token so it can be displayed and logged safely. func (t Token) ID() string { if len(t.Hash) < IDLen { return t.Hash } return t.Hash[:IDLen] } // Expired reports whether the token has an expiry in the past. Tokens // with a zero Expires never expire. func (t Token) Expired(now time.Time) bool { return !t.Expires.IsZero() && !now.Before(t.Expires) } // Store manages the on-disk token directory. type Store struct { dir string mu sync.Mutex // serializes file creation; reads are lock-free } // NewStore opens (and creates if missing) the token directory at // /.zddc.d/tokens. Directory mode 0700 — single-user-trust at // the FS layer. func NewStore(root string) (*Store, error) { dir := filepath.Join(root, ".zddc.d", DirName) if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("create token dir: %w", err) } // Best-effort tighten if pre-existing dir was looser. Don't fail // startup if chmod isn't permitted (NFS/SMB shares often refuse). _ = os.Chmod(dir, 0o700) return &Store{dir: dir}, nil } // Dir returns the absolute path of the token directory. Exposed for // tests and operator diagnostics; not used in the request path. func (s *Store) Dir() string { return s.dir } // Generate creates a new token for the given email and writes its file. // Returns the plaintext bearer token (to be displayed once to the user) // and the persisted Token metadata. // // expires==zero means no expiry. description is free-form; the caller // is responsible for any sanitization (e.g. trimming, length-capping). func (s *Store) Generate(email, description string, expires time.Time) (string, Token, error) { if email == "" { return "", Token{}, errors.New("email required") } // 32 bytes = 256 bits of entropy, URL-safe base64 → 43-char string. var raw [32]byte if _, err := rand.Read(raw[:]); err != nil { return "", Token{}, fmt.Errorf("generate token: %w", err) } plaintext := base64.RawURLEncoding.EncodeToString(raw[:]) sum := sha256.Sum256([]byte(plaintext)) hashHex := hex.EncodeToString(sum[:]) tok := Token{ Hash: hashHex, Email: email, Created: time.Now().UTC().Truncate(time.Second), Expires: expires, Description: description, } s.mu.Lock() defer s.mu.Unlock() if err := s.writeAtomic(hashHex, tok); err != nil { return "", Token{}, err } return plaintext, tok, nil } // Validate looks up a plaintext bearer token. Returns the persisted // Token on success, ErrInvalidToken if missing/malformed/expired. // // Constant-time comparison: not strictly required since the lookup is // keyed by the hash (no string comparison happens), but the hex // filename comparison via os.Stat is timing-stable enough for our // purposes — there's no known oracle that distinguishes "file missing" // from "file present but expired" without observing the response code. func (s *Store) Validate(plaintext string) (*Token, error) { if plaintext == "" { return nil, ErrInvalidToken } sum := sha256.Sum256([]byte(plaintext)) hashHex := hex.EncodeToString(sum[:]) tok, err := s.readByHash(hashHex) if err != nil { return nil, ErrInvalidToken } if tok.Expired(time.Now()) { return nil, ErrInvalidToken } return tok, nil } // List returns all tokens belonging to the given email, sorted by // Created descending (newest first). Returns an empty slice (not nil) // when the user has no tokens. func (s *Store) List(email string) ([]Token, error) { if email == "" { return nil, errors.New("email required") } entries, err := os.ReadDir(s.dir) if err != nil { if os.IsNotExist(err) { return []Token{}, nil } return nil, err } out := make([]Token, 0, len(entries)) for _, e := range entries { if e.IsDir() { continue } name := e.Name() if len(name) != HashLen || !isHex(name) { continue } tok, err := s.readByHash(name) if err != nil { continue } if !strings.EqualFold(tok.Email, email) { continue } out = append(out, *tok) } sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) }) return out, nil } // Revoke deletes the token identified by the given short ID (first // IDLen hex chars of the hash) or the full hash. Only revokes tokens // belonging to email; returns ErrNotFound otherwise (whether the token // is missing or owned by a different user — to avoid leaking ownership). func (s *Store) Revoke(email, idOrHash string) error { if email == "" { return errors.New("email required") } idOrHash = strings.ToLower(strings.TrimSpace(idOrHash)) s.mu.Lock() defer s.mu.Unlock() // Full-hash path: O(1) lookup. if len(idOrHash) == HashLen && isHex(idOrHash) { tok, err := s.readByHash(idOrHash) if err != nil { return ErrNotFound } if !strings.EqualFold(tok.Email, email) { return ErrNotFound } return os.Remove(filepath.Join(s.dir, idOrHash)) } // Short-ID path: must scan. Token IDs are 8 hex chars (~32 bits), // collision-resistant in practice for any reasonable per-user // token count. We pick the (single) match owned by email. if len(idOrHash) < 4 || !isHex(idOrHash) { return ErrNotFound } entries, err := os.ReadDir(s.dir) if err != nil { return ErrNotFound } var matches []string for _, e := range entries { if e.IsDir() { continue } name := e.Name() if len(name) != HashLen || !isHex(name) { continue } if !strings.HasPrefix(name, idOrHash) { continue } tok, err := s.readByHash(name) if err != nil { continue } if !strings.EqualFold(tok.Email, email) { continue } matches = append(matches, name) } if len(matches) == 0 { return ErrNotFound } if len(matches) > 1 { return fmt.Errorf("ambiguous id %q matches %d tokens", idOrHash, len(matches)) } return os.Remove(filepath.Join(s.dir, matches[0])) } // readByHash reads and parses the token file named by hashHex. The // returned Token has its Hash field set. func (s *Store) readByHash(hashHex string) (*Token, error) { if len(hashHex) != HashLen || !isHex(hashHex) { return nil, ErrInvalidToken } path := filepath.Join(s.dir, hashHex) bytes, err := os.ReadFile(path) if err != nil { return nil, err } var tok Token if err := yaml.Unmarshal(bytes, &tok); err != nil { return nil, err } tok.Hash = hashHex if tok.Email == "" { return nil, ErrInvalidToken } return &tok, nil } // writeAtomic serializes a token to YAML and writes it via tmp+rename. // File mode 0600. func (s *Store) writeAtomic(hashHex string, tok Token) error { bytes, err := yaml.Marshal(tok) if err != nil { return fmt.Errorf("marshal token: %w", err) } final := filepath.Join(s.dir, hashHex) tmp, err := os.CreateTemp(s.dir, ".tmp-*") if err != nil { return fmt.Errorf("create temp: %w", err) } tmpName := tmp.Name() if _, err := tmp.Write(bytes); err != nil { tmp.Close() os.Remove(tmpName) return fmt.Errorf("write temp: %w", err) } if err := tmp.Chmod(0o600); err != nil { tmp.Close() os.Remove(tmpName) return fmt.Errorf("chmod temp: %w", err) } if err := tmp.Close(); err != nil { os.Remove(tmpName) return fmt.Errorf("close temp: %w", err) } if err := os.Rename(tmpName, final); err != nil { os.Remove(tmpName) return fmt.Errorf("rename temp: %w", err) } return nil } // isHex reports whether s is a non-empty string of [0-9a-f] characters. func isHex(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { c := s[i] if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { return false } } return true }