snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-16 01:08:34 +05:00
commit 9456a4e922
2560 changed files with 120911 additions and 0 deletions

266
oryx/cookiex/cookiex.go Normal file
View File

@@ -0,0 +1,266 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
// Package cookiex encodes authenticated, encrypted HTTP cookies. Values are
// sealed with the Ory-wide AEAD (see ory/x/aead) under keys derived from the
// configured secrets with HKDF-SHA256, and bound to their purpose and cookie
// name. The package replaces the gorilla/securecookie-based cookie stores; a
// decode-only fallback reads cookies minted by those stores until they have
// expired.
package cookiex
import (
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
"github.com/ory/x/aead"
)
const (
// formatPrefix marks the versioned wire format. A dot is not part of the
// base64url alphabet, so legacy securecookie values can never collide
// with the prefix.
formatPrefix = "v1."
// kdfInfo domain-separates the HKDF key derivation.
kdfInfo = "ory/x/cookiex/v1"
// aadPrefix domain-separates the additional authenticated data.
aadPrefix = "ory/x/cookiex/v1"
// defaultMaxAge matches the gorilla/securecookie default that the
// previous cookie stores relied on.
defaultMaxAge = 30 * 24 * time.Hour
// maxCookieValueLength is the browser limit that securecookie also
// enforced.
maxCookieValueLength = 4096
)
// ErrInvalidCookie is returned when a cookie with the requested name is
// present but none of its values could be decoded (or none matched).
var ErrInvalidCookie = errors.New("cookiex: cookie could not be decoded")
type (
// Option configures a Codec.
Option func(*config)
config struct {
maxAge time.Duration
legacyKeyPairs [][]byte
legacyEncode bool
}
)
// WithMaxAge overrides how old a cookie may be before decoding rejects it.
// The default is 30 days; zero disables the check. The value must not be
// negative.
func WithMaxAge(d time.Duration) Option {
return func(c *config) { c.maxAge = d }
}
// Codec seals and opens cookie values of type T. T is serialized as JSON.
// A Codec is safe for concurrent use.
type Codec[T any] struct {
purpose string
keys [][32]byte
maxAge time.Duration
legacy legacyState
now func() time.Time
}
// New returns a codec for the given purpose. The purpose is bound into the
// ciphertext and used as the metric label; it must be a short constant like
// "kratos/session". Because the purpose is embedded in the additional
// authenticated data, it must be non-empty and must not contain a pipe
// character. The codec seals with a key derived from the first secret
// and opens with keys derived from any of them, so secrets rotate by
// prepending a new one.
func New[T any](purpose string, secrets [][]byte, opts ...Option) (*Codec[T], error) {
if purpose == "" || strings.Contains(purpose, "|") {
return nil, errors.New("cookiex: purpose must be non-empty and must not contain a pipe character")
}
if len(secrets) == 0 {
return nil, errors.New("cookiex: at least one secret is required")
}
cfg := config{maxAge: defaultMaxAge}
for _, opt := range opts {
opt(&cfg)
}
if cfg.maxAge < 0 {
return nil, errors.New("cookiex: max age must not be negative")
}
if cfg.legacyEncode && len(cfg.legacyKeyPairs) == 0 {
return nil, errors.New("cookiex: legacy encode requires legacy key pairs")
}
keys := make([][32]byte, len(secrets))
for i, secret := range secrets {
key, err := hkdf.Key(sha256.New, secret, nil, kdfInfo, 32)
if err != nil {
return nil, errors.Wrap(err, "cookiex: cannot derive key")
}
keys[i] = [32]byte(key)
}
return &Codec[T]{
purpose: purpose,
keys: keys,
maxAge: cfg.maxAge,
legacy: newLegacyState(cfg, cfg.maxAge),
now: time.Now,
}, nil
}
// envelope wraps the JSON payload with the seal time so decoding can enforce
// the max age.
type envelope struct {
IssuedAt int64 `json:"iat"`
Values json.RawMessage `json:"v"`
}
// aad binds a ciphertext to this codec's purpose and the cookie name, so a
// sealed value cannot be replayed as a different cookie or in a different
// context, even under the same key.
func (c *Codec[T]) aad(name string) []byte {
return []byte(aadPrefix + "|" + c.purpose + "|" + name)
}
func (c *Codec[T]) seal(name string, value T) (string, error) {
payload, err := json.Marshal(value)
if err != nil {
return "", errors.Wrap(err, "cookiex: cannot marshal cookie value")
}
plaintext, err := json.Marshal(envelope{IssuedAt: c.now().Unix(), Values: payload})
if err != nil {
return "", errors.Wrap(err, "cookiex: cannot marshal envelope")
}
a, err := aead.New(c.keys[0])
if err != nil {
return "", errors.Wrap(err, "cookiex: cannot create AEAD")
}
// The nonce is prepended to the ciphertext. AEADs that manage the nonce
// internally report a nonce size of zero, so this also covers them.
nonce := make([]byte, a.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", errors.Wrap(err, "cookiex: cannot generate nonce")
}
sealed := a.Seal(nonce, nonce, plaintext, c.aad(name))
return formatPrefix + base64.RawURLEncoding.EncodeToString(sealed), nil
}
func (c *Codec[T]) open(name, value string) (T, error) {
var zero T
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, formatPrefix))
if err != nil {
return zero, errors.WithStack(ErrInvalidCookie)
}
for _, key := range c.keys {
a, err := aead.New(key)
if err != nil {
return zero, errors.Wrap(err, "cookiex: cannot create AEAD")
}
if len(raw) < a.NonceSize() {
continue
}
plaintext, err := a.Open(nil, raw[:a.NonceSize()], raw[a.NonceSize():], c.aad(name))
if err != nil {
continue
}
var env envelope
if err := json.Unmarshal(plaintext, &env); err != nil {
return zero, errors.WithStack(ErrInvalidCookie)
}
if c.maxAge > 0 && c.now().Sub(time.Unix(env.IssuedAt, 0)) > c.maxAge {
return zero, errors.WithStack(ErrInvalidCookie)
}
var out T
if err := json.Unmarshal(env.Values, &out); err != nil {
return zero, errors.WithStack(ErrInvalidCookie)
}
return out, nil
}
return zero, errors.WithStack(ErrInvalidCookie)
}
// Set seals value into cookie.Value and writes the cookie to w. All other
// attributes (name, path, domain, max-age, secure, http-only, same-site) must
// be set by the caller on the cookie.
func (c *Codec[T]) Set(w http.ResponseWriter, cookie *http.Cookie, value T) error {
var encoded string
var err error
if c.legacy.encode {
encoded, err = c.sealLegacy(cookie.Name, value)
} else {
encoded, err = c.seal(cookie.Name, value)
}
if err != nil {
return err
}
if len(encoded) > maxCookieValueLength {
return errors.Errorf("cookiex: encoded cookie %q exceeds %d bytes", cookie.Name, maxCookieValueLength)
}
cookie.Value = encoded
http.SetCookie(w, cookie)
return nil
}
// Get returns the value of the first cookie named name that decodes. It
// returns http.ErrNoCookie when no cookie with that name is present, and
// ErrInvalidCookie when none of the present values decode.
func (c *Codec[T]) Get(r *http.Request, name string) (T, error) {
return c.GetMatching(r, name, nil)
}
// GetMatching returns the value of the first cookie named name that decodes
// and matches. Requests can carry multiple cookies with the same name, for
// example one from a parent and one from a sub domain; match selects among
// them. A nil match matches any value. It returns http.ErrNoCookie when no
// cookie with that name is present, and ErrInvalidCookie when none of the
// present values decode and match.
func (c *Codec[T]) GetMatching(r *http.Request, name string, match func(T) bool) (T, error) {
if match == nil {
match = func(T) bool { return true }
}
var zero T
found := false
for _, ck := range r.Cookies() {
if ck.Name != name {
continue
}
found = true
// Refuse oversized values before doing any decode work, matching the
// limit Set enforces and securecookie's own decode-side check.
if len(ck.Value) > maxCookieValueLength {
continue
}
var value T
var err error
if strings.HasPrefix(ck.Value, formatPrefix) {
value, err = c.open(name, ck.Value)
} else {
value, err = c.openLegacy(name, ck.Value)
}
if err != nil {
continue
}
if match(value) {
return value, nil
}
}
if !found {
return zero, errors.WithStack(http.ErrNoCookie)
}
return zero, errors.WithStack(ErrInvalidCookie)
}
// Expire overwrites the cookie with an expired empty one, deleting it from
// the client.
func (c *Codec[T]) Expire(w http.ResponseWriter, cookie *http.Cookie) {
cookie.Value = ""
cookie.MaxAge = -1
cookie.Expires = time.Unix(1, 0)
http.SetCookie(w, cookie)
}

View File

@@ -0,0 +1,136 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package cookiex
// This file isolates the gorilla/securecookie dependency. It exists so that
// cookies sealed by the previous securecookie-based stores keep decoding
// during the migration, and (behind WithLegacyEncode) so that freshly minted
// cookies stay readable by old pods during the stage-1 rollout. Delete it,
// and the securecookie dependency, once ory_legacy_cookie_decodes_total has
// flatlined fleet-wide for a full max-cookie-lifespan window.
import (
"crypto/sha256"
"encoding/json"
"time"
"github.com/gorilla/securecookie"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// legacyDecodes counts cookies that were successfully decoded via the legacy
// securecookie fallback. It is registered on the Prometheus default
// registerer, which Kratos and Hydra expose on /metrics/prometheus.
var legacyDecodes = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ory_legacy_cookie_decodes_total",
Help: "Number of cookies successfully decoded via the legacy securecookie fallback, by codec purpose.",
}, []string{"purpose"})
// LegacySecureCookieKeyPairs derives the (hashKey, blockKey) pairs the
// securecookie-based cookie stores used: each secret signs, and its SHA-256
// digest encrypts.
func LegacySecureCookieKeyPairs(secrets [][]byte) [][]byte {
pairs := make([][]byte, 0, 2*len(secrets))
for _, secret := range secrets {
block := sha256.Sum256(secret)
pairs = append(pairs, secret, block[:])
}
return pairs
}
// WithLegacyKeyPairs enables decoding cookies in the legacy securecookie
// format, sealed under the given (hashKey, blockKey) pairs. While this option
// is in use, T must be unmarshalable from a flat JSON object with string
// values; WithLegacyEncode additionally requires T to marshal to one. Remove
// once all cookies sealed in the legacy format have expired.
func WithLegacyKeyPairs(pairs ...[]byte) Option {
return func(c *config) { c.legacyKeyPairs = pairs }
}
type legacyState struct {
codecs []securecookie.Codec
encode bool
}
func newLegacyState(cfg config, maxAge time.Duration) legacyState {
codecs := securecookie.CodecsFromPairs(cfg.legacyKeyPairs...)
for _, codec := range codecs {
if sc, ok := codec.(*securecookie.SecureCookie); ok {
sc.MaxAge(int(maxAge / time.Second))
}
}
return legacyState{codecs: codecs, encode: cfg.legacyEncode}
}
// openLegacy decodes a securecookie-sealed value and bridges it into T via
// its JSON representation: legacy payloads are flat string-to-string maps, so
// string, time.Time, and UUID fields all round-trip through their JSON string
// forms.
func (c *Codec[T]) openLegacy(name, value string) (T, error) {
var zero T
if len(c.legacy.codecs) == 0 {
return zero, errors.WithStack(ErrInvalidCookie)
}
values := make(map[any]any)
if err := securecookie.DecodeMulti(name, value, &values, c.legacy.codecs...); err != nil {
return zero, errors.WithStack(ErrInvalidCookie)
}
flat := make(map[string]string, len(values))
for k, v := range values {
key, keyOK := k.(string)
val, valOK := v.(string)
if !keyOK || !valOK {
return zero, errors.WithStack(ErrInvalidCookie)
}
flat[key] = val
}
bridge, err := json.Marshal(flat)
if err != nil {
return zero, errors.Wrap(err, "cookiex: cannot bridge legacy cookie")
}
var out T
if err := json.Unmarshal(bridge, &out); err != nil {
return zero, errors.WithStack(ErrInvalidCookie)
}
legacyDecodes.WithLabelValues(c.purpose).Inc()
return out, nil
}
// WithLegacyEncode makes Set seal in the legacy securecookie format under the
// first legacy key pair, so pods that only understand the legacy format can
// read freshly minted cookies during a rolling deploy. Requires
// WithLegacyKeyPairs. This is stage 1 of the rollout; a follow-up removes the
// option, flipping encoding to the v1 format. JSON null values inside the
// payload are coerced to empty strings by the bridge; do not use pointer-typed
// fields while legacy encode is enabled.
func WithLegacyEncode() Option {
return func(c *config) { c.legacyEncode = true }
}
// sealLegacy bridges T through its JSON representation into the flat
// string-to-string map that the securecookie stores used.
func (c *Codec[T]) sealLegacy(name string, value T) (string, error) {
buf, err := json.Marshal(value)
if err != nil {
return "", errors.Wrap(err, "cookiex: cannot marshal cookie value")
}
var flat map[string]string
if err := json.Unmarshal(buf, &flat); err != nil {
return "", errors.Wrap(err, "cookiex: payload must be a flat JSON object with string values while legacy encode is enabled")
}
if flat == nil {
return "", errors.New("cookiex: payload must be a flat JSON object with string values while legacy encode is enabled")
}
values := make(map[any]any, len(flat))
for k, v := range flat {
values[k] = v
}
encoded, err := securecookie.EncodeMulti(name, values, c.legacy.codecs[0])
if err != nil {
return "", errors.Wrap(err, "cookiex: cannot encode legacy cookie")
}
return encoded, nil
}