Files
coder/coderd/apikey/apikey.go
T
Kacper Sawicki 8cfb294291 fix: initialize API key LastUsed to Unix epoch instead of zero time (#22327)
## Flake Fix

Resolves https://github.com/coder/internal/issues/1301

`TestAIBridgeListInterceptions/Pagination/offset` flakes with a 500
caused by `runtime error: integer divide by zero` in `pq.ParseTimestamp`
(encode.go:430) during `GetAPIKeyByID` in the auth middleware.

### Root Cause

**PostgreSQL historical timezone formatting + fragile pq parser:**

1. **Year-0001 timestamps trigger unusual PostgreSQL formatting.** New
API keys were initialized with `LastUsed: time.Time{}` (year
0001-01-01). When the PostgreSQL server timezone is non-UTC, it applies
historical Local Mean Time (LMT) offsets for pre-1900 dates. For year
0001, this can produce timestamps with seconds in the timezone offset
like `0001-12-31 19:03:58-04:56:02`, a format the pq parser was never
designed to handle.

2. **The pq parser panics on unexpected formats.** The
fractional-seconds parser at encode.go:430 computes `fracOff` via
`strings.IndexAny`. When the timestamp has an unusual LMT format, index
arithmetic can produce `fracOff ≤ 0`, causing `int(math.Pow(10,
float64(negative))) = 0` → divide-by-zero panic.

3. **Why it is intermittent:** CI Postgres instances may have varying
timezone configs across runs. The pagination test makes 80+ API calls,
each reading `last_used` via `GetAPIKeyByID`, increasing the probability
of hitting the edge case.

4. **Ruled out pq race condition.** The decode path copies bytes to a Go
string via `string(s)` before `ParseTimestamp`, so buffer reuse cannot
corrupt the input.

### Fix

Initialize `LastUsed` to `time.Unix(0, 0).UTC()` (Unix epoch,
1970-01-01) instead of `time.Time{}` (year 0001). This avoids the entire
class of historical timestamp formatting edge cases.

**Why not `dbtime.Now()`?** The auth middleware debounces `LastUsed`
updates — it only writes when `now.Sub(key.LastUsed) > time.Hour`. Using
`dbtime.Now()` makes the key appear freshly used so the debounce never
triggers, breaking `TestPostUsers/LastSeenAt` and
`TestUsersFilter/LastSeenBeforeNow`. Unix epoch is always >1 hour in the
past, so debounce works correctly.

### Follow-up

A defensive fix should also be added to the `coder/pq` fork (guard
`fracOff ≤ 0` before the division in `ParseTimestamp`). Other year-0001
sentinel values exist across the codebase (`workspace_builds.deadline`,
`users.last_seen_at`, `workspaces.last_used_at`, etc.) and remain
theoretically vulnerable until the pq fork is hardened.
2026-03-02 16:02:01 +01:00

158 lines
4.4 KiB
Go

package apikey
import (
"crypto/sha256"
"crypto/subtle"
"fmt"
"net"
"time"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/cryptorand"
)
type CreateParams struct {
UserID uuid.UUID
LoginType database.LoginType
// DefaultLifetime is configured in DeploymentValues.
// It is used if both ExpiresAt and LifetimeSeconds are not set.
DefaultLifetime time.Duration
// Optional.
ExpiresAt time.Time
LifetimeSeconds int64
// Scope is legacy single-scope input kept for backward compatibility.
//
// Deprecated: use Scopes instead.
Scope database.APIKeyScope
// Scopes is the full list of scopes to attach to the key.
Scopes database.APIKeyScopes
TokenName string
RemoteAddr string
// AllowList is an optional, normalized allow-list
// of resource type and uuid entries. If empty, defaults to wildcard.
AllowList database.AllowList
}
// Generate generates an API key, returning the key as a string as well as the
// database representation. It is the responsibility of the caller to insert it
// into the database.
func Generate(params CreateParams) (database.InsertAPIKeyParams, string, error) {
// Length of an API Key ID.
keyID, err := cryptorand.String(10)
if err != nil {
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("generate API key ID: %w", err)
}
// Length of an API Key secret.
keySecret, hashedSecret, err := GenerateSecret(22)
if err != nil {
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("generate API key secret: %w", err)
}
// Default expires at to now+lifetime, or use the configured value if not
// set.
if params.ExpiresAt.IsZero() {
if params.LifetimeSeconds != 0 {
params.ExpiresAt = dbtime.Now().Add(time.Duration(params.LifetimeSeconds) * time.Second)
} else {
params.ExpiresAt = dbtime.Now().Add(params.DefaultLifetime)
params.LifetimeSeconds = int64(params.DefaultLifetime.Seconds())
}
}
if params.LifetimeSeconds == 0 {
params.LifetimeSeconds = int64(time.Until(params.ExpiresAt).Seconds())
}
if len(params.AllowList) == 0 {
params.AllowList = database.AllowList{{Type: policy.WildcardSymbol, ID: policy.WildcardSymbol}}
}
ip := net.ParseIP(params.RemoteAddr)
if ip == nil {
ip = net.IPv4(0, 0, 0, 0)
}
bitlen := len(ip) * 8
var scopes database.APIKeyScopes
switch {
case len(params.Scopes) > 0:
scopes = params.Scopes
case params.Scope != "":
var scope database.APIKeyScope
switch params.Scope {
case "all":
scope = database.ApiKeyScopeCoderAll
case "application_connect":
scope = database.ApiKeyScopeCoderApplicationConnect
default:
scope = params.Scope
}
scopes = database.APIKeyScopes{scope}
default:
// Default to coder:all scope for backward compatibility.
scopes = database.APIKeyScopes{database.ApiKeyScopeCoderAll}
}
for _, s := range scopes {
if !s.Valid() {
return database.InsertAPIKeyParams{}, "", xerrors.Errorf("invalid API key scope: %q", s)
}
}
token := fmt.Sprintf("%s-%s", keyID, keySecret)
return database.InsertAPIKeyParams{
ID: keyID,
UserID: params.UserID,
LastUsed: time.Unix(0, 0).UTC(),
LifetimeSeconds: params.LifetimeSeconds,
IPAddress: pqtype.Inet{
IPNet: net.IPNet{
IP: ip,
Mask: net.CIDRMask(bitlen, bitlen),
},
Valid: true,
},
// Make sure in UTC time for common time zone
ExpiresAt: params.ExpiresAt.UTC(),
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
HashedSecret: hashedSecret,
LoginType: params.LoginType,
Scopes: scopes,
AllowList: params.AllowList,
TokenName: params.TokenName,
}, token, nil
}
func GenerateSecret(length int) (secret string, hashed []byte, err error) {
secret, err = cryptorand.String(length)
if err != nil {
return "", nil, err
}
hash := HashSecret(secret)
return secret, hash, nil
}
// ValidateHash compares a secret against an expected hashed secret.
func ValidateHash(hashedSecret []byte, secret string) bool {
hash := HashSecret(secret)
return subtle.ConstantTimeCompare(hashedSecret, hash) == 1
}
// HashSecret is the single function used to hash API key secrets.
// Use this to ensure a consistent hashing algorithm.
func HashSecret(secret string) []byte {
hash := sha256.Sum256([]byte(secret))
return hash[:]
}