ficha/README.md
2026-04-29 02:17:52 +00:00

8.2 KiB

ficha

Fichas are opaque tokens carrying hidden permissions, named after the historical access tokens used for entry and identification.

The name comes from Spanish ficha: a token used for entry, identification, or access.

Features

  • Opaque tokens. Contents are encrypted with XChaCha20-Poly1305 (AEAD). Token holders cannot read or modify their permissions.
  • Consumer-defined permissions. Permissions are arbitrary strings — use any convention (orders:read, admin, team:42:write, etc.).
  • Composable permission checks. Has, HasAll, HasAny, HasNone, RequiresAll, plus a matcher API for All/Any/Not composition.
  • Free-form metadata. Carry consumer-defined data alongside permissions.
  • Key rotation. Add new keys, promote them active, retire old ones — outstanding tokens keep working until they expire.
  • Pluggable revocation. A small interface lets you back revocation with Redis, Postgres, or anything else. An in-memory reference implementation is included.
  • No database required for the core. Storage is the consumer's concern.
  • Zero third-party runtime dependencies beyond golang.org/x/crypto.

Installation

go get git.juancwu.dev/juancwu/ficha

Requires Go 1.26 or later.

Quick start

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	"git.juancwu.dev/juancwu/ficha"
)

type Meta struct {
	UserID string `json:"user_id"`
	Tier   string `json:"tier"`
}

func main() {
	// 1. Generate a signing key (store this somewhere safe in production).
	key, err := ficha.GenerateKey()
	if err != nil {
		log.Fatal(err)
	}

	// 2. Build a key ring and an issuer.
	ring, err := ficha.NewKeyRing("k1", key)
	if err != nil {
		log.Fatal(err)
	}
	store := ficha.NewMemoryRevocationStore()
	iss, err := ficha.NewIssuer(ring, store)
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()

	// 3. Issue a token.
	token, err := iss.Issue(ctx,
		[]string{"orders:read", "orders:write"},
		Meta{UserID: "u_42", Tier: "pro"},
		15*time.Minute,
	)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println("Token:", token)

	// 4. Validate it.
	tok, err := iss.Validate(ctx, token)
	if err != nil {
		log.Fatal(err)
	}

	if tok.HasAll("orders:read", "orders:write") {
		fmt.Println("authorized")
	}

	var meta Meta
	if err := tok.UnmarshalData(&meta); err != nil {
		log.Fatal(err)
	}
	fmt.Printf("user: %+v\n", meta)

	// 5. Revoke it (optional).
	if err := iss.Revoke(ctx, token); err != nil {
		log.Fatal(err)
	}
}

Token format

A ficha token is a string of the form: v1.<keyID>.<base64url(none||ciphertext)>.

The version and key ID are in plaintext (the verifier needs the key ID to pick a decryption key) but they are bound to the ciphertext through AAD — swapping or modifying them causes decryption to fail.

The encrypted body contains:

  • A unique token ID (used for revocation lookups)
  • Issued-at and expiry timestamps
  • The consumer's permissions ([]string)
  • The consumer's free-form data blob (any JSON-encodable value)

Token holders see only the opaque ciphertext. They cannot enumerate the permissions or read the metadata.

API overview

Construction

key, _ := ficha.GenerateKey()                  // 32-byte XChaCha20 key
ring, _ := ficha.NewKeyRing("k1", key)         // initial key, marked active
store := ficha.NewMemoryRevocationStore()      // or your own RevocationStore
iss, _ := ficha.NewIssuer(ring, store)         // optionally with WithClock(...)

store may be nil to disable revocation entirely. Tokens still expire.

Issuing

token, err := iss.Issue(ctx, perms, data, ttl)
  • perms []string — permission strings; may be nil
  • data any — anything JSON-encodable; may be nil
  • ttl time.Duration — must be positive

Validating

tok, err := iss.Validate(ctx, token)

Returns *Token on success. Possible errors:

  • ficha.ErrInvalidToken — malformed, tampered, or undecryptable
  • ficha.ErrUnknownKey — key ID not in the ring
  • ficha.ErrExpired — past the token's expiry
  • ficha.ErrRevoked — present in the revocation store

Permission checks on *Token

tok.Has("orders:read")                         // single check
tok.HasAll("orders:read", "orders:write")      // every perm; empty = true
tok.RequiresAll(perms...)                      // every perm; empty = false
tok.HasAny("admin", "owner")                   // at least one
tok.HasNone("banned", "suspended")             // none of these

// Composable matcher API:
tok.Check(ficha.All(
    "orders:read",
    ficha.Any("admin", "billing-manager"),
    ficha.Not("readonly"),
))

HasAll returns true on empty input (vacuous truth — composes well with dynamic lists). Use RequiresAll when an empty list should fail closed.

Other *Token methods

tok.ID()             // unique token identifier
tok.IssuedAt()       // time.Time
tok.ExpiresAt()      // time.Time
tok.Permissions()    // []string (copy, safe to mutate)
tok.UnmarshalData(&v) // decode the data blob into v

Revocation

err := iss.Revoke(ctx, token)

Marks the token's ID as revoked until its natural expiry. The revocation store decides how long to retain the entry — typically until the original token would have expired.

Key rotation

A safe, three-phase rotation:

// 1. Add the new key. Existing tokens unaffected.
newKey, _ := ficha.GenerateKey()
ring.Add("k2", newKey)

// 2. Promote the new key. New tokens are now signed with k2.
//    Tokens signed with k1 still validate.
ring.SetActive("k2")

// 3. After the longest possible TTL has passed, retire the old key.
ring.Remove("k1")

Skip steps in distributed deployments at your peril — adding the key everywhere before promoting it ensures every server can validate tokens issued by any other server during the rollout window.

Custom revocation stores

Implement RevocationStore:

type RevocationStore interface {
    IsRevoked(ctx context.Context, tokenID string) (bool, error)
    Revoke(ctx context.Context, tokenID string, until time.Time) error
}

Implementations must be safe for concurrent use. The until parameter is the token's natural expiry — backends may discard entries after that time since expired tokens fail validation regardless.

A Redis-backed implementation is straightforward — Redis's native TTL handles cleanup automatically:

func (r *RedisStore) Revoke(ctx context.Context, id string, until time.Time) error {
    return r.client.Set(ctx, "ficha:rev:"+id, "1", time.Until(until)).Err()
}

func (r *RedisStore) IsRevoked(ctx context.Context, id string) (bool, error) {
    n, err := r.client.Exists(ctx, "ficha:rev:"+id).Result()
    return n > 0, err
}

Security notes

The encryption key is the only secret. Anyone with the key can issue valid tokens and read existing ones. Store it the way you'd store any production secret — environment variables, KMS, a vault, etc. Never commit it, never log it, never serialize it into a token.

Key size and algorithm. ficha uses XChaCha20-Poly1305 with a 32-byte key and a 24-byte random nonce. The 24-byte nonce makes random generation collision-safe — there is no need to track nonces externally.

Encryption hides permission strings; it is not a primary access control. The real authorization happens server-side after decryption. Encryption adds defense-in-depth (a stolen token doesn't reveal its scope) but the core security comes from the key being secret and the AEAD construction being unforgeable.

Token IDs are 128-bit unguessable values generated from crypto/rand, suitable for use as revocation lookup keys.

Short TTLs reduce revocation pressure. If your tokens expire in minutes, you may not need a revocation store at all.

Limitations

  • Symmetric encryption only. The same key issues and validates — useful for service-to-service auth or single-issuer setups, not for federated systems where verifiers shouldn't be able to mint tokens.
  • JSON payload encoding. Larger than msgpack or protobuf would produce, but debuggable, dependency-free, and good enough for typical token sizes.
  • No claims standard. ficha does not implement JWT, PASETO, or any other standard. Tokens are interoperable only between systems running ficha.

License

Apache 2.0 — see LICENSE.