284 lines
8.2 KiB
Markdown
284 lines
8.2 KiB
Markdown
# 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
|
|
|
|
```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).
|