# 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.
