Packages
xentitlement
0.1.0
Lightweight, reusable Elixir library for RSA-signed entitlement token validation and creation. Stateless cryptographic layer for distributed microservices.
Current section
Files
Jump to
Current section
Files
xentitlement
README.md
README.md
# Xentitlement
A lightweight, reusable Elixir library for RSA-signed entitlement token validation.
**Xentitlement** provides a minimal cryptographic layer that validates JSON tokens signed with RSA-SHA256. It's designed to be shared across multiple applications that need to verify signed authorization tokens without coupling to a specific service's authentication system.
## Features
- **RSA-SHA256 signatures** — Asymmetric cryptography for secure token verification
- **Canonical JSON** — Deterministic serialization prevents tampering
- **Stateless validation** — No state, configuration, or database required
- **Zero dependencies** (except Jason for JSON parsing)
- **100% test coverage** — Comprehensive test suite included
## Installation
Add to your project's `mix.exs`:
```elixir
def deps do
[
{:xentitlement, path: "../xentitlement"}
]
end
```
Then run `mix deps.get`.
## Quick Start
### Create a Token (with Private Key)
```elixir
alias Xentitlement
claims = %{
"entitlement_id" => "967376a7-6a33-4bc8-846c-ca443c5c56eb",
"user_id" => "user-123",
"operation" => "file_upload",
"expires_at" => "2026-12-31"
}
{:ok, token_json} = Xentitlement.sign_token(claims, private_key_pem)
# Send token_json to client (e.g., in response header or body)
```
### Validate a Token (with Public Key)
```elixir
alias Xentitlement
# Validate a token from an HTTP header
case Xentitlement.extract_from_header(conn) do
{:ok, token} ->
case Xentitlement.validate(token, public_key_pem, entitlement_id) do
{:ok, claims} ->
# Authorization granted; use claims
handle_authorized(claims)
{:error, reason} ->
# Validation failed
reject_request(reason)
end
{:error, :missing} ->
reject_request("no_token")
end
```
## API
### Sign Operations (Private Key)
#### `Xentitlement.sign_token(claims, private_key_pem)`
Signs a claims map and returns a JSON string with RSA signature.
**Parameters:**
- `claims` (map) — Authorization data (keys should be strings)
- `private_key_pem` (binary) — RSA private key in PEM format
**Returns:**
- `{:ok, token_json}` — Signed token as JSON string with signature field
- `{:error, :invalid_key}` — Private key could not be decoded
- `{:error, :signing_failed}` — Signature operation failed
#### `Xentitlement.sign_canonical_json(canonical_json, private_key_pem)`
Signs canonical JSON with RSA private key (low-level).
**Parameters:**
- `canonical_json` (binary) — JSON string to sign
- `private_key_pem` (binary) — RSA private key in PEM format
**Returns:**
- `{:ok, signature}` — Base64-encoded RSA signature
- `{:error, :invalid_key}` — Key could not be decoded
### Validate Operations (Public Key)
#### `Xentitlement.validate(token_json, public_key_pem, entitlement_id)`
Validates an entitlement token.
**Parameters:**
- `token_json` (binary) — JSON string containing signed token
- `public_key_pem` (binary) — RSA public key in PEM format
- `entitlement_id` (binary) — Expected entitlement ID (UUID)
**Returns:**
- `{:ok, claims}` — Token is valid; contains all fields from the token
- `{:error, :invalid_json}` — Token is not valid JSON
- `{:error, :invalid_signature}` — RSA signature verification failed
- `{:error, :invalid_claims}` — Missing or mismatched entitlement_id, or invalid signature field
#### `Xentitlement.extract_from_header(conn, header_name \\ "x-entitlement")`
Extracts entitlement token from HTTP header.
**Parameters:**
- `conn` (Plug.Conn) — HTTP connection
- `header_name` (binary, optional) — HTTP header name (default: "x-entitlement")
**Returns:**
- `{:ok, token}` — Token extracted
- `{:error, :missing}` — Header not present
- `{:error, :multiple}` — Multiple headers present
## Token Format
An entitlement token is a JSON object:
```json
{
"entitlement_id": "967376a7-6a33-4bc8-846c-ca443c5c56eb",
"user_id": "user-123",
"operation": "file_upload",
"expires_at": "2026-12-31",
"signature": "base64-encoded-rsa-sha256-signature",
"custom_field_1": "value1",
"custom_field_2": 42
}
```
### Signature Computation
The signature is computed over the **canonical JSON** representation:
1. Remove the `signature` field
2. Sort all keys alphabetically
3. Compact JSON encoding (no whitespace)
4. RSA-SHA256 sign the result
5. Base64-encode the signature bytes
This ensures that any tampering with the token is detected.
## Generating RSA Keys
### Generate Keys with OpenSSL
Generate a 2048-bit RSA keypair:
```bash
# Generate private key
openssl genrsa -out private_key.pem 2048
# Extract public key from private key
openssl rsa -in private_key.pem -pubout -out public_key.pem
```
### Using Keys in Elixir
Load keys from files:
```elixir
defmodule MyApp.Entitlements do
@doc "Load RSA keys from files"
def load_keys do
%{
private_key: File.read!("config/keys/private_key.pem"),
public_key: File.read!("config/keys/public_key.pem")
}
end
@doc "Or load from environment variables (recommended for production)"
def load_keys_from_env do
%{
private_key: System.fetch_env!("ENTITLEMENT_PRIVATE_KEY"),
public_key: System.fetch_env!("ENTITLEMENT_PUBLIC_KEY")
}
end
end
```
### Storing Keys Securely
**Production Best Practices:**
1. **Never commit private keys** to version control
2. **Use environment variables** (e.g., `ENTITLEMENT_PRIVATE_KEY`)
3. **Use a key management service** (e.g., AWS KMS, HashiCorp Vault)
4. **Restrict file permissions** on key files (chmod 600)
5. **Rotate keys regularly** according to your security policy
### Key Format Requirements
Keys must be in **PEM format** (Privacy Enhanced Mail):
```
# Private key format
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA...
...
-----END RSA PRIVATE KEY-----
# Public key format
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A...
...
-----END PUBLIC KEY-----
```
## Testing
Generate tokens for testing:
```elixir
alias Xentitlement.Test.EntitlementHelpers
keys = EntitlementHelpers.load_test_keys()
token_json = EntitlementHelpers.generate_valid_entitlement_json(
entitlement_id: "967376a7-6a33-4bc8-846c-ca443c5c56eb",
user_id: "user-123",
operation: "file_upload",
expires_at: "2026-12-31",
private_key: keys.private_key
)
{:ok, claims} = Xentitlement.validate(token_json, keys.public_key, entitlement_id)
```
## Architecture
```
xentitlement/
├── lib/
│ ├── xentitlement.ex # Public API (delegates)
│ └── xentitlement/entitlements.ex # Core validation logic
├── test/
│ ├── xentitlement_test.exs # Public API tests
│ ├── xentitlement/entitlements_test.exs # Validation logic tests
│ ├── support/entitlement_helpers.ex # Test fixtures
│ └── fixtures/*.pem # RSA test keys
└── CLAUDE.md # Development guide
```
## Usage in Services
### Signing Service (Generates Tokens)
The signing service has the RSA **private key** and generates signed tokens:
```elixir
def generate_presigned_url_token(user_id, file_name) do
claims = %{
"entitlement_id" => entitlement_id(),
"user_id" => user_id,
"operation" => "file_upload",
"file_name" => file_name,
"expires_at" => Date.utc_today() |> Date.add(1) |> Date.to_iso8601()
}
signature = sign_claims(claims) # Sign with private key
claims
|> Map.put("signature", signature)
|> Jason.encode!()
end
```
### Validating Service (Verifies Tokens)
The validating service has the RSA **public key** and verifies tokens:
```elixir
def verify_presigned_url(token_json) do
Xentitlement.validate(token_json, public_key_pem(), entitlement_id())
end
```
## Design Notes
### Why RSA instead of HMAC?
- RSA allows verification without sharing a secret key
- Multiple services can verify tokens with only the public key
- No need for a central key distribution system
### Why canonical JSON?
- Ensures the signature was computed over a deterministic representation
- Prevents ambiguity in JSON key ordering or whitespace
- Simplifies debugging and testing
### Why stateless?
- No database, cache, or session required for validation
- Xentitlement can run anywhere: web servers, workers, CLI tools
- Each service manages its own public keys independently
## Development
See `CLAUDE.md` for detailed development guidelines, testing requirements, and architecture decisions.
### Run Tests
```sh
mix test # Run all tests
mix test --cover # Generate coverage report
```
Coverage should be 100% (or very close — see CLAUDE.md for exceptions).
### Code Quality
```sh
mix format # Format code
mix compile --warnings-as-errors # Compile with strict checks
```
## License
BSD-3-Clause License. See [LICENSE](LICENSE) for details.
## Questions?
See `CLAUDE.md` for detailed FAQ and troubleshooting.