update README
This commit is contained in:
parent
bc1c2f97b0
commit
84bc06d861
1 changed files with 282 additions and 1 deletions
283
README.md
283
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.
|
||||
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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue