From 84bc06d8612c8394dec79a9c6dfd7b06f12398a7 Mon Sep 17 00:00:00 2001 From: juancwu Date: Wed, 29 Apr 2026 02:17:52 +0000 Subject: [PATCH] update README --- README.md | 283 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 282 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ee0f63..6f61820 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,284 @@ # ficha -Fichas are opaque tokens carrying hidden permissions, named after the historical access tokens used for entry and identification. \ No newline at end of file +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..`. + +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).