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 forAll/Any/Notcomposition. - 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 nildata any— anything JSON-encodable; may be nilttl time.Duration— must be positive
Validating
tok, err := iss.Validate(ctx, token)
Returns *Token on success. Possible errors:
ficha.ErrInvalidToken— malformed, tampered, or undecryptableficha.ErrUnknownKey— key ID not in the ringficha.ErrExpired— past the token's expiryficha.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.
fichadoes not implement JWT, PASETO, or any other standard. Tokens are interoperable only between systems runningficha.
License
Apache 2.0 — see LICENSE.