Add Cashu blind signature access tokens (NIP-XX draft)

Implements privacy-preserving bearer tokens for relay access control using
Cashu-style blind signatures. Tokens prove whitelist membership without
linking issuance to usage.

Features:
- BDHKE crypto primitives (HashToCurve, Blind, Sign, Unblind, Verify)
- Keyset management with weekly rotation
- Token format with kind permissions and scope isolation
- Generic issuer/verifier with pluggable authorization
- HTTP endpoints: POST /cashu/mint, GET /cashu/keysets, GET /cashu/info
- ACL adapter bridging ORLY's access control to Cashu AuthzChecker
- Stateless revocation via ACL re-check on each token use
- Two-token rotation for seamless renewal (max 2 weeks after blacklist)

Configuration:
- ORLY_CASHU_ENABLED: Enable Cashu tokens
- ORLY_CASHU_TOKEN_TTL: Token validity (default: 1 week)
- ORLY_CASHU_SCOPES: Allowed scopes (relay, nip46, blossom, api)
- ORLY_CASHU_REAUTHORIZE: Re-check ACL on each verification

Files:
- pkg/cashu/bdhke/: Core blind signature cryptography
- pkg/cashu/keyset/: Keyset management and rotation
- pkg/cashu/token/: Token format with kind permissions
- pkg/cashu/issuer/: Token issuance with authorization
- pkg/cashu/verifier/: Token verification with middleware
- pkg/interfaces/cashu/: AuthzChecker, KeysetStore interfaces
- pkg/bunker/acl_adapter.go: ORLY ACL integration
- app/handle-cashu.go: HTTP endpoints
- docs/NIP-XX-CASHU-ACCESS-TOKENS.md: Full specification

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 11:30:11 +02:00
parent 2eb523c161
commit ea4a54c5e7
18 changed files with 4129 additions and 0 deletions

View File

@@ -0,0 +1,390 @@
# NIP-XX: Cashu Access Tokens for Relay Authorization
`draft` `optional`
This NIP defines a protocol for relays to issue privacy-preserving access tokens using Cashu blind signatures. Tokens prove relay membership without linking issuance to usage, enabling spam protection while preserving user privacy.
## Motivation
Relays need to control access to prevent spam and abuse. Current approaches (NIP-42, NIP-98) require per-request authentication that links all user activity. Cashu blind signatures allow relays to issue bearer tokens that prove authorization without revealing which specific user is connecting.
This is particularly useful for:
- NIP-46 remote signing (bunker) access control
- Premium relay tiers
- Rate limit bypass for trusted users
- Any service requiring proof of relay membership
## Overview
1. Relay operates as a Cashu mint for its authorized users
2. Users authenticate via NIP-98 to obtain blinded signatures
3. Tokens specify permitted event kinds and expiry
4. Two-token rotation allows seamless renewal before expiry
5. Tokens are bearer credentials passed in HTTP/WebSocket headers
## Token Format
### Token Structure
```json
{
"k": "<keyset_id>",
"s": "<secret_hex>",
"c": "<signature_hex>",
"p": "<pubkey_hex>",
"e": <expiry_unix>,
"kinds": [0, 1, 3, 10002],
"kind_ranges": [[20000, 29999]],
"scope": "relay"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `k` | string | Keyset ID (hex) identifying the signing key |
| `s` | string | 32-byte random secret (hex) |
| `c` | string | 33-byte compressed signature point (hex) |
| `p` | string | 32-byte user pubkey (hex) |
| `e` | number | Unix timestamp when token expires |
| `kinds` | number[] | Explicit list of permitted event kinds |
| `kind_ranges` | number[][] | Ranges of permitted kinds as [min, max] pairs |
| `scope` | string | Token scope: "relay", "nip46", "blossom", or custom |
### Kind Permissions
Tokens specify which event kinds the bearer may publish:
- `kinds`: Explicit list of individual kinds (e.g., `[0, 1, 3]`)
- `kind_ranges`: Inclusive ranges (e.g., `[[20000, 29999]]` for ephemeral events)
A token permits a kind if it appears in `kinds` OR falls within any `kind_ranges` entry.
Special values:
- Empty `kinds` and `kind_ranges`: No write access (read-only token)
- `kinds: [-1]`: All kinds permitted (wildcard)
### Scopes
| Scope | Description |
|-------|-------------|
| `relay` | Standard relay WebSocket access (REQ, EVENT, COUNT) |
| `nip46` | NIP-46 remote signing / bunker access |
| `blossom` | Blossom media server access |
| `api` | HTTP API access |
Custom scopes may be defined by applications.
### Serialization
Tokens are serialized as:
```
cashuA<base64url(json)>
```
The `cashuA` prefix indicates version 1 of this specification.
## Keyset Management
### Keyset Structure
Relays maintain signing keysets that rotate periodically:
```json
{
"id": "<keyset_id>",
"pubkey": "<compressed_pubkey_hex>",
"active": true,
"created_at": 1735300000,
"expires_at": 1736510000
}
```
### Keyset ID Calculation
```
keyset_id = SHA256(compressed_pubkey)[0:7] as hex (14 characters)
```
### Rotation Policy
- **Active period**: 1 week (tokens can be issued)
- **Verification period**: 3 weeks (tokens can still be validated)
- **Total validity**: Active keyset + 2 previous keysets accepted
This ensures tokens issued at the end of an active period remain valid for their full lifetime.
## Two-Token Rotation
Users may hold up to two tokens:
| Token | State | Purpose |
|-------|-------|---------|
| Active | In use | Current authentication credential |
| Pending | Awaiting | Pre-fetched for seamless rotation |
### Rotation Flow
1. User obtains initial token (becomes Active)
2. When Active token reaches 50% lifetime, user requests new token (becomes Pending)
3. When Active token expires, Pending becomes Active
4. User requests new Pending token
5. Repeat
This ensures continuous access without authentication gaps.
### Blacklist Behavior
If a user is removed from the relay's whitelist:
- Active token continues working until expiry (max 1 week)
- Pending token continues working until expiry (max 1 week)
- **Maximum access after blacklist: 2 weeks**
New token requests will fail immediately upon blacklist.
## HTTP Endpoints
### Token Issuance
```http
POST /cashu/mint
Authorization: Nostr <base64_nip98_event>
Content-Type: application/json
{
"blinded_message": "<B_hex>",
"scope": "relay",
"kinds": [0, 1, 3, 7],
"kind_ranges": [[30000, 39999]]
}
```
**Response:**
```json
{
"blinded_signature": "<C_hex>",
"keyset_id": "<keyset_id>",
"expiry": 1736294400,
"pubkey": "<mint_pubkey_hex>"
}
```
The user must:
1. Generate random secret `x` and blinding factor `r`
2. Compute `Y = hash_to_curve(x)`
3. Compute `B_ = Y + r*G`
4. Send `B_` as `blinded_message`
5. Receive `C_` as `blinded_signature`
6. Compute `C = C_ - r*K` (K is mint pubkey)
7. Token is `(x, C)` with metadata
### Keyset Discovery
```http
GET /cashu/keysets
Response:
{
"keysets": [
{
"id": "0a1b2c3d4e5f67",
"pubkey": "02...",
"active": true,
"expires_at": 1736510000
}
]
}
```
### Token Info (Optional)
```http
GET /cashu/info
Response:
{
"name": "Relay Name",
"version": "NIP-XX/1",
"token_ttl": 604800,
"max_kinds": 100,
"supported_scopes": ["relay", "nip46", "blossom"]
}
```
## Authentication Headers
### WebSocket Upgrade
```http
GET / HTTP/1.1
Upgrade: websocket
X-Cashu-Token: cashuA<base64url>
```
### HTTP Requests
```http
GET /api/resource HTTP/1.1
Authorization: Cashu cashuA<base64url>
```
Or as dedicated header:
```http
X-Cashu-Token: cashuA<base64url>
```
### NIP-46 Integration
For NIP-46 bunker connections, the token is passed in the WebSocket upgrade:
```http
GET /nip46 HTTP/1.1
Upgrade: websocket
X-Cashu-Token: cashuA<base64url>
```
The bunker verifies:
1. Token signature is valid
2. Token has not expired
3. Token scope is "nip46"
4. User pubkey in token matches NIP-46 connect pubkey
## Cryptographic Details
### Blind Diffie-Hellman Key Exchange (BDHKE)
Uses secp256k1 curve with Cashu's hash-to-curve:
```
hash_to_curve(message):
msg_hash = SHA256("Secp256k1_HashToCurve_Cashu_" || message)
for counter in 0..65536:
hash = SHA256(msg_hash || counter_le32)
point = try_parse("02" || hash)
if point is valid:
return point
fail
```
**Blinding:**
```
Y = hash_to_curve(secret)
r = random_scalar()
B_ = Y + r*G
```
**Signing (mint):**
```
C_ = k * B_
```
**Unblinding (user):**
```
C = C_ - r*K
```
**Verification (mint):**
```
valid = (C == k * hash_to_curve(secret))
```
## Verification Flow
When relay receives a token:
1. Parse token from header
2. Find keyset by ID (must be active or recently expired)
3. Verify: `C == k * hash_to_curve(secret)`
4. Check: `expiry > now`
5. Check: scope matches service
6. Check: requested kind in `kinds` or `kind_ranges`
7. **Optional**: Re-check user pubkey against current ACL
Step 7 provides "stateless revocation" - tokens become invalid immediately when user is removed from ACL, not just when they expire.
## Security Considerations
### Token as Bearer Credential
Tokens are bearer credentials. Compromise allows impersonation until expiry. Mitigations:
- Short TTL (1 week recommended)
- TLS for all transport
- Secure client storage
### Privacy Properties
- **Unlinkability**: Relay cannot link token issuance to token use
- **No tracking**: Different secrets prevent correlation across tokens
- **Pubkey binding**: Token is bound to user's Nostr pubkey
### Keyset Compromise
If keyset private key is compromised:
- Rotate immediately (new keyset)
- Old keyset enters verification-only mode
- Tokens issued by compromised keyset expire naturally (max 3 weeks)
### Replay Prevention
- Tokens have expiry timestamps
- Optional: Relay tracks used secrets (adds state, breaks unlinkability)
- Scope prevents cross-service replay
## Example Flow
```
1. Alice wants NIP-46 bunker access to relay.example.com
2. Alice authenticates via NIP-98:
POST /cashu/mint
Authorization: Nostr <signed_kind_27235>
{"blinded_message": "02abc...", "scope": "nip46"}
3. Relay checks:
- NIP-98 signature valid
- Alice's pubkey in whitelist with write access
4. Relay responds:
{"blinded_signature": "03def...", "keyset_id": "a1b2c3...", "expiry": 1736294400}
5. Alice unblinds signature, constructs token
6. Alice connects to bunker:
GET /nip46 HTTP/1.1
Upgrade: websocket
X-Cashu-Token: cashuA<token>
7. Bunker verifies token, establishes NIP-46 session
8. One week later, Alice's token approaches expiry
- Alice requests new token (step 2-5)
- New token becomes Pending
- When Active expires, Pending becomes Active
```
## Relay Implementation Notes
### Recommended Defaults
| Parameter | Value | Rationale |
|-----------|-------|-----------|
| Token TTL | 7 days | Balance between convenience and revocation speed |
| Keyset rotation | Weekly | Limits key exposure |
| Verification keysets | 3 | Covers full token lifetime + grace period |
| Re-check ACL | On every use | Enables immediate revocation |
### Error Codes
| Code | Meaning |
|------|---------|
| 401 | Missing or malformed token |
| 403 | Valid token but insufficient permissions (wrong scope/kinds) |
| 410 | Token expired |
| 421 | Unknown keyset ID |
## References
- [Cashu Protocol](https://github.com/cashubtc/nuts)
- [NIP-42: Authentication of clients to relays](https://github.com/nostr-protocol/nips/blob/master/42.md)
- [NIP-46: Nostr Remote Signing](https://github.com/nostr-protocol/nips/blob/master/46.md)
- [NIP-98: HTTP Auth](https://github.com/nostr-protocol/nips/blob/master/98.md)
- [Blind Signatures for Untraceable Payments](http://www.hit.bme.hu/~buttyan/courses/BMEVIHIM219/2009/Chaum.BlindSigForPayworx.1662.pdf)