update README

This commit is contained in:
juancwu 2026-04-29 02:17:52 +00:00
commit 84bc06d861

281
README.md
View file

@ -1,3 +1,284 @@
# ficha # ficha
Fichas are opaque tokens carrying hidden permissions, named after the historical access tokens used for entry and identification. 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
```sh
go get git.juancwu.dev/juancwu/ficha
```
Requires Go 1.26 or later.
## Quick start
```go
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
```go
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
```go
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
```go
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`
```go
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
```go
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
```go
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:
```go
// 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`:
```go
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:
```go
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](./LICENSE).