Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ if ok, _ := pwd.Verify("Str0ng-P@ssword!", hash); ok {
authcore is an in-process library, not a hosted identity platform: it ships no
database and no HTTP server of its own, generates and manages its own signing
keys on first run, and each module (password, jwt, apikey, oauth, email,
username, totp) can be used independently.
username, totp, credential, field) can be used independently.

## Modules

Expand All @@ -74,18 +74,20 @@ Pick only what you need — each is independent, testable, and safe by default.
| | Module | Does |
|---|---|---|
| 🔑 | **[password](docs/password.md)** | Hash + verify. Argon2id, policy-enforced, self-describing PHC format. |
| 🎫 | **[jwt](docs/jwt.md)** | Access + refresh tokens. EdDSA / Ed25519, generic claims, rotation. |
| 🎫 | **[jwt](docs/jwt.md)** | Access + refresh tokens. EdDSA / Ed25519, generic claims, rotation, optional denylist for instant revocation. |
| 📧 | **[email](docs/validation.md)** | Validate + normalize. RFC 5321/5322, optional cached DNS MX check. |
| 👤 | **[username](docs/validation.md)** | Validate + normalize. Reserved-name blocklist, character rules. |
| 🗝️ | **[apikey](docs/apikey.md)** | Opaque API keys. Generate, keyed-hash for storage, constant-time verify. |
| 🔐 | **[totp](docs/totp.md)** | TOTP / RFC 6238 second factor. Enroll, verify (with replay protection), recovery codes. |
| ✉️ | **[credential](docs/credential.md)** | Single-use tokens for password reset and account activation. Bound to a purpose and a subject, TTL enforced. |
| 🛡️ | **[field](docs/field.md)** | Column encryption. AES-256-GCM plus an HMAC blind index, so a value stays searchable by equality without being readable. |
| 🌐 | **[oauth](docs/oauth.md)** | Social login — Google, Microsoft (OIDC) and GitHub, Discord (OAuth2). Auth Code + PKCE, ID-token validation or userinfo. |

```mermaid
flowchart LR
App["Your app"] -->|init once| Core["authcore"]
Core -->|auto-generates| Keys[("🔑 Ed25519 + HMAC<br/>on disk")]
Core -->|Provider| M["password · jwt · apikey · oauth<br/>email · username · totp"]
Core -->|Provider| M["password · jwt · apikey · oauth · email<br/>username · totp · credential · field"]
M -->|hash · sign · verify| App
```

Expand All @@ -94,7 +96,7 @@ flowchart LR
**New here? Start with the [Secure login recipe](docs/secure-login.md)** — the
step-by-step flow that turns these primitives into a login an auditor accepts.

[Secure login recipe](docs/secure-login.md) · [Password](docs/password.md) · [JWT](docs/jwt.md) · [Email & username](docs/validation.md) · [API keys](docs/apikey.md) · [TOTP](docs/totp.md) · [OIDC login](docs/oauth.md) · [Key management](docs/key-management.md) · [Configuration](docs/configuration.md) · [Testing & modules](docs/testing.md) · [Migrating from bcrypt](docs/migrating.md) · [Errors](docs/errors.md) · [FAQ](docs/faq.md) · [Versioning](docs/versioning.md)
[Secure login recipe](docs/secure-login.md) · [Password](docs/password.md) · [JWT](docs/jwt.md) · [Email & username](docs/validation.md) · [API keys](docs/apikey.md) · [TOTP](docs/totp.md) · [Credential tokens](docs/credential.md) · [Field encryption](docs/field.md) · [OIDC login](docs/oauth.md) · [Key management](docs/key-management.md) · [Configuration](docs/configuration.md) · [Testing & modules](docs/testing.md) · [Migrating from bcrypt](docs/migrating.md) · [Errors](docs/errors.md) · [FAQ](docs/faq.md) · [Versioning](docs/versioning.md)

Full API reference on [pkg.go.dev](https://pkg.go.dev/github.com/Glyndor/authcore).

Expand Down
84 changes: 84 additions & 0 deletions auth/credential/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package credential

import (
"fmt"
"time"
)

// Config holds the credential module configuration.
//
// The configuration is split into two layers, matching the authcore
// principle documented in docs/configuration.md:
//
// - The cryptographic layer is CLOSED and is not configurable here. Token
// entropy (32 bytes / 256 bits), the HMAC-SHA256 construction and its
// library-managed pepper, the constant-time comparison, the binding of
// purpose and subject into the stored hash, and the base64-URL token
// encoding are fixed. Weakening any of these produces a credential a
// stolen email can be spent against the wrong user or the wrong flow.
// - The policy layer is OPEN with today's value as the default: TTL
// (how long a token remains redeemable).
//
// What stays fixed regardless of configuration:
//
// - Token length: 32 random bytes (256 bits) per CSPRNG draw
// - Token encoding: base64 URL without padding, drops into a link as-is
// - Stored hash: HMAC-SHA256(pepper, len(purpose)||purpose ||
// len(subject)||subject || len(token)||token), each length a big-endian
// uint32, so ("reset", "ab") cannot collide with ("reseta", "b") no
// matter what bytes the fields contain
// - Hash output: lowercase hex
// - Comparison: crypto/subtle.ConstantTimeCompare, with the comparison
// always run before the expiry check so wall-clock time does not reveal
// whether a token existed
// - Future issuedAt tolerance: 1 minute, anything more counts as expired
//
// Start from DefaultConfig and override only what your installation needs:
//
// cred, err := credential.New(auth) // defaults
// cred, err := credential.New(auth, credential.Config{TTL: 15 * time.Minute})
type Config struct {
// TTL is how long a credential token remains valid from its issuedAt.
// A reset link that lives longer than a day is a standing key to the
// account sitting in an inbox, so validateConfig refuses anything above
// 24 hours. A TTL of zero or a negative TTL is refused for the same
// reason: an instantly-expired or backwards-running token has no
// legitimate use, only a bug.
//
// Defaults to 1 hour.
TTL time.Duration
}

// DefaultConfig returns a Config with the library's recommended defaults.
func DefaultConfig() Config {
return Config{TTL: time.Hour}
}

const maxTTL = 24 * time.Hour

// applyDefaults is a pass-through for TTL.
//
// Unlike auth/totp's SkewSteps (a pointer so zero is a meaningful "no
// tolerance" value), TTL is a plain time.Duration: zero is not
// meaningful (it would make every token instantly expired), and the
// brief is explicit that it must be refused. Filling zero with the
// default here would silently turn a caller bug into a 1-hour token,
// so validateConfig is the only thing that decides what TTL values are
// allowed. New routes the no-Config case through DefaultConfig() so
// that callers who omit Config still get the 1-hour default; applyDefaults
// exists only so the function trio (DefaultConfig / applyDefaults /
// validateConfig) matches the shape used across the rest of authcore.
func applyDefaults(cfg Config) Config {
return cfg
}

// validateConfig returns an error if cfg contains invalid values.
func validateConfig(cfg Config) error {
if cfg.TTL <= 0 {
return fmt.Errorf("ttl must be positive, got %s", cfg.TTL)
}
if cfg.TTL > maxTTL {
return fmt.Errorf("ttl must be at most %s, got %s", maxTTL, cfg.TTL)
}
return nil
}
257 changes: 257 additions & 0 deletions auth/credential/credential.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
// Package credential mints and verifies single-use credential tokens for
// authcore.
//
// # What it is for
//
// Password resets and account activations are the two most security
// sensitive emails an application ever sends. The token that powers
// each of them has to be high-entropy, single-use, time-bounded, and
// bound to the user and the flow it was minted for. Without that
// binding, a caller who keeps one table for both flows has a
// cross-flow confusion bug, and one who looks up by token rather than
// by user has an account mixup. This module makes those mistakes
// impossible from the credential side: it mints the token, hands the
// raw value back once for the email link and a keyed hash for the
// caller to store, and verifies a presented token against the stored
// hash. The module stores nothing.
//
// auth, _ := authcore.New(authcore.DefaultConfig())
// cred, _ := credential.New(auth)
//
// // Issue: put Token in the email link, persist Hash and issuedAt.
// issued, _ := cred.Issue("reset", "alice@example.com")
// sendEmail(alice, "?token="+url.QueryEscape(issued.Token))
// db.StoreResetToken(alice.ID, issued.Hash, time.Now())
//
// // Verify: same purpose and subject, before TTL, with single-use.
// err := cred.Verify("reset", "alice@example.com",
// presented, storedHash, storedAt)
// switch {
// case errors.Is(err, credential.ErrExpired):
// return genericError() // "link invalid or expired"
// case errors.Is(err, credential.ErrInvalidCredential):
// return genericError() // same message; never reveal which it was
// case err != nil:
// return serverError()
// }
// db.DeleteResetToken(alice.ID) // single use, in the same transaction
//
// # What is fixed and what is open
//
// The cryptographic layer is closed: token entropy, HMAC-SHA256 with the
// library-managed pepper, base64-URL encoding, constant-time comparison,
// and the purpose || subject || token binding into the stored hash are
// all fixed. Weakening any of these produces a credential a stolen
// email can be spent against the wrong user or the wrong flow.
//
// The policy layer is open with secure defaults: the TTL (how long a
// token stays valid) is configurable within a 1-nanosecond-to-24-hour
// range, enforced by validateConfig. See docs/configuration.md for the
// principle.
package credential

import (
"crypto/hmac"
"crypto/rand" //nolint:gosec // CSPRNG draws for tokens
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"encoding/hex"
"fmt"
"hash"
"time"

"github.com/Glyndor/authcore"
"github.com/Glyndor/authcore/internal/clock"
)

// Compile-time assertion: *Credential must satisfy authcore.Module.
var _ authcore.Module = (*Credential)(nil)

const (
tokenLen = 32 // 256 bits of CSPRNG output per token
futureSkew = time.Minute
)

// Credential is the credential module.
//
// Construct one instance at application startup using New and share it
// across goroutines. Credential is safe for concurrent use after
// construction.
//
// It carries configuration only. Issue returns everything an issuance
// produces, so the module is safe to share across goroutines and never
// holds a raw token.
type Credential struct {
cfg Config
log authcore.Logger
secret []byte // HMAC-SHA256 pepper, sourced from the parent AuthCore
clock clock.Clock // injected; replaced by clock.Fixed in tests
}

// The module holds no per-issue state on purpose. An earlier draft kept the
// most recent Token and Hash on this struct, which made two concurrent Issue
// calls a data race (the race detector flags it) and left the raw token, the
// one secret the caller must show exactly once, alive in memory for as long
// as the module. Issue returns everything the caller needs.

// Issued is the result of Issue.
//
// Show Token to the user EXACTLY ONCE, typically by embedding it in a
// single-use email link. Persist Hash and the issuance timestamp together;
// pass them back to Verify at redemption time. The mapping between Token
// and Hash is 1:1 and irreversible: Hash cannot be inverted to recover
// Token.
type Issued struct {
// Token is the raw token. Put this in the email link; show it once.
Token string
// Hash is what the caller stores. It is a keyed HMAC-SHA256 hex digest
// that binds Token to the purpose and subject it was minted for.
Hash string
}

// New creates a Credential module.
//
// cfg is optional. Omit it, or pass a zero-value Config, to apply the
// safe default (TTL=1 hour):
//
// cred, err := credential.New(auth)
// cred, err := credential.New(auth, credential.DefaultConfig())
// cred, err := credential.New(auth, credential.Config{TTL: 15 * time.Minute})
//
// The module reads the parent AuthCore's logger, refresh secret, and
// timezone; it generates no key material of its own.
func New(p authcore.Provider, cfg ...Config) (*Credential, error) {
var resolved Config
if len(cfg) > 0 {
resolved = applyDefaults(cfg[0])
} else {
resolved = DefaultConfig()
}
if err := validateConfig(resolved); err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidConfig, err)
}

c := &Credential{
cfg: resolved,
log: p.Logger(),
secret: p.Keys().RefreshSecret(),
clock: clock.New(p.Config().Timezone),
}
c.log.Info("credential: module initialised (ttl=%s)", resolved.TTL)
return c, nil
}

// Name returns the module's unique identifier. It implements authcore.Module.
func (c *Credential) Name() string { return "credential" }

// Issue mints a fresh credential token bound to purpose and subject, and
// returns the raw token (for the email link) alongside the hash (for
// storage). Both purpose and subject are required, because an unbound
// token is
// the failure this module exists to prevent.
//
// The returned Issued is the only place the raw token appears. The module
// keeps no copy: it is safe to call from several goroutines at once, and
// the token does not outlive what the caller does with it.
//
// Errors:
//
// credential.ErrEmptyPurpose - purpose is ""
// credential.ErrEmptySubject - subject is ""
func (c *Credential) Issue(purpose, subject string) (*Issued, error) {
if purpose == "" {
return nil, ErrEmptyPurpose
}
if subject == "" {
return nil, ErrEmptySubject
}

tokenBytes := make([]byte, tokenLen)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, fmt.Errorf("credential: generate token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(tokenBytes)
hash := c.computeHash(purpose, subject, token)

c.log.Debug("credential: issued (purpose=%q, subject=%q)", purpose, subject)

return &Issued{Token: token, Hash: hash}, nil
}

// Verify checks a presented token against a stored hash for the given
// purpose and subject, with expiry enforced against issuedAt.
//
// purpose and subject must be the same strings that were passed to Issue.
// A mismatched purpose or subject produces a different hash and therefore
// a clean failure, so the module cannot redeem a "reset" token as an
// "activate" token, nor one for alice@example.com as one for bob@example.com.
//
// issuedAt must be the timestamp the caller stored alongside the hash at
// Issue time. The token is rejected as expired when:
//
// - clock.Now() is more than Config.TTL past issuedAt, or
// - issuedAt is more than one minute in the future (a backwards-running
// caller clock must not extend a token's life).
//
// Errors:
//
// credential.ErrInvalidCredential - token does not match the stored hash
// credential.ErrExpired - hash matched but issuedAt is outside
// the TTL window
//
// The caller MUST return the same generic message ("link invalid or
// expired") for both errors. Distinguishing them tells an attacker that a
// token existed. Compare, then check expiry; both run on every call so
// wall-clock time does not reveal whether the token was unknown.
func (c *Credential) Verify(purpose, subject, token, storedHash string, issuedAt time.Time) error {
// Always recompute the hash and run the constant-time comparison,
// even if a later check would reject the call anyway. This is what
// keeps the wall-clock timing of Verify independent of whether the
// token existed.
candidate := c.computeHash(purpose, subject, token)
matched := subtle.ConstantTimeCompare([]byte(candidate), []byte(storedHash)) == 1

elapsed := c.clock.Now().Sub(issuedAt)
expired := elapsed > c.cfg.TTL || elapsed < -futureSkew

if !matched {
return ErrInvalidCredential
}
if expired {
return ErrExpired
}
return nil
}

// computeHash returns the keyed HMAC-SHA256 hex digest of purpose, subject
// and token, each length-prefixed with a big-endian uint32.
//
// The length prefix is what makes the encoding unambiguous, and it is not
// interchangeable with a separator byte. An earlier draft joined the fields
// with a 0x00 byte, which works only while no field can contain that byte,
// and a Go string can: (purpose="a\x00b", subject="c") and (purpose="a",
// subject="b\x00c") hashed identically, so a caller whose subject came from
// untrusted input could redeem a token minted for a different pair. That is
// the exact property this module exists to provide. Prefixing by length
// depends on no assumption about the contents.
//
// Both Issue and Verify call through here, so a mismatched purpose or
// subject at Verify time produces a different candidate hash and a clean
// comparison failure.
func (c *Credential) computeHash(purpose, subject, token string) string {
mac := hmac.New(sha256.New, c.secret)
writeField(mac, purpose)
writeField(mac, subject)
writeField(mac, token)
return hex.EncodeToString(mac.Sum(nil))
}

// writeField writes s to h prefixed by its length as a big-endian uint32.
func writeField(h hash.Hash, s string) {
var n [4]byte
binary.BigEndian.PutUint32(n[:], uint32(len(s)))
_, _ = h.Write(n[:])
_, _ = h.Write([]byte(s))
}
Loading
Loading