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:
@@ -143,6 +143,14 @@ type C struct {
|
||||
BunkerEnabled bool `env:"ORLY_BUNKER_ENABLED" default:"false" usage:"enable NIP-46 bunker signing service (requires WireGuard)"`
|
||||
BunkerPort int `env:"ORLY_BUNKER_PORT" default:"3335" usage:"internal port for bunker WebSocket (only accessible via WireGuard)"`
|
||||
|
||||
// Cashu access token configuration (NIP-XX)
|
||||
CashuEnabled bool `env:"ORLY_CASHU_ENABLED" default:"false" usage:"enable Cashu blind signature tokens for access control"`
|
||||
CashuTokenTTL string `env:"ORLY_CASHU_TOKEN_TTL" default:"168h" usage:"token validity duration (default: 1 week)"`
|
||||
CashuKeysetTTL string `env:"ORLY_CASHU_KEYSET_TTL" default:"168h" usage:"keyset active signing period (default: 1 week)"`
|
||||
CashuVerifyTTL string `env:"ORLY_CASHU_VERIFY_TTL" default:"504h" usage:"keyset verification period (default: 3 weeks)"`
|
||||
CashuScopes string `env:"ORLY_CASHU_SCOPES" default:"relay,nip46" usage:"comma-separated list of allowed token scopes"`
|
||||
CashuReauthorize bool `env:"ORLY_CASHU_REAUTHORIZE" default:"true" usage:"re-check ACL on each token verification for stateless revocation"`
|
||||
|
||||
// Cluster replication configuration
|
||||
ClusterPropagatePrivilegedEvents bool `env:"ORLY_CLUSTER_PROPAGATE_PRIVILEGED_EVENTS" default:"true" usage:"propagate privileged events (DMs, gift wraps, etc.) to relay peers for replication"`
|
||||
|
||||
@@ -523,3 +531,54 @@ func (cfg *C) GetWireGuardConfigValues() (
|
||||
cfg.BunkerEnabled,
|
||||
cfg.BunkerPort
|
||||
}
|
||||
|
||||
// GetCashuConfigValues returns the Cashu access token configuration values.
|
||||
// This avoids circular imports with pkg/cashu while allowing main.go to construct
|
||||
// the Cashu issuer/verifier configuration.
|
||||
func (cfg *C) GetCashuConfigValues() (
|
||||
enabled bool,
|
||||
tokenTTL time.Duration,
|
||||
keysetTTL time.Duration,
|
||||
verifyTTL time.Duration,
|
||||
scopes []string,
|
||||
reauthorize bool,
|
||||
) {
|
||||
// Parse token TTL
|
||||
tokenTTL = 168 * time.Hour // Default: 1 week
|
||||
if cfg.CashuTokenTTL != "" {
|
||||
if d, err := time.ParseDuration(cfg.CashuTokenTTL); err == nil {
|
||||
tokenTTL = d
|
||||
}
|
||||
}
|
||||
|
||||
// Parse keyset TTL
|
||||
keysetTTL = 168 * time.Hour // Default: 1 week
|
||||
if cfg.CashuKeysetTTL != "" {
|
||||
if d, err := time.ParseDuration(cfg.CashuKeysetTTL); err == nil {
|
||||
keysetTTL = d
|
||||
}
|
||||
}
|
||||
|
||||
// Parse verify TTL
|
||||
verifyTTL = 504 * time.Hour // Default: 3 weeks
|
||||
if cfg.CashuVerifyTTL != "" {
|
||||
if d, err := time.ParseDuration(cfg.CashuVerifyTTL); err == nil {
|
||||
verifyTTL = d
|
||||
}
|
||||
}
|
||||
|
||||
// Parse scopes
|
||||
if cfg.CashuScopes != "" {
|
||||
scopes = strings.Split(cfg.CashuScopes, ",")
|
||||
for i := range scopes {
|
||||
scopes[i] = strings.TrimSpace(scopes[i])
|
||||
}
|
||||
}
|
||||
|
||||
return cfg.CashuEnabled,
|
||||
tokenTTL,
|
||||
keysetTTL,
|
||||
verifyTTL,
|
||||
scopes,
|
||||
cfg.CashuReauthorize
|
||||
}
|
||||
|
||||
144
app/handle-cashu.go
Normal file
144
app/handle-cashu.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"lol.mleku.dev/chk"
|
||||
"lol.mleku.dev/log"
|
||||
|
||||
"git.mleku.dev/mleku/nostr/httpauth"
|
||||
"next.orly.dev/pkg/cashu/issuer"
|
||||
"next.orly.dev/pkg/cashu/keyset"
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
)
|
||||
|
||||
// CashuMintRequest is the request body for token issuance.
|
||||
type CashuMintRequest struct {
|
||||
BlindedMessage string `json:"blinded_message"` // Hex-encoded blinded point B_
|
||||
Scope string `json:"scope"` // Token scope (e.g., "relay", "nip46")
|
||||
Kinds []int `json:"kinds,omitempty"` // Permitted event kinds
|
||||
KindRanges [][]int `json:"kind_ranges,omitempty"` // Permitted kind ranges
|
||||
}
|
||||
|
||||
// CashuMintResponse is the response body for token issuance.
|
||||
type CashuMintResponse struct {
|
||||
BlindedSignature string `json:"blinded_signature"` // Hex-encoded blinded signature C_
|
||||
KeysetID string `json:"keyset_id"` // Keyset ID used
|
||||
Expiry int64 `json:"expiry"` // Token expiration timestamp
|
||||
MintPubkey string `json:"mint_pubkey"` // Hex-encoded mint public key
|
||||
}
|
||||
|
||||
// handleCashuMint handles POST /cashu/mint - issues a new token.
|
||||
func (s *Server) handleCashuMint(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if Cashu is enabled
|
||||
if s.CashuIssuer == nil {
|
||||
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
// Require NIP-98 authentication
|
||||
valid, pubkey, err := httpauth.CheckAuth(r)
|
||||
if chk.E(err) || !valid {
|
||||
http.Error(w, "NIP-98 authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var req CashuMintRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode blinded message from hex
|
||||
blindedMsg, err := hex.DecodeString(req.BlindedMessage)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid blinded_message: must be hex", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Default scope
|
||||
if req.Scope == "" {
|
||||
req.Scope = token.ScopeRelay
|
||||
}
|
||||
|
||||
// Issue token
|
||||
issueReq := &issuer.IssueRequest{
|
||||
BlindedMessage: blindedMsg,
|
||||
Pubkey: pubkey,
|
||||
Scope: req.Scope,
|
||||
Kinds: req.Kinds,
|
||||
KindRanges: req.KindRanges,
|
||||
}
|
||||
|
||||
resp, err := s.CashuIssuer.Issue(r.Context(), issueReq, r.RemoteAddr)
|
||||
if err != nil {
|
||||
log.W.F("Cashu mint failed for %x: %v", pubkey[:8], err)
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
log.D.F("Cashu token issued for %x, scope=%s, keyset=%s", pubkey[:8], req.Scope, resp.KeysetID)
|
||||
|
||||
// Return response
|
||||
mintResp := CashuMintResponse{
|
||||
BlindedSignature: hex.EncodeToString(resp.BlindedSignature),
|
||||
KeysetID: resp.KeysetID,
|
||||
Expiry: resp.Expiry,
|
||||
MintPubkey: hex.EncodeToString(resp.MintPubkey),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(mintResp)
|
||||
}
|
||||
|
||||
// handleCashuKeysets handles GET /cashu/keysets - returns available keysets.
|
||||
func (s *Server) handleCashuKeysets(w http.ResponseWriter, r *http.Request) {
|
||||
if s.CashuIssuer == nil {
|
||||
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
infos := s.CashuIssuer.GetKeysetInfo()
|
||||
|
||||
type KeysetsResponse struct {
|
||||
Keysets []keyset.KeysetInfo `json:"keysets"`
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(KeysetsResponse{Keysets: infos})
|
||||
}
|
||||
|
||||
// handleCashuInfo handles GET /cashu/info - returns mint information.
|
||||
func (s *Server) handleCashuInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if s.CashuIssuer == nil {
|
||||
http.Error(w, "Cashu tokens not enabled", http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
|
||||
info := s.CashuIssuer.GetMintInfo(s.Config.AppName)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
// CashuTokenTTL returns the configured token TTL.
|
||||
func (s *Server) CashuTokenTTL() time.Duration {
|
||||
enabled, tokenTTL, _, _, _, _ := s.Config.GetCashuConfigValues()
|
||||
if !enabled {
|
||||
return 0
|
||||
}
|
||||
return tokenTTL
|
||||
}
|
||||
|
||||
// CashuKeysetTTL returns the configured keyset TTL.
|
||||
func (s *Server) CashuKeysetTTL() time.Duration {
|
||||
enabled, _, keysetTTL, _, _, _ := s.Config.GetCashuConfigValues()
|
||||
if !enabled {
|
||||
return 0
|
||||
}
|
||||
return keysetTTL
|
||||
}
|
||||
@@ -34,6 +34,8 @@ import (
|
||||
"next.orly.dev/pkg/protocol/nip43"
|
||||
"next.orly.dev/pkg/protocol/publish"
|
||||
"next.orly.dev/pkg/bunker"
|
||||
"next.orly.dev/pkg/cashu/issuer"
|
||||
"next.orly.dev/pkg/cashu/verifier"
|
||||
"next.orly.dev/pkg/ratelimit"
|
||||
"next.orly.dev/pkg/spider"
|
||||
dsync "next.orly.dev/pkg/sync"
|
||||
@@ -85,6 +87,10 @@ type Server struct {
|
||||
wireguardServer *wireguard.Server
|
||||
bunkerServer *bunker.Server
|
||||
subnetPool *wireguard.SubnetPool
|
||||
|
||||
// Cashu access token system (NIP-XX)
|
||||
CashuIssuer *issuer.Issuer
|
||||
CashuVerifier *verifier.Verifier
|
||||
}
|
||||
|
||||
// isIPBlacklisted checks if an IP address is blacklisted using the managed ACL system
|
||||
@@ -350,6 +356,14 @@ func (s *Server) UserInterface() {
|
||||
s.mux.HandleFunc("/api/wireguard/status", s.handleWireGuardStatus)
|
||||
s.mux.HandleFunc("/api/wireguard/audit", s.handleWireGuardAudit)
|
||||
s.mux.HandleFunc("/api/bunker/url", s.handleBunkerURL)
|
||||
|
||||
// Cashu access token endpoints (NIP-XX)
|
||||
s.mux.HandleFunc("/cashu/mint", s.handleCashuMint)
|
||||
s.mux.HandleFunc("/cashu/keysets", s.handleCashuKeysets)
|
||||
s.mux.HandleFunc("/cashu/info", s.handleCashuInfo)
|
||||
if s.CashuIssuer != nil {
|
||||
log.Printf("Cashu access token API enabled at /cashu")
|
||||
}
|
||||
}
|
||||
|
||||
// handleFavicon serves orly-favicon.png as favicon.ico
|
||||
|
||||
390
docs/NIP-XX-CASHU-ACCESS-TOKENS.md
Normal file
390
docs/NIP-XX-CASHU-ACCESS-TOKENS.md
Normal 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)
|
||||
100
pkg/bunker/acl_adapter.go
Normal file
100
pkg/bunker/acl_adapter.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// Package bunker implements NIP-46 remote signing with Cashu token authentication.
|
||||
package bunker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"next.orly.dev/pkg/acl"
|
||||
acliface "next.orly.dev/pkg/interfaces/acl"
|
||||
cashuiface "next.orly.dev/pkg/interfaces/cashu"
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
)
|
||||
|
||||
// ACLAuthzChecker adapts ORLY's ACL system to cashu.AuthzChecker.
|
||||
// This allows the Cashu token system to use the existing ACL for authorization.
|
||||
type ACLAuthzChecker struct {
|
||||
// ScopeRequirements maps scopes to required access levels.
|
||||
// If not set, defaults are used.
|
||||
ScopeRequirements map[string]string
|
||||
}
|
||||
|
||||
// NewACLAuthzChecker creates a new ACL-based authorization checker.
|
||||
func NewACLAuthzChecker() *ACLAuthzChecker {
|
||||
return &ACLAuthzChecker{
|
||||
ScopeRequirements: map[string]string{
|
||||
token.ScopeRelay: acliface.Write, // Relay access requires write
|
||||
token.ScopeNIP46: acliface.Write, // Bunker access requires write
|
||||
token.ScopeBlossom: acliface.Write, // Blossom access requires write
|
||||
token.ScopeAPI: acliface.Admin, // API access requires admin
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CheckAuthorization checks if a pubkey is authorized for a scope.
|
||||
func (a *ACLAuthzChecker) CheckAuthorization(ctx context.Context, pubkey []byte, scope string, remoteAddr string) error {
|
||||
// Get access level from ACL registry
|
||||
level := acl.Registry.GetAccessLevel(pubkey, remoteAddr)
|
||||
|
||||
// Check against required level for scope
|
||||
requiredLevel, ok := a.ScopeRequirements[scope]
|
||||
if !ok {
|
||||
// Default to write access for unknown scopes
|
||||
requiredLevel = acliface.Write
|
||||
}
|
||||
|
||||
if !hasAccessLevel(level, requiredLevel) {
|
||||
return cashuiface.NewAuthzError(
|
||||
cashuiface.ErrCodeInsufficientAccess,
|
||||
"insufficient access level for scope "+scope,
|
||||
)
|
||||
}
|
||||
|
||||
// Check for banned/blocked status
|
||||
if level == "banned" {
|
||||
return cashuiface.ErrBanned
|
||||
}
|
||||
if level == "blocked" {
|
||||
return cashuiface.ErrBlocked
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReauthorizationEnabled returns true - we always re-check ACL on each verification.
|
||||
func (a *ACLAuthzChecker) ReauthorizationEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// hasAccessLevel checks if the actual level meets or exceeds the required level.
|
||||
func hasAccessLevel(actual, required string) bool {
|
||||
levels := map[string]int{
|
||||
acliface.None: 0,
|
||||
"banned": 0,
|
||||
"blocked": 0,
|
||||
acliface.Read: 1,
|
||||
acliface.Write: 2,
|
||||
acliface.Admin: 3,
|
||||
acliface.Owner: 4,
|
||||
}
|
||||
|
||||
actualLevel, aok := levels[actual]
|
||||
requiredLevel, rok := levels[required]
|
||||
|
||||
if !aok || !rok {
|
||||
return false
|
||||
}
|
||||
|
||||
return actualLevel >= requiredLevel
|
||||
}
|
||||
|
||||
// SetScopeRequirement sets the required access level for a scope.
|
||||
func (a *ACLAuthzChecker) SetScopeRequirement(scope, level string) {
|
||||
if a.ScopeRequirements == nil {
|
||||
a.ScopeRequirements = make(map[string]string)
|
||||
}
|
||||
a.ScopeRequirements[scope] = level
|
||||
}
|
||||
|
||||
// Ensure ACLAuthzChecker implements both interfaces.
|
||||
var _ cashuiface.AuthzChecker = (*ACLAuthzChecker)(nil)
|
||||
var _ cashuiface.ReauthorizationChecker = (*ACLAuthzChecker)(nil)
|
||||
293
pkg/cashu/bdhke/bdhke.go
Normal file
293
pkg/cashu/bdhke/bdhke.go
Normal file
@@ -0,0 +1,293 @@
|
||||
// Package bdhke implements Blind Diffie-Hellman Key Exchange for Cashu-style tokens.
|
||||
// This is the core cryptographic primitive used in ecash blind signatures.
|
||||
//
|
||||
// The protocol allows a mint (issuer) to sign a message without knowing what
|
||||
// it's signing, providing unlinkability between token issuance and redemption.
|
||||
//
|
||||
// Protocol overview:
|
||||
// 1. User creates secret x, computes Y = HashToCurve(x)
|
||||
// 2. User blinds: B_ = Y + r*G (r is random blinding factor)
|
||||
// 3. Mint signs: C_ = k*B_ (k is mint's private key)
|
||||
// 4. User unblinds: C = C_ - r*K (K is mint's public key)
|
||||
// 5. Token is (x, C) - mint can verify: C == k*HashToCurve(x)
|
||||
//
|
||||
// Reference: https://github.com/cashubtc/nuts/blob/main/00.md
|
||||
package bdhke
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
)
|
||||
|
||||
// DomainSeparator is prepended to messages before hashing to prevent
|
||||
// cross-protocol attacks.
|
||||
const DomainSeparator = "Secp256k1_HashToCurve_Cashu_"
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrHashToCurveFailed = errors.New("bdhke: hash to curve failed after max iterations")
|
||||
ErrInvalidPoint = errors.New("bdhke: invalid curve point")
|
||||
ErrInvalidPrivateKey = errors.New("bdhke: invalid private key")
|
||||
ErrSignatureMismatch = errors.New("bdhke: signature verification failed")
|
||||
)
|
||||
|
||||
// HashToCurve deterministically maps a message to a point on secp256k1.
|
||||
// Uses the try-and-increment method as specified in Cashu NUT-00.
|
||||
//
|
||||
// Algorithm:
|
||||
// 1. Compute msg_hash = SHA256(domain_separator || message)
|
||||
// 2. For counter in 0..65536:
|
||||
// a. Compute hash = SHA256(msg_hash || counter)
|
||||
// b. Try to parse 02 || hash as compressed point
|
||||
// c. If valid point, return it
|
||||
// 3. Fail if no valid point found (extremely unlikely)
|
||||
func HashToCurve(message []byte) (*secp256k1.PublicKey, error) {
|
||||
// Hash the message with domain separator
|
||||
msgHash := sha256.Sum256(append([]byte(DomainSeparator), message...))
|
||||
|
||||
// Try up to 65536 iterations (in practice, ~50% chance on first try)
|
||||
counterBytes := make([]byte, 4)
|
||||
for counter := uint32(0); counter < 65536; counter++ {
|
||||
binary.LittleEndian.PutUint32(counterBytes, counter)
|
||||
|
||||
// Hash again with counter
|
||||
toHash := append(msgHash[:], counterBytes...)
|
||||
hash := sha256.Sum256(toHash)
|
||||
|
||||
// Try to parse as compressed point with 02 prefix (even y)
|
||||
compressed := make([]byte, 33)
|
||||
compressed[0] = 0x02
|
||||
copy(compressed[1:], hash[:])
|
||||
|
||||
pk, err := secp256k1.ParsePubKey(compressed)
|
||||
if err == nil {
|
||||
return pk, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrHashToCurveFailed
|
||||
}
|
||||
|
||||
// BlindResult contains the blinding operation result.
|
||||
type BlindResult struct {
|
||||
B *secp256k1.PublicKey // Blinded message B_ = Y + r*G
|
||||
R *secp256k1.PrivateKey // Blinding factor (keep secret until unblinding)
|
||||
Y *secp256k1.PublicKey // Original point Y = HashToCurve(secret)
|
||||
}
|
||||
|
||||
// Blind creates a blinded message from a secret.
|
||||
// The blinding factor r is generated randomly and must be kept secret
|
||||
// until the signature is received and needs to be unblinded.
|
||||
//
|
||||
// B_ = Y + r*G where:
|
||||
// - Y = HashToCurve(secret)
|
||||
// - r = random scalar
|
||||
// - G = generator point
|
||||
func Blind(secret []byte) (*BlindResult, error) {
|
||||
// Compute Y = HashToCurve(secret)
|
||||
Y, err := HashToCurve(secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("blind: %w", err)
|
||||
}
|
||||
|
||||
// Generate random blinding factor r
|
||||
rBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(rBytes); err != nil {
|
||||
return nil, fmt.Errorf("blind: failed to generate random: %w", err)
|
||||
}
|
||||
r := secp256k1.PrivKeyFromBytes(rBytes)
|
||||
|
||||
// Compute r*G (blinding factor times generator)
|
||||
rG := new(secp256k1.JacobianPoint)
|
||||
secp256k1.ScalarBaseMultNonConst(&r.Key, rG)
|
||||
|
||||
// Convert Y to Jacobian
|
||||
yJ := new(secp256k1.JacobianPoint)
|
||||
Y.AsJacobian(yJ)
|
||||
|
||||
// Compute B_ = Y + r*G
|
||||
bJ := new(secp256k1.JacobianPoint)
|
||||
secp256k1.AddNonConst(yJ, rG, bJ)
|
||||
bJ.ToAffine()
|
||||
|
||||
// Convert back to PublicKey
|
||||
B := secp256k1.NewPublicKey(&bJ.X, &bJ.Y)
|
||||
|
||||
return &BlindResult{
|
||||
B: B,
|
||||
R: r,
|
||||
Y: Y,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BlindWithFactor creates a blinded message using a provided blinding factor.
|
||||
// This is useful for testing or when the blinding factor needs to be deterministic.
|
||||
func BlindWithFactor(secret []byte, rBytes []byte) (*BlindResult, error) {
|
||||
if len(rBytes) != 32 {
|
||||
return nil, errors.New("blind: blinding factor must be 32 bytes")
|
||||
}
|
||||
|
||||
// Compute Y = HashToCurve(secret)
|
||||
Y, err := HashToCurve(secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("blind: %w", err)
|
||||
}
|
||||
|
||||
r := secp256k1.PrivKeyFromBytes(rBytes)
|
||||
|
||||
// Compute r*G
|
||||
rG := new(secp256k1.JacobianPoint)
|
||||
secp256k1.ScalarBaseMultNonConst(&r.Key, rG)
|
||||
|
||||
// Convert Y to Jacobian
|
||||
yJ := new(secp256k1.JacobianPoint)
|
||||
Y.AsJacobian(yJ)
|
||||
|
||||
// Compute B_ = Y + r*G
|
||||
bJ := new(secp256k1.JacobianPoint)
|
||||
secp256k1.AddNonConst(yJ, rG, bJ)
|
||||
bJ.ToAffine()
|
||||
|
||||
B := secp256k1.NewPublicKey(&bJ.X, &bJ.Y)
|
||||
|
||||
return &BlindResult{
|
||||
B: B,
|
||||
R: r,
|
||||
Y: Y,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Sign creates a blinded signature on a blinded message.
|
||||
// This is performed by the mint using its private key k.
|
||||
//
|
||||
// C_ = k * B_ where:
|
||||
// - k = mint's private key scalar
|
||||
// - B_ = blinded message from user
|
||||
func Sign(B *secp256k1.PublicKey, k *secp256k1.PrivateKey) (*secp256k1.PublicKey, error) {
|
||||
if B == nil || k == nil {
|
||||
return nil, ErrInvalidPoint
|
||||
}
|
||||
|
||||
// Convert B to Jacobian
|
||||
bJ := new(secp256k1.JacobianPoint)
|
||||
B.AsJacobian(bJ)
|
||||
|
||||
// Compute C_ = k * B_
|
||||
cJ := new(secp256k1.JacobianPoint)
|
||||
secp256k1.ScalarMultNonConst(&k.Key, bJ, cJ)
|
||||
cJ.ToAffine()
|
||||
|
||||
C := secp256k1.NewPublicKey(&cJ.X, &cJ.Y)
|
||||
return C, nil
|
||||
}
|
||||
|
||||
// Unblind removes the blinding factor from the signature.
|
||||
// This is performed by the user after receiving the blinded signature.
|
||||
//
|
||||
// C = C_ - r*K where:
|
||||
// - C_ = blinded signature from mint
|
||||
// - r = original blinding factor
|
||||
// - K = mint's public key
|
||||
func Unblind(C_ *secp256k1.PublicKey, r *secp256k1.PrivateKey, K *secp256k1.PublicKey) (*secp256k1.PublicKey, error) {
|
||||
if C_ == nil || r == nil || K == nil {
|
||||
return nil, ErrInvalidPoint
|
||||
}
|
||||
|
||||
// Compute r*K
|
||||
kJ := new(secp256k1.JacobianPoint)
|
||||
K.AsJacobian(kJ)
|
||||
|
||||
rK := new(secp256k1.JacobianPoint)
|
||||
secp256k1.ScalarMultNonConst(&r.Key, kJ, rK)
|
||||
|
||||
// Negate r*K to get -r*K
|
||||
rK.Y.Negate(1)
|
||||
rK.Y.Normalize()
|
||||
|
||||
// Convert C_ to Jacobian
|
||||
c_J := new(secp256k1.JacobianPoint)
|
||||
C_.AsJacobian(c_J)
|
||||
|
||||
// Compute C = C_ + (-r*K) = C_ - r*K
|
||||
cJ := new(secp256k1.JacobianPoint)
|
||||
secp256k1.AddNonConst(c_J, rK, cJ)
|
||||
cJ.ToAffine()
|
||||
|
||||
C := secp256k1.NewPublicKey(&cJ.X, &cJ.Y)
|
||||
return C, nil
|
||||
}
|
||||
|
||||
// Verify checks that a token's signature is valid.
|
||||
// The mint uses this to verify tokens during redemption.
|
||||
//
|
||||
// Checks: C == k * HashToCurve(secret) where:
|
||||
// - C = unblinded signature from token
|
||||
// - k = mint's private key
|
||||
// - secret = token's secret value
|
||||
func Verify(secret []byte, C *secp256k1.PublicKey, k *secp256k1.PrivateKey) (bool, error) {
|
||||
if C == nil || k == nil {
|
||||
return false, ErrInvalidPoint
|
||||
}
|
||||
|
||||
// Compute Y = HashToCurve(secret)
|
||||
Y, err := HashToCurve(secret)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Compute expected = k * Y
|
||||
yJ := new(secp256k1.JacobianPoint)
|
||||
Y.AsJacobian(yJ)
|
||||
|
||||
expectedJ := new(secp256k1.JacobianPoint)
|
||||
secp256k1.ScalarMultNonConst(&k.Key, yJ, expectedJ)
|
||||
expectedJ.ToAffine()
|
||||
|
||||
expected := secp256k1.NewPublicKey(&expectedJ.X, &expectedJ.Y)
|
||||
|
||||
// Compare C with expected
|
||||
return C.IsEqual(expected), nil
|
||||
}
|
||||
|
||||
// VerifyWithPublicKey verifies a token without knowing the private key.
|
||||
// This requires a DLEQ proof (not yet implemented).
|
||||
// For now, returns error indicating this is not supported.
|
||||
func VerifyWithPublicKey(secret []byte, C *secp256k1.PublicKey, K *secp256k1.PublicKey) (bool, error) {
|
||||
return false, errors.New("bdhke: DLEQ proof verification not implemented")
|
||||
}
|
||||
|
||||
// GenerateKeypair generates a new mint keypair.
|
||||
func GenerateKeypair() (*secp256k1.PrivateKey, *secp256k1.PublicKey, error) {
|
||||
keyBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return nil, nil, fmt.Errorf("generate keypair: %w", err)
|
||||
}
|
||||
|
||||
privKey := secp256k1.PrivKeyFromBytes(keyBytes)
|
||||
pubKey := privKey.PubKey()
|
||||
|
||||
return privKey, pubKey, nil
|
||||
}
|
||||
|
||||
// SecretFromBytes creates a secret suitable for token issuance.
|
||||
// The secret should be 32 bytes of random data.
|
||||
func SecretFromBytes(data []byte) []byte {
|
||||
// Just return a copy - secrets are arbitrary byte strings
|
||||
secret := make([]byte, len(data))
|
||||
copy(secret, data)
|
||||
return secret
|
||||
}
|
||||
|
||||
// GenerateSecret creates a new random 32-byte secret.
|
||||
func GenerateSecret() ([]byte, error) {
|
||||
secret := make([]byte, 32)
|
||||
if _, err := rand.Read(secret); err != nil {
|
||||
return nil, fmt.Errorf("generate secret: %w", err)
|
||||
}
|
||||
return secret, nil
|
||||
}
|
||||
348
pkg/cashu/bdhke/bdhke_test.go
Normal file
348
pkg/cashu/bdhke/bdhke_test.go
Normal file
@@ -0,0 +1,348 @@
|
||||
package bdhke
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
)
|
||||
|
||||
// Test vectors from Cashu NUT-00 specification
|
||||
// https://github.com/cashubtc/nuts/blob/main/00.md
|
||||
|
||||
func TestHashToCurve(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
message string
|
||||
expected string // Expected compressed public key in hex
|
||||
}{
|
||||
{
|
||||
name: "test vector 1",
|
||||
message: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
expected: "024cce997d3b518f739663b757deaec95bcd9473c30a14ac2fd04023a739d1a725",
|
||||
},
|
||||
{
|
||||
name: "test vector 2",
|
||||
message: "0000000000000000000000000000000000000000000000000000000000000001",
|
||||
expected: "022e7158e11c9506f1aa4248bf531298daa7febd6194f003edcd9b93ade6253acf",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
msgBytes, err := hex.DecodeString(tt.message)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode message: %v", err)
|
||||
}
|
||||
|
||||
point, err := HashToCurve(msgBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("HashToCurve failed: %v", err)
|
||||
}
|
||||
|
||||
got := hex.EncodeToString(point.SerializeCompressed())
|
||||
if got != tt.expected {
|
||||
t.Errorf("HashToCurve(%s) = %s, want %s", tt.message, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlindSignUnblindVerify(t *testing.T) {
|
||||
// Generate mint keypair
|
||||
k, K, err := GenerateKeypair()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate keypair: %v", err)
|
||||
}
|
||||
|
||||
// Generate a secret
|
||||
secret, err := GenerateSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate secret: %v", err)
|
||||
}
|
||||
|
||||
// User blinds the secret
|
||||
blindResult, err := Blind(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Blind failed: %v", err)
|
||||
}
|
||||
|
||||
// Mint signs the blinded message
|
||||
C_, err := Sign(blindResult.B, k)
|
||||
if err != nil {
|
||||
t.Fatalf("Sign failed: %v", err)
|
||||
}
|
||||
|
||||
// User unblinds the signature
|
||||
C, err := Unblind(C_, blindResult.R, K)
|
||||
if err != nil {
|
||||
t.Fatalf("Unblind failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
valid, err := Verify(secret, C, k)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
|
||||
if !valid {
|
||||
t.Error("Verify returned false, expected true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWrongSecret(t *testing.T) {
|
||||
k, K, _ := GenerateKeypair()
|
||||
secret1, _ := GenerateSecret()
|
||||
secret2, _ := GenerateSecret()
|
||||
|
||||
// Create token with secret1
|
||||
blindResult, _ := Blind(secret1)
|
||||
C_, _ := Sign(blindResult.B, k)
|
||||
C, _ := Unblind(C_, blindResult.R, K)
|
||||
|
||||
// Try to verify with secret2
|
||||
valid, err := Verify(secret2, C, k)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
|
||||
if valid {
|
||||
t.Error("Verify returned true for wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWrongKey(t *testing.T) {
|
||||
k1, K1, _ := GenerateKeypair()
|
||||
k2, _, _ := GenerateKeypair()
|
||||
|
||||
secret, _ := GenerateSecret()
|
||||
|
||||
// Create token with k1
|
||||
blindResult, _ := Blind(secret)
|
||||
C_, _ := Sign(blindResult.B, k1)
|
||||
C, _ := Unblind(C_, blindResult.R, K1)
|
||||
|
||||
// Try to verify with k2
|
||||
valid, err := Verify(secret, C, k2)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
|
||||
if valid {
|
||||
t.Error("Verify returned true for wrong key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlindWithFactor(t *testing.T) {
|
||||
k, K, _ := GenerateKeypair()
|
||||
secret := []byte("test secret message")
|
||||
|
||||
// Use deterministic blinding factor
|
||||
rBytes := make([]byte, 32)
|
||||
for i := range rBytes {
|
||||
rBytes[i] = byte(i)
|
||||
}
|
||||
|
||||
blindResult, err := BlindWithFactor(secret, rBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("BlindWithFactor failed: %v", err)
|
||||
}
|
||||
|
||||
// Complete the protocol
|
||||
C_, _ := Sign(blindResult.B, k)
|
||||
C, _ := Unblind(C_, blindResult.R, K)
|
||||
|
||||
valid, _ := Verify(secret, C, k)
|
||||
if !valid {
|
||||
t.Error("BlindWithFactor: verification failed")
|
||||
}
|
||||
|
||||
// Do it again with same factor - should get same B
|
||||
blindResult2, _ := BlindWithFactor(secret, rBytes)
|
||||
if !bytes.Equal(blindResult.B.SerializeCompressed(), blindResult2.B.SerializeCompressed()) {
|
||||
t.Error("BlindWithFactor not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashToCurveDeterministic(t *testing.T) {
|
||||
message := []byte("deterministic test")
|
||||
|
||||
p1, err := HashToCurve(message)
|
||||
if err != nil {
|
||||
t.Fatalf("HashToCurve failed: %v", err)
|
||||
}
|
||||
|
||||
p2, err := HashToCurve(message)
|
||||
if err != nil {
|
||||
t.Fatalf("HashToCurve failed: %v", err)
|
||||
}
|
||||
|
||||
if !p1.IsEqual(p2) {
|
||||
t.Error("HashToCurve not deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignNilInputs(t *testing.T) {
|
||||
k, _, _ := GenerateKeypair()
|
||||
|
||||
_, err := Sign(nil, k)
|
||||
if err == nil {
|
||||
t.Error("Sign(nil, k) should error")
|
||||
}
|
||||
|
||||
B, _ := HashToCurve([]byte("test"))
|
||||
_, err = Sign(B, nil)
|
||||
if err == nil {
|
||||
t.Error("Sign(B, nil) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnblindNilInputs(t *testing.T) {
|
||||
k, K, _ := GenerateKeypair()
|
||||
secret, _ := GenerateSecret()
|
||||
blindResult, _ := Blind(secret)
|
||||
C_, _ := Sign(blindResult.B, k)
|
||||
|
||||
_, err := Unblind(nil, blindResult.R, K)
|
||||
if err == nil {
|
||||
t.Error("Unblind(nil, r, K) should error")
|
||||
}
|
||||
|
||||
_, err = Unblind(C_, nil, K)
|
||||
if err == nil {
|
||||
t.Error("Unblind(C_, nil, K) should error")
|
||||
}
|
||||
|
||||
_, err = Unblind(C_, blindResult.R, nil)
|
||||
if err == nil {
|
||||
t.Error("Unblind(C_, r, nil) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyNilInputs(t *testing.T) {
|
||||
k, K, _ := GenerateKeypair()
|
||||
secret, _ := GenerateSecret()
|
||||
blindResult, _ := Blind(secret)
|
||||
C_, _ := Sign(blindResult.B, k)
|
||||
C, _ := Unblind(C_, blindResult.R, K)
|
||||
|
||||
_, err := Verify(secret, nil, k)
|
||||
if err == nil {
|
||||
t.Error("Verify(secret, nil, k) should error")
|
||||
}
|
||||
|
||||
_, err = Verify(secret, C, nil)
|
||||
if err == nil {
|
||||
t.Error("Verify(secret, C, nil) should error")
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark functions
|
||||
func BenchmarkHashToCurve(b *testing.B) {
|
||||
secret, _ := GenerateSecret()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
HashToCurve(secret)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBlind(b *testing.B) {
|
||||
secret, _ := GenerateSecret()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
Blind(secret)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSign(b *testing.B) {
|
||||
k, _, _ := GenerateKeypair()
|
||||
secret, _ := GenerateSecret()
|
||||
blindResult, _ := Blind(secret)
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
Sign(blindResult.B, k)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkUnblind(b *testing.B) {
|
||||
k, K, _ := GenerateKeypair()
|
||||
secret, _ := GenerateSecret()
|
||||
blindResult, _ := Blind(secret)
|
||||
C_, _ := Sign(blindResult.B, k)
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
Unblind(C_, blindResult.R, K)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkVerify(b *testing.B) {
|
||||
k, K, _ := GenerateKeypair()
|
||||
secret, _ := GenerateSecret()
|
||||
blindResult, _ := Blind(secret)
|
||||
C_, _ := Sign(blindResult.B, k)
|
||||
C, _ := Unblind(C_, blindResult.R, K)
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
Verify(secret, C, k)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFullProtocol(b *testing.B) {
|
||||
k, K, _ := GenerateKeypair()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
secret, _ := GenerateSecret()
|
||||
blindResult, _ := Blind(secret)
|
||||
C_, _ := Sign(blindResult.B, k)
|
||||
C, _ := Unblind(C_, blindResult.R, K)
|
||||
Verify(secret, C, k)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that serialization/deserialization works correctly
|
||||
func TestPointSerialization(t *testing.T) {
|
||||
k, K, _ := GenerateKeypair()
|
||||
secret, _ := GenerateSecret()
|
||||
blindResult, _ := Blind(secret)
|
||||
C_, _ := Sign(blindResult.B, k)
|
||||
C, _ := Unblind(C_, blindResult.R, K)
|
||||
|
||||
// Serialize and deserialize C
|
||||
serialized := C.SerializeCompressed()
|
||||
deserialized, err := secp256k1.ParsePubKey(serialized)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse serialized point: %v", err)
|
||||
}
|
||||
|
||||
// Verify with deserialized point
|
||||
valid, err := Verify(secret, deserialized, k)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Error("Verify failed after point serialization round-trip")
|
||||
}
|
||||
|
||||
// Same for K
|
||||
kSerialized := K.SerializeCompressed()
|
||||
kDeserialized, err := secp256k1.ParsePubKey(kSerialized)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse serialized K: %v", err)
|
||||
}
|
||||
|
||||
// Unblind with deserialized K
|
||||
C2, err := Unblind(C_, blindResult.R, kDeserialized)
|
||||
if err != nil {
|
||||
t.Fatalf("Unblind with deserialized K failed: %v", err)
|
||||
}
|
||||
if !C.IsEqual(C2) {
|
||||
t.Error("Unblind result differs after K round-trip")
|
||||
}
|
||||
}
|
||||
288
pkg/cashu/issuer/issuer.go
Normal file
288
pkg/cashu/issuer/issuer.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// Package issuer implements Cashu token issuance with authorization checks.
|
||||
package issuer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
|
||||
"next.orly.dev/pkg/cashu/bdhke"
|
||||
"next.orly.dev/pkg/cashu/keyset"
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
cashuiface "next.orly.dev/pkg/interfaces/cashu"
|
||||
)
|
||||
|
||||
// Errors.
|
||||
var (
|
||||
ErrNoActiveKeyset = errors.New("issuer: no active keyset available")
|
||||
ErrInvalidBlindedMsg = errors.New("issuer: invalid blinded message")
|
||||
ErrInvalidPubkey = errors.New("issuer: invalid pubkey")
|
||||
ErrInvalidScope = errors.New("issuer: invalid scope")
|
||||
)
|
||||
|
||||
// Config holds issuer configuration.
|
||||
type Config struct {
|
||||
// DefaultTTL is the default token lifetime.
|
||||
DefaultTTL time.Duration
|
||||
|
||||
// MaxTTL is the maximum allowed token lifetime.
|
||||
MaxTTL time.Duration
|
||||
|
||||
// AllowedScopes is the list of scopes this issuer can issue tokens for.
|
||||
// Empty means all scopes are allowed.
|
||||
AllowedScopes []string
|
||||
|
||||
// MaxKinds is the maximum number of explicit kinds in a token.
|
||||
// 0 means unlimited.
|
||||
MaxKinds int
|
||||
|
||||
// MaxKindRanges is the maximum number of kind ranges in a token.
|
||||
// 0 means unlimited.
|
||||
MaxKindRanges int
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible default configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
DefaultTTL: 7 * 24 * time.Hour, // 1 week
|
||||
MaxTTL: 7 * 24 * time.Hour, // 1 week
|
||||
MaxKinds: 100,
|
||||
MaxKindRanges: 10,
|
||||
}
|
||||
}
|
||||
|
||||
// Issuer handles token issuance with authorization checks.
|
||||
type Issuer struct {
|
||||
keysets *keyset.Manager
|
||||
authz cashuiface.AuthzChecker
|
||||
config Config
|
||||
}
|
||||
|
||||
// New creates a new issuer.
|
||||
func New(keysets *keyset.Manager, authz cashuiface.AuthzChecker, config Config) *Issuer {
|
||||
return &Issuer{
|
||||
keysets: keysets,
|
||||
authz: authz,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// IssueRequest contains the request parameters for token issuance.
|
||||
type IssueRequest struct {
|
||||
// BlindedMessage is the blinded point B_ (33 bytes compressed).
|
||||
BlindedMessage []byte
|
||||
|
||||
// Pubkey is the user's Nostr pubkey (32 bytes).
|
||||
Pubkey []byte
|
||||
|
||||
// Scope is the requested token scope.
|
||||
Scope string
|
||||
|
||||
// Kinds is the list of permitted event kinds.
|
||||
Kinds []int
|
||||
|
||||
// KindRanges is the list of permitted kind ranges.
|
||||
KindRanges [][]int
|
||||
|
||||
// TTL is the requested token lifetime (optional, uses default if zero).
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
// IssueResponse contains the response from token issuance.
|
||||
type IssueResponse struct {
|
||||
// BlindedSignature is the blinded signature C_ (33 bytes compressed).
|
||||
BlindedSignature []byte
|
||||
|
||||
// KeysetID is the ID of the keyset used for signing.
|
||||
KeysetID string
|
||||
|
||||
// Expiry is the token expiration timestamp.
|
||||
Expiry int64
|
||||
|
||||
// MintPubkey is the public key of the keyset (for unblinding).
|
||||
MintPubkey []byte
|
||||
}
|
||||
|
||||
// Issue creates a blinded signature after authorization check.
|
||||
func (i *Issuer) Issue(ctx context.Context, req *IssueRequest, remoteAddr string) (*IssueResponse, error) {
|
||||
// Validate request
|
||||
if err := i.validateRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check authorization
|
||||
if err := i.authz.CheckAuthorization(ctx, req.Pubkey, req.Scope, remoteAddr); err != nil {
|
||||
return nil, fmt.Errorf("issuer: authorization failed: %w", err)
|
||||
}
|
||||
|
||||
// Get active keyset
|
||||
ks := i.keysets.GetSigningKeyset()
|
||||
if ks == nil || !ks.IsActiveForSigning() {
|
||||
return nil, ErrNoActiveKeyset
|
||||
}
|
||||
|
||||
// Parse blinded message
|
||||
B_, err := secp256k1.ParsePubKey(req.BlindedMessage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidBlindedMsg, err)
|
||||
}
|
||||
|
||||
// Sign the blinded message
|
||||
C_, err := bdhke.Sign(B_, ks.PrivateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issuer: signing failed: %w", err)
|
||||
}
|
||||
|
||||
// Calculate expiry
|
||||
ttl := req.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = i.config.DefaultTTL
|
||||
}
|
||||
if ttl > i.config.MaxTTL {
|
||||
ttl = i.config.MaxTTL
|
||||
}
|
||||
expiry := time.Now().Add(ttl).Unix()
|
||||
|
||||
return &IssueResponse{
|
||||
BlindedSignature: C_.SerializeCompressed(),
|
||||
KeysetID: ks.ID,
|
||||
Expiry: expiry,
|
||||
MintPubkey: ks.SerializePublicKey(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validateRequest validates the issue request.
|
||||
func (i *Issuer) validateRequest(req *IssueRequest) error {
|
||||
// Validate blinded message
|
||||
if len(req.BlindedMessage) != 33 {
|
||||
return fmt.Errorf("%w: expected 33 bytes, got %d", ErrInvalidBlindedMsg, len(req.BlindedMessage))
|
||||
}
|
||||
|
||||
// Validate pubkey
|
||||
if len(req.Pubkey) != 32 {
|
||||
return fmt.Errorf("%w: expected 32 bytes, got %d", ErrInvalidPubkey, len(req.Pubkey))
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
if req.Scope == "" {
|
||||
return ErrInvalidScope
|
||||
}
|
||||
if len(i.config.AllowedScopes) > 0 {
|
||||
allowed := false
|
||||
for _, s := range i.config.AllowedScopes {
|
||||
if s == req.Scope {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return fmt.Errorf("%w: %s not in allowed scopes", ErrInvalidScope, req.Scope)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate kinds count
|
||||
if i.config.MaxKinds > 0 && len(req.Kinds) > i.config.MaxKinds {
|
||||
return fmt.Errorf("issuer: too many kinds: %d > %d", len(req.Kinds), i.config.MaxKinds)
|
||||
}
|
||||
|
||||
// Validate kind ranges count
|
||||
if i.config.MaxKindRanges > 0 && len(req.KindRanges) > i.config.MaxKindRanges {
|
||||
return fmt.Errorf("issuer: too many kind ranges: %d > %d", len(req.KindRanges), i.config.MaxKindRanges)
|
||||
}
|
||||
|
||||
// Validate kind ranges format
|
||||
for idx, r := range req.KindRanges {
|
||||
if len(r) != 2 {
|
||||
return fmt.Errorf("issuer: kind range %d must have 2 elements", idx)
|
||||
}
|
||||
if r[0] > r[1] {
|
||||
return fmt.Errorf("issuer: kind range %d min > max: %d > %d", idx, r[0], r[1])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetKeysetInfo returns public information about available keysets.
|
||||
func (i *Issuer) GetKeysetInfo() []keyset.KeysetInfo {
|
||||
return i.keysets.ListKeysetInfo()
|
||||
}
|
||||
|
||||
// GetActiveKeysetID returns the ID of the currently active keyset.
|
||||
func (i *Issuer) GetActiveKeysetID() string {
|
||||
ks := i.keysets.GetSigningKeyset()
|
||||
if ks == nil {
|
||||
return ""
|
||||
}
|
||||
return ks.ID
|
||||
}
|
||||
|
||||
// MintInfo contains public information about the mint.
|
||||
type MintInfo struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Version string `json:"version"`
|
||||
TokenTTL int64 `json:"token_ttl"`
|
||||
MaxKinds int `json:"max_kinds,omitempty"`
|
||||
MaxKindRanges int `json:"max_kind_ranges,omitempty"`
|
||||
SupportedScopes []string `json:"supported_scopes,omitempty"`
|
||||
}
|
||||
|
||||
// GetMintInfo returns public information about the issuer.
|
||||
func (i *Issuer) GetMintInfo(name string) MintInfo {
|
||||
return MintInfo{
|
||||
Name: name,
|
||||
Version: "NIP-XX/1",
|
||||
TokenTTL: int64(i.config.DefaultTTL.Seconds()),
|
||||
MaxKinds: i.config.MaxKinds,
|
||||
MaxKindRanges: i.config.MaxKindRanges,
|
||||
SupportedScopes: i.config.AllowedScopes,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildToken is a helper that creates a complete token from the issue response
|
||||
// and the user's secret and blinding factor.
|
||||
// This is typically done client-side, but provided for testing and CLI tools.
|
||||
func BuildToken(
|
||||
resp *IssueResponse,
|
||||
secret []byte,
|
||||
blindingFactor *secp256k1.PrivateKey,
|
||||
pubkey []byte,
|
||||
scope string,
|
||||
kinds []int,
|
||||
kindRanges [][]int,
|
||||
) (*token.Token, error) {
|
||||
// Parse mint pubkey
|
||||
mintPubkey, err := secp256k1.ParsePubKey(resp.MintPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid mint pubkey: %w", err)
|
||||
}
|
||||
|
||||
// Parse blinded signature
|
||||
C_, err := secp256k1.ParsePubKey(resp.BlindedSignature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid blinded signature: %w", err)
|
||||
}
|
||||
|
||||
// Unblind the signature
|
||||
C, err := bdhke.Unblind(C_, blindingFactor, mintPubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unblind failed: %w", err)
|
||||
}
|
||||
|
||||
// Create token
|
||||
tok := token.New(
|
||||
resp.KeysetID,
|
||||
secret,
|
||||
C.SerializeCompressed(),
|
||||
pubkey,
|
||||
time.Unix(resp.Expiry, 0),
|
||||
scope,
|
||||
)
|
||||
tok.SetKinds(kinds...)
|
||||
tok.KindRanges = kindRanges
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
296
pkg/cashu/issuer/issuer_test.go
Normal file
296
pkg/cashu/issuer/issuer_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package issuer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
|
||||
"next.orly.dev/pkg/cashu/bdhke"
|
||||
"next.orly.dev/pkg/cashu/keyset"
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
cashuiface "next.orly.dev/pkg/interfaces/cashu"
|
||||
)
|
||||
|
||||
func setupIssuer(authz cashuiface.AuthzChecker) (*Issuer, *keyset.Manager) {
|
||||
store := keyset.NewMemoryStore()
|
||||
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
config := DefaultConfig()
|
||||
issuer := New(manager, authz, config)
|
||||
|
||||
return issuer, manager
|
||||
}
|
||||
|
||||
func TestIssueSuccess(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
// Generate user keypair
|
||||
secret, err := bdhke.GenerateSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateSecret failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate blinded message
|
||||
blindResult, err := bdhke.Blind(secret)
|
||||
if err != nil {
|
||||
t.Fatalf("Blind failed: %v", err)
|
||||
}
|
||||
|
||||
// User pubkey
|
||||
pubkey := make([]byte, 32)
|
||||
for i := range pubkey {
|
||||
pubkey[i] = byte(i)
|
||||
}
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
Kinds: []int{0, 1, 3},
|
||||
KindRanges: [][]int{{30000, 39999}},
|
||||
}
|
||||
|
||||
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue failed: %v", err)
|
||||
}
|
||||
|
||||
// Check response
|
||||
if len(resp.BlindedSignature) != 33 {
|
||||
t.Errorf("BlindedSignature length = %d, want 33", len(resp.BlindedSignature))
|
||||
}
|
||||
if resp.KeysetID == "" {
|
||||
t.Error("KeysetID is empty")
|
||||
}
|
||||
if resp.Expiry <= time.Now().Unix() {
|
||||
t.Error("Expiry should be in the future")
|
||||
}
|
||||
if len(resp.MintPubkey) != 33 {
|
||||
t.Errorf("MintPubkey length = %d, want 33", len(resp.MintPubkey))
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAuthorizationDenied(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.DenyAllChecker{})
|
||||
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
}
|
||||
|
||||
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Issue should fail when authorization is denied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueInvalidBlindedMessage(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: []byte{1, 2, 3}, // Invalid
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
}
|
||||
|
||||
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Issue should fail with invalid blinded message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueInvalidPubkey(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: []byte{1, 2, 3}, // Invalid length
|
||||
Scope: token.ScopeRelay,
|
||||
}
|
||||
|
||||
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Issue should fail with invalid pubkey")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueInvalidScope(t *testing.T) {
|
||||
store := keyset.NewMemoryStore()
|
||||
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
config := DefaultConfig()
|
||||
config.AllowedScopes = []string{token.ScopeRelay} // Only relay scope allowed
|
||||
|
||||
issuer := New(manager, cashuiface.AllowAllChecker{}, config)
|
||||
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeNIP46, // Not allowed
|
||||
}
|
||||
|
||||
_, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Issue should fail with disallowed scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueTTL(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
// Request with custom TTL
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
TTL: time.Hour,
|
||||
}
|
||||
|
||||
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue failed: %v", err)
|
||||
}
|
||||
|
||||
// Expiry should be ~1 hour from now
|
||||
expectedExpiry := time.Now().Add(time.Hour).Unix()
|
||||
if resp.Expiry < expectedExpiry-60 || resp.Expiry > expectedExpiry+60 {
|
||||
t.Errorf("Expiry %d not within expected range of %d", resp.Expiry, expectedExpiry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildToken(t *testing.T) {
|
||||
issuer, manager := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
// Generate secret and blind it
|
||||
secret, _ := bdhke.GenerateSecret()
|
||||
blindResult, _ := bdhke.Blind(secret)
|
||||
pubkey := make([]byte, 32)
|
||||
for i := range pubkey {
|
||||
pubkey[i] = byte(i)
|
||||
}
|
||||
|
||||
// Issue token
|
||||
req := &IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: token.ScopeRelay,
|
||||
Kinds: []int{1, 2, 3},
|
||||
}
|
||||
|
||||
resp, err := issuer.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Fatalf("Issue failed: %v", err)
|
||||
}
|
||||
|
||||
// Build complete token
|
||||
tok, err := BuildToken(resp, secret, blindResult.R, pubkey, token.ScopeRelay, []int{1, 2, 3}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildToken failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify token structure
|
||||
if tok.KeysetID != resp.KeysetID {
|
||||
t.Errorf("KeysetID mismatch: %s != %s", tok.KeysetID, resp.KeysetID)
|
||||
}
|
||||
if tok.Scope != token.ScopeRelay {
|
||||
t.Errorf("Scope = %s, want %s", tok.Scope, token.ScopeRelay)
|
||||
}
|
||||
|
||||
// Verify signature (using the keyset)
|
||||
ks := manager.FindByID(tok.KeysetID)
|
||||
if ks == nil {
|
||||
t.Fatal("Keyset not found")
|
||||
}
|
||||
|
||||
valid, err := bdhke.Verify(tok.Secret, mustParsePoint(tok.Signature), ks.PrivateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
if !valid {
|
||||
t.Error("Token signature is not valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetKeysetInfo(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
infos := issuer.GetKeysetInfo()
|
||||
if len(infos) == 0 {
|
||||
t.Error("GetKeysetInfo returned empty")
|
||||
}
|
||||
|
||||
for _, info := range infos {
|
||||
if info.ID == "" {
|
||||
t.Error("KeysetInfo has empty ID")
|
||||
}
|
||||
if info.PublicKey == "" {
|
||||
t.Error("KeysetInfo has empty PublicKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetActiveKeysetID(t *testing.T) {
|
||||
issuer, _ := setupIssuer(cashuiface.AllowAllChecker{})
|
||||
|
||||
id := issuer.GetActiveKeysetID()
|
||||
if id == "" {
|
||||
t.Error("GetActiveKeysetID returned empty")
|
||||
}
|
||||
if len(id) != 14 {
|
||||
t.Errorf("KeysetID length = %d, want 14", len(id))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMintInfo(t *testing.T) {
|
||||
store := keyset.NewMemoryStore()
|
||||
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
config := DefaultConfig()
|
||||
config.AllowedScopes = []string{token.ScopeRelay, token.ScopeNIP46}
|
||||
|
||||
issuer := New(manager, cashuiface.AllowAllChecker{}, config)
|
||||
|
||||
info := issuer.GetMintInfo("Test Relay")
|
||||
|
||||
if info.Name != "Test Relay" {
|
||||
t.Errorf("Name = %s, want Test Relay", info.Name)
|
||||
}
|
||||
if info.Version != "NIP-XX/1" {
|
||||
t.Errorf("Version = %s, want NIP-XX/1", info.Version)
|
||||
}
|
||||
if len(info.SupportedScopes) != 2 {
|
||||
t.Errorf("SupportedScopes length = %d, want 2", len(info.SupportedScopes))
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to parse point for testing
|
||||
func mustParsePoint(data []byte) *secp256k1.PublicKey {
|
||||
pk, err := secp256k1.ParsePubKey(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return pk
|
||||
}
|
||||
338
pkg/cashu/keyset/keyset.go
Normal file
338
pkg/cashu/keyset/keyset.go
Normal file
@@ -0,0 +1,338 @@
|
||||
// Package keyset manages Cashu mint keysets for blind signature tokens.
|
||||
// Keysets rotate periodically to limit key exposure and provide forward secrecy.
|
||||
package keyset
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
)
|
||||
|
||||
// DefaultActiveWindow is how long a keyset is valid for issuing new tokens.
|
||||
const DefaultActiveWindow = 7 * 24 * time.Hour // 1 week
|
||||
|
||||
// DefaultVerifyWindow is how long a keyset remains valid for verification.
|
||||
const DefaultVerifyWindow = 21 * 24 * time.Hour // 3 weeks
|
||||
|
||||
// Keyset represents a signing keyset with lifecycle management.
|
||||
type Keyset struct {
|
||||
ID string // 14-char hex ID (7 bytes)
|
||||
PrivateKey *secp256k1.PrivateKey // Signing key
|
||||
PublicKey *secp256k1.PublicKey // Verification key
|
||||
CreatedAt time.Time // When keyset was created
|
||||
ActiveAt time.Time // When keyset becomes active for signing
|
||||
ExpiresAt time.Time // When keyset can no longer sign (but can still verify)
|
||||
VerifyEnd time.Time // When keyset can no longer verify
|
||||
Active bool // Whether keyset is currently active for signing
|
||||
}
|
||||
|
||||
// New creates a new keyset with generated keys.
|
||||
func New() (*Keyset, error) {
|
||||
return NewWithTTL(DefaultActiveWindow, DefaultVerifyWindow)
|
||||
}
|
||||
|
||||
// NewWithTTL creates a new keyset with custom lifetimes.
|
||||
func NewWithTTL(activeTTL, verifyTTL time.Duration) (*Keyset, error) {
|
||||
// Generate random private key
|
||||
keyBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(keyBytes); err != nil {
|
||||
return nil, fmt.Errorf("keyset: failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
privKey := secp256k1.PrivKeyFromBytes(keyBytes)
|
||||
pubKey := privKey.PubKey()
|
||||
|
||||
now := time.Now()
|
||||
k := &Keyset{
|
||||
PrivateKey: privKey,
|
||||
PublicKey: pubKey,
|
||||
CreatedAt: now,
|
||||
ActiveAt: now,
|
||||
ExpiresAt: now.Add(activeTTL),
|
||||
VerifyEnd: now.Add(verifyTTL),
|
||||
Active: true,
|
||||
}
|
||||
|
||||
// Calculate ID from public key
|
||||
k.ID = k.calculateID()
|
||||
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// NewFromPrivateKey creates a keyset from an existing private key.
|
||||
func NewFromPrivateKey(privKeyBytes []byte, createdAt time.Time, activeTTL, verifyTTL time.Duration) (*Keyset, error) {
|
||||
if len(privKeyBytes) != 32 {
|
||||
return nil, fmt.Errorf("keyset: private key must be 32 bytes")
|
||||
}
|
||||
|
||||
privKey := secp256k1.PrivKeyFromBytes(privKeyBytes)
|
||||
pubKey := privKey.PubKey()
|
||||
|
||||
k := &Keyset{
|
||||
PrivateKey: privKey,
|
||||
PublicKey: pubKey,
|
||||
CreatedAt: createdAt,
|
||||
ActiveAt: createdAt,
|
||||
ExpiresAt: createdAt.Add(activeTTL),
|
||||
VerifyEnd: createdAt.Add(verifyTTL),
|
||||
Active: true,
|
||||
}
|
||||
|
||||
k.ID = k.calculateID()
|
||||
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// calculateID computes the keyset ID from the public key.
|
||||
// ID = hex(SHA256(compressed_pubkey)[0:7])
|
||||
func (k *Keyset) calculateID() string {
|
||||
compressed := k.PublicKey.SerializeCompressed()
|
||||
hash := sha256.Sum256(compressed)
|
||||
return hex.EncodeToString(hash[:7])
|
||||
}
|
||||
|
||||
// IsActiveForSigning returns true if keyset can be used to sign new tokens.
|
||||
func (k *Keyset) IsActiveForSigning() bool {
|
||||
now := time.Now()
|
||||
return k.Active && now.After(k.ActiveAt) && now.Before(k.ExpiresAt)
|
||||
}
|
||||
|
||||
// IsValidForVerification returns true if keyset can be used to verify tokens.
|
||||
func (k *Keyset) IsValidForVerification() bool {
|
||||
now := time.Now()
|
||||
return now.After(k.ActiveAt) && now.Before(k.VerifyEnd)
|
||||
}
|
||||
|
||||
// Deactivate marks the keyset as no longer active for signing.
|
||||
func (k *Keyset) Deactivate() {
|
||||
k.Active = false
|
||||
}
|
||||
|
||||
// SerializePrivateKey returns the private key as bytes for storage.
|
||||
func (k *Keyset) SerializePrivateKey() []byte {
|
||||
return k.PrivateKey.Serialize()
|
||||
}
|
||||
|
||||
// SerializePublicKey returns the compressed public key.
|
||||
func (k *Keyset) SerializePublicKey() []byte {
|
||||
return k.PublicKey.SerializeCompressed()
|
||||
}
|
||||
|
||||
// KeysetInfo is a public view of a keyset (without private key).
|
||||
type KeysetInfo struct {
|
||||
ID string `json:"id"`
|
||||
PublicKey string `json:"pubkey"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
VerifyEnd int64 `json:"verify_end"`
|
||||
}
|
||||
|
||||
// Info returns public information about the keyset.
|
||||
func (k *Keyset) Info() KeysetInfo {
|
||||
return KeysetInfo{
|
||||
ID: k.ID,
|
||||
PublicKey: hex.EncodeToString(k.SerializePublicKey()),
|
||||
Active: k.IsActiveForSigning(),
|
||||
CreatedAt: k.CreatedAt.Unix(),
|
||||
ExpiresAt: k.ExpiresAt.Unix(),
|
||||
VerifyEnd: k.VerifyEnd.Unix(),
|
||||
}
|
||||
}
|
||||
|
||||
// Manager handles keyset lifecycle including rotation.
|
||||
type Manager struct {
|
||||
store Store
|
||||
activeTTL time.Duration
|
||||
verifyTTL time.Duration
|
||||
|
||||
mu sync.RWMutex
|
||||
current *Keyset // Current active keyset for signing
|
||||
verification []*Keyset // All keysets valid for verification (including current)
|
||||
}
|
||||
|
||||
// NewManager creates a keyset manager.
|
||||
func NewManager(store Store, activeTTL, verifyTTL time.Duration) *Manager {
|
||||
return &Manager{
|
||||
store: store,
|
||||
activeTTL: activeTTL,
|
||||
verifyTTL: verifyTTL,
|
||||
verification: make([]*Keyset, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Init initializes the manager by loading existing keysets or creating a new one.
|
||||
func (m *Manager) Init() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Load all valid keysets from store
|
||||
keysets, err := m.store.ListVerificationKeysets()
|
||||
if err != nil {
|
||||
return fmt.Errorf("manager: failed to load keysets: %w", err)
|
||||
}
|
||||
|
||||
// Find current active keyset
|
||||
var active *Keyset
|
||||
for _, k := range keysets {
|
||||
if k.IsActiveForSigning() {
|
||||
if active == nil || k.CreatedAt.After(active.CreatedAt) {
|
||||
active = k
|
||||
}
|
||||
}
|
||||
if k.IsValidForVerification() {
|
||||
m.verification = append(m.verification, k)
|
||||
}
|
||||
}
|
||||
|
||||
// If no active keyset, create one
|
||||
if active == nil {
|
||||
newKeyset, err := NewWithTTL(m.activeTTL, m.verifyTTL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("manager: failed to create initial keyset: %w", err)
|
||||
}
|
||||
if err := m.store.SaveKeyset(newKeyset); err != nil {
|
||||
return fmt.Errorf("manager: failed to save initial keyset: %w", err)
|
||||
}
|
||||
active = newKeyset
|
||||
m.verification = append(m.verification, newKeyset)
|
||||
}
|
||||
|
||||
m.current = active
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetSigningKeyset returns the current active keyset for signing.
|
||||
func (m *Manager) GetSigningKeyset() *Keyset {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.current
|
||||
}
|
||||
|
||||
// GetVerificationKeysets returns all keysets valid for verification.
|
||||
func (m *Manager) GetVerificationKeysets() []*Keyset {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]*Keyset, 0, len(m.verification))
|
||||
for _, k := range m.verification {
|
||||
if k.IsValidForVerification() {
|
||||
result = append(result, k)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// FindByID returns the keyset with the given ID, if it's valid for verification.
|
||||
func (m *Manager) FindByID(id string) *Keyset {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
for _, k := range m.verification {
|
||||
if k.ID == id && k.IsValidForVerification() {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateIfNeeded checks if rotation is needed and performs it.
|
||||
// Returns true if a new keyset was created.
|
||||
func (m *Manager) RotateIfNeeded() (bool, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Check if current keyset is still active
|
||||
if m.current != nil && m.current.IsActiveForSigning() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Create new keyset
|
||||
newKeyset, err := NewWithTTL(m.activeTTL, m.verifyTTL)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("manager: failed to create new keyset: %w", err)
|
||||
}
|
||||
|
||||
// Deactivate old keyset
|
||||
if m.current != nil {
|
||||
m.current.Deactivate()
|
||||
}
|
||||
|
||||
// Save new keyset
|
||||
if err := m.store.SaveKeyset(newKeyset); err != nil {
|
||||
return false, fmt.Errorf("manager: failed to save new keyset: %w", err)
|
||||
}
|
||||
|
||||
// Update manager state
|
||||
m.current = newKeyset
|
||||
m.verification = append(m.verification, newKeyset)
|
||||
|
||||
// Prune expired verification keysets
|
||||
m.pruneExpired()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// pruneExpired removes keysets that are no longer valid for verification.
|
||||
// Must be called with lock held.
|
||||
func (m *Manager) pruneExpired() {
|
||||
valid := make([]*Keyset, 0, len(m.verification))
|
||||
for _, k := range m.verification {
|
||||
if k.IsValidForVerification() {
|
||||
valid = append(valid, k)
|
||||
}
|
||||
}
|
||||
m.verification = valid
|
||||
}
|
||||
|
||||
// ListKeysetInfo returns public info for all verification keysets.
|
||||
func (m *Manager) ListKeysetInfo() []KeysetInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
result := make([]KeysetInfo, 0, len(m.verification))
|
||||
for _, k := range m.verification {
|
||||
if k.IsValidForVerification() {
|
||||
result = append(result, k.Info())
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// StartRotationTicker starts a goroutine that rotates keysets periodically.
|
||||
// Returns a channel that receives true on each rotation.
|
||||
func (m *Manager) StartRotationTicker(interval time.Duration) (rotated <-chan bool, stop func()) {
|
||||
ticker := time.NewTicker(interval)
|
||||
ch := make(chan bool, 1)
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
rotated, err := m.RotateIfNeeded()
|
||||
if err != nil {
|
||||
// Log error but continue
|
||||
continue
|
||||
}
|
||||
if rotated {
|
||||
select {
|
||||
case ch <- true:
|
||||
default:
|
||||
}
|
||||
}
|
||||
case <-done:
|
||||
ticker.Stop()
|
||||
close(ch)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, func() { close(done) }
|
||||
}
|
||||
278
pkg/cashu/keyset/keyset_test.go
Normal file
278
pkg/cashu/keyset/keyset_test.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package keyset
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewKeyset(t *testing.T) {
|
||||
k, err := New()
|
||||
if err != nil {
|
||||
t.Fatalf("New() failed: %v", err)
|
||||
}
|
||||
|
||||
// Check ID is 14 characters (7 bytes hex)
|
||||
if len(k.ID) != 14 {
|
||||
t.Errorf("ID length = %d, want 14", len(k.ID))
|
||||
}
|
||||
|
||||
// Check keys are set
|
||||
if k.PrivateKey == nil {
|
||||
t.Error("PrivateKey is nil")
|
||||
}
|
||||
if k.PublicKey == nil {
|
||||
t.Error("PublicKey is nil")
|
||||
}
|
||||
|
||||
// Check times are set
|
||||
if k.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt is zero")
|
||||
}
|
||||
if !k.IsActiveForSigning() {
|
||||
t.Error("New keyset should be active for signing")
|
||||
}
|
||||
if !k.IsValidForVerification() {
|
||||
t.Error("New keyset should be valid for verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysetIDDeterministic(t *testing.T) {
|
||||
// Same private key should produce same ID
|
||||
privKeyBytes := make([]byte, 32)
|
||||
for i := range privKeyBytes {
|
||||
privKeyBytes[i] = byte(i)
|
||||
}
|
||||
|
||||
k1, err := NewFromPrivateKey(privKeyBytes, time.Now(), DefaultActiveWindow, DefaultVerifyWindow)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFromPrivateKey failed: %v", err)
|
||||
}
|
||||
|
||||
k2, err := NewFromPrivateKey(privKeyBytes, time.Now(), DefaultActiveWindow, DefaultVerifyWindow)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFromPrivateKey failed: %v", err)
|
||||
}
|
||||
|
||||
if k1.ID != k2.ID {
|
||||
t.Errorf("IDs should match: %s != %s", k1.ID, k2.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysetExpiration(t *testing.T) {
|
||||
// Create keyset with very short TTL
|
||||
k, err := NewWithTTL(100*time.Millisecond, 200*time.Millisecond)
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithTTL failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be active initially
|
||||
if !k.IsActiveForSigning() {
|
||||
t.Error("New keyset should be active for signing")
|
||||
}
|
||||
|
||||
// Wait for signing to expire
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
if k.IsActiveForSigning() {
|
||||
t.Error("Keyset should not be active for signing after expiry")
|
||||
}
|
||||
if !k.IsValidForVerification() {
|
||||
t.Error("Keyset should still be valid for verification")
|
||||
}
|
||||
|
||||
// Wait for verification to expire
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if k.IsValidForVerification() {
|
||||
t.Error("Keyset should not be valid for verification after verify expiry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysetDeactivate(t *testing.T) {
|
||||
k, _ := New()
|
||||
|
||||
if !k.Active {
|
||||
t.Error("New keyset should be active")
|
||||
}
|
||||
|
||||
k.Deactivate()
|
||||
|
||||
if k.Active {
|
||||
t.Error("Keyset should not be active after Deactivate()")
|
||||
}
|
||||
if k.IsActiveForSigning() {
|
||||
t.Error("Deactivated keyset should not be active for signing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeysetInfo(t *testing.T) {
|
||||
k, _ := New()
|
||||
info := k.Info()
|
||||
|
||||
if info.ID != k.ID {
|
||||
t.Errorf("Info ID = %s, want %s", info.ID, k.ID)
|
||||
}
|
||||
if len(info.PublicKey) != 66 { // 33 bytes * 2 hex chars
|
||||
t.Errorf("Info PublicKey length = %d, want 66", len(info.PublicKey))
|
||||
}
|
||||
if !info.Active {
|
||||
t.Error("Info Active should be true for new keyset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
manager := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
|
||||
|
||||
if err := manager.Init(); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have a signing keyset
|
||||
signing := manager.GetSigningKeyset()
|
||||
if signing == nil {
|
||||
t.Fatal("GetSigningKeyset returned nil")
|
||||
}
|
||||
|
||||
// Should have at least one verification keyset
|
||||
verification := manager.GetVerificationKeysets()
|
||||
if len(verification) == 0 {
|
||||
t.Error("GetVerificationKeysets returned empty")
|
||||
}
|
||||
|
||||
// Should find keyset by ID
|
||||
found := manager.FindByID(signing.ID)
|
||||
if found == nil {
|
||||
t.Error("FindByID returned nil for signing keyset")
|
||||
}
|
||||
if found.ID != signing.ID {
|
||||
t.Errorf("FindByID returned wrong keyset: %s != %s", found.ID, signing.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerRotation(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
manager := NewManager(store, 50*time.Millisecond, 200*time.Millisecond)
|
||||
|
||||
if err := manager.Init(); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
initialID := manager.GetSigningKeyset().ID
|
||||
|
||||
// Rotation should not happen yet
|
||||
rotated, err := manager.RotateIfNeeded()
|
||||
if err != nil {
|
||||
t.Fatalf("RotateIfNeeded failed: %v", err)
|
||||
}
|
||||
if rotated {
|
||||
t.Error("Should not rotate when keyset is still active")
|
||||
}
|
||||
|
||||
// Wait for signing to expire
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
|
||||
// Now rotation should happen
|
||||
rotated, err = manager.RotateIfNeeded()
|
||||
if err != nil {
|
||||
t.Fatalf("RotateIfNeeded failed: %v", err)
|
||||
}
|
||||
if !rotated {
|
||||
t.Error("Should rotate when keyset is expired")
|
||||
}
|
||||
|
||||
newID := manager.GetSigningKeyset().ID
|
||||
if newID == initialID {
|
||||
t.Error("New keyset should have different ID")
|
||||
}
|
||||
|
||||
// Old keyset should still be valid for verification
|
||||
old := manager.FindByID(initialID)
|
||||
if old == nil {
|
||||
t.Error("Old keyset should still be found for verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerPersistence(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
// First manager creates keyset
|
||||
m1 := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
|
||||
if err := m1.Init(); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
id := m1.GetSigningKeyset().ID
|
||||
|
||||
// Second manager should load existing keyset
|
||||
m2 := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
|
||||
if err := m2.Init(); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
if m2.GetSigningKeyset().ID != id {
|
||||
t.Error("Second manager should use same keyset as first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagerListKeysetInfo(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
manager := NewManager(store, DefaultActiveWindow, DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
infos := manager.ListKeysetInfo()
|
||||
if len(infos) == 0 {
|
||||
t.Error("ListKeysetInfo returned empty")
|
||||
}
|
||||
|
||||
for _, info := range infos {
|
||||
if info.ID == "" {
|
||||
t.Error("KeysetInfo has empty ID")
|
||||
}
|
||||
if info.PublicKey == "" {
|
||||
t.Error("KeysetInfo has empty PublicKey")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStore(t *testing.T) {
|
||||
store := NewMemoryStore()
|
||||
|
||||
k, _ := New()
|
||||
|
||||
// Save
|
||||
if err := store.SaveKeyset(k); err != nil {
|
||||
t.Fatalf("SaveKeyset failed: %v", err)
|
||||
}
|
||||
|
||||
// Load
|
||||
loaded, err := store.LoadKeyset(k.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKeyset failed: %v", err)
|
||||
}
|
||||
if loaded == nil {
|
||||
t.Fatal("LoadKeyset returned nil")
|
||||
}
|
||||
if loaded.ID != k.ID {
|
||||
t.Errorf("Loaded ID = %s, want %s", loaded.ID, k.ID)
|
||||
}
|
||||
|
||||
// List active
|
||||
active, err := store.ListActiveKeysets()
|
||||
if err != nil {
|
||||
t.Fatalf("ListActiveKeysets failed: %v", err)
|
||||
}
|
||||
if len(active) != 1 {
|
||||
t.Errorf("ListActiveKeysets returned %d, want 1", len(active))
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := store.DeleteKeyset(k.ID); err != nil {
|
||||
t.Fatalf("DeleteKeyset failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be gone
|
||||
loaded, _ = store.LoadKeyset(k.ID)
|
||||
if loaded != nil {
|
||||
t.Error("Keyset should be deleted")
|
||||
}
|
||||
}
|
||||
74
pkg/cashu/keyset/store.go
Normal file
74
pkg/cashu/keyset/store.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package keyset
|
||||
|
||||
// Store is the interface for persisting keysets.
|
||||
// Implement this interface for your database backend.
|
||||
type Store interface {
|
||||
// SaveKeyset persists a keyset.
|
||||
SaveKeyset(k *Keyset) error
|
||||
|
||||
// LoadKeyset loads a keyset by ID.
|
||||
LoadKeyset(id string) (*Keyset, error)
|
||||
|
||||
// ListActiveKeysets returns all keysets that can be used for signing.
|
||||
ListActiveKeysets() ([]*Keyset, error)
|
||||
|
||||
// ListVerificationKeysets returns all keysets that can be used for verification.
|
||||
ListVerificationKeysets() ([]*Keyset, error)
|
||||
|
||||
// DeleteKeyset removes a keyset from storage.
|
||||
DeleteKeyset(id string) error
|
||||
}
|
||||
|
||||
// MemoryStore is an in-memory implementation of Store for testing.
|
||||
type MemoryStore struct {
|
||||
keysets map[string]*Keyset
|
||||
}
|
||||
|
||||
// NewMemoryStore creates a new in-memory store.
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{
|
||||
keysets: make(map[string]*Keyset),
|
||||
}
|
||||
}
|
||||
|
||||
// SaveKeyset saves a keyset to memory.
|
||||
func (s *MemoryStore) SaveKeyset(k *Keyset) error {
|
||||
s.keysets[k.ID] = k
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadKeyset loads a keyset by ID.
|
||||
func (s *MemoryStore) LoadKeyset(id string) (*Keyset, error) {
|
||||
if k, ok := s.keysets[id]; ok {
|
||||
return k, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ListActiveKeysets returns all active keysets.
|
||||
func (s *MemoryStore) ListActiveKeysets() ([]*Keyset, error) {
|
||||
result := make([]*Keyset, 0)
|
||||
for _, k := range s.keysets {
|
||||
if k.IsActiveForSigning() {
|
||||
result = append(result, k)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ListVerificationKeysets returns all keysets valid for verification.
|
||||
func (s *MemoryStore) ListVerificationKeysets() ([]*Keyset, error) {
|
||||
result := make([]*Keyset, 0)
|
||||
for _, k := range s.keysets {
|
||||
if k.IsValidForVerification() {
|
||||
result = append(result, k)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteKeyset removes a keyset.
|
||||
func (s *MemoryStore) DeleteKeyset(id string) error {
|
||||
delete(s.keysets, id)
|
||||
return nil
|
||||
}
|
||||
345
pkg/cashu/token/token.go
Normal file
345
pkg/cashu/token/token.go
Normal file
@@ -0,0 +1,345 @@
|
||||
// Package token implements the Cashu access token format as defined in NIP-XX.
|
||||
// Tokens are privacy-preserving bearer credentials with kind permissions.
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Prefix for serialized tokens.
|
||||
const Prefix = "cashuA"
|
||||
|
||||
// Predefined scopes.
|
||||
const (
|
||||
ScopeRelay = "relay" // Standard relay WebSocket access
|
||||
ScopeNIP46 = "nip46" // NIP-46 remote signing / bunker
|
||||
ScopeBlossom = "blossom" // Blossom media server
|
||||
ScopeAPI = "api" // HTTP API access
|
||||
)
|
||||
|
||||
// WildcardKind indicates all kinds are permitted.
|
||||
const WildcardKind = -1
|
||||
|
||||
// Errors.
|
||||
var (
|
||||
ErrInvalidPrefix = errors.New("token: invalid prefix, expected cashuA")
|
||||
ErrInvalidEncoding = errors.New("token: invalid base64url encoding")
|
||||
ErrInvalidJSON = errors.New("token: invalid JSON structure")
|
||||
ErrTokenExpired = errors.New("token: expired")
|
||||
ErrKindNotPermitted = errors.New("token: kind not permitted")
|
||||
ErrScopeMismatch = errors.New("token: scope mismatch")
|
||||
)
|
||||
|
||||
// Token represents a Cashu access token with kind permissions.
|
||||
type Token struct {
|
||||
// Cryptographic fields
|
||||
KeysetID string `json:"k"` // Keyset ID (hex)
|
||||
Secret []byte `json:"s"` // Random secret (32 bytes)
|
||||
Signature []byte `json:"c"` // Blind signature (33 bytes compressed)
|
||||
Pubkey []byte `json:"p"` // User's Nostr pubkey (32 bytes)
|
||||
|
||||
// Metadata
|
||||
Expiry int64 `json:"e"` // Unix timestamp when token expires
|
||||
Scope string `json:"sc"` // Token scope (relay, nip46, etc.)
|
||||
|
||||
// Kind permissions
|
||||
Kinds []int `json:"kinds,omitempty"` // Explicit list of permitted kinds
|
||||
KindRanges [][]int `json:"kind_ranges,omitempty"` // Ranges as [min, max] pairs
|
||||
}
|
||||
|
||||
// tokenJSON is the JSON-serializable form with hex-encoded bytes.
|
||||
type tokenJSON struct {
|
||||
KeysetID string `json:"k"`
|
||||
Secret string `json:"s"`
|
||||
Signature string `json:"c"`
|
||||
Pubkey string `json:"p"`
|
||||
Expiry int64 `json:"e"`
|
||||
Scope string `json:"sc"`
|
||||
Kinds []int `json:"kinds,omitempty"`
|
||||
KindRanges [][]int `json:"kind_ranges,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new token with the given parameters.
|
||||
func New(keysetID string, secret, signature, pubkey []byte, expiry time.Time, scope string) *Token {
|
||||
return &Token{
|
||||
KeysetID: keysetID,
|
||||
Secret: secret,
|
||||
Signature: signature,
|
||||
Pubkey: pubkey,
|
||||
Expiry: expiry.Unix(),
|
||||
Scope: scope,
|
||||
}
|
||||
}
|
||||
|
||||
// SetKinds sets explicit permitted kinds.
|
||||
// Use WildcardKind (-1) to allow all kinds.
|
||||
func (t *Token) SetKinds(kinds ...int) {
|
||||
t.Kinds = kinds
|
||||
}
|
||||
|
||||
// SetKindRanges sets permitted kind ranges.
|
||||
// Each range is [min, max] inclusive.
|
||||
func (t *Token) SetKindRanges(ranges ...[]int) {
|
||||
t.KindRanges = ranges
|
||||
}
|
||||
|
||||
// AddKindRange adds a single kind range.
|
||||
func (t *Token) AddKindRange(min, max int) {
|
||||
t.KindRanges = append(t.KindRanges, []int{min, max})
|
||||
}
|
||||
|
||||
// IsExpired returns true if the token has expired.
|
||||
func (t *Token) IsExpired() bool {
|
||||
return time.Now().Unix() > t.Expiry
|
||||
}
|
||||
|
||||
// ExpiresAt returns the expiry time.
|
||||
func (t *Token) ExpiresAt() time.Time {
|
||||
return time.Unix(t.Expiry, 0)
|
||||
}
|
||||
|
||||
// TimeRemaining returns the duration until expiry.
|
||||
func (t *Token) TimeRemaining() time.Duration {
|
||||
return time.Until(t.ExpiresAt())
|
||||
}
|
||||
|
||||
// IsKindPermitted checks if a given event kind is permitted by this token.
|
||||
func (t *Token) IsKindPermitted(kind int) bool {
|
||||
// Check for wildcard
|
||||
for _, k := range t.Kinds {
|
||||
if k == WildcardKind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check explicit kinds
|
||||
for _, k := range t.Kinds {
|
||||
if k == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check kind ranges
|
||||
for _, r := range t.KindRanges {
|
||||
if len(r) >= 2 && kind >= r[0] && kind <= r[1] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// If no kinds or ranges specified, check scope defaults
|
||||
if len(t.Kinds) == 0 && len(t.KindRanges) == 0 {
|
||||
return t.defaultKindPermitted(kind)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultKindPermitted returns default permissions based on scope.
|
||||
func (t *Token) defaultKindPermitted(kind int) bool {
|
||||
switch t.Scope {
|
||||
case ScopeRelay:
|
||||
// Default relay scope allows common kinds
|
||||
return true
|
||||
case ScopeNIP46:
|
||||
// NIP-46 scope allows NIP-46 kinds (24133)
|
||||
return kind == 24133
|
||||
case ScopeBlossom:
|
||||
// Blossom scope allows auth kinds
|
||||
return kind == 24242
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// HasWritePermission returns true if any kind is permitted (not read-only).
|
||||
func (t *Token) HasWritePermission() bool {
|
||||
return len(t.Kinds) > 0 || len(t.KindRanges) > 0
|
||||
}
|
||||
|
||||
// IsReadOnly returns true if no kinds are permitted.
|
||||
func (t *Token) IsReadOnly() bool {
|
||||
return !t.HasWritePermission()
|
||||
}
|
||||
|
||||
// MatchesScope checks if the token scope matches the required scope.
|
||||
func (t *Token) MatchesScope(requiredScope string) bool {
|
||||
return t.Scope == requiredScope
|
||||
}
|
||||
|
||||
// PubkeyHex returns the pubkey as a hex string.
|
||||
func (t *Token) PubkeyHex() string {
|
||||
return hex.EncodeToString(t.Pubkey)
|
||||
}
|
||||
|
||||
// Encode serializes the token to the wire format: cashuA<base64url(json)>
|
||||
func (t *Token) Encode() (string, error) {
|
||||
// Convert to JSON-friendly format
|
||||
tj := tokenJSON{
|
||||
KeysetID: t.KeysetID,
|
||||
Secret: hex.EncodeToString(t.Secret),
|
||||
Signature: hex.EncodeToString(t.Signature),
|
||||
Pubkey: hex.EncodeToString(t.Pubkey),
|
||||
Expiry: t.Expiry,
|
||||
Scope: t.Scope,
|
||||
Kinds: t.Kinds,
|
||||
KindRanges: t.KindRanges,
|
||||
}
|
||||
|
||||
jsonBytes, err := json.Marshal(tj)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token: failed to encode: %w", err)
|
||||
}
|
||||
|
||||
encoded := base64.RawURLEncoding.EncodeToString(jsonBytes)
|
||||
return Prefix + encoded, nil
|
||||
}
|
||||
|
||||
// Parse decodes a token from the wire format.
|
||||
func Parse(s string) (*Token, error) {
|
||||
// Check prefix
|
||||
if !strings.HasPrefix(s, Prefix) {
|
||||
return nil, ErrInvalidPrefix
|
||||
}
|
||||
|
||||
// Decode base64url
|
||||
encoded := strings.TrimPrefix(s, Prefix)
|
||||
jsonBytes, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidEncoding, err)
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
var tj tokenJSON
|
||||
if err := json.Unmarshal(jsonBytes, &tj); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidJSON, err)
|
||||
}
|
||||
|
||||
// Decode hex fields
|
||||
secret, err := hex.DecodeString(tj.Secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token: invalid secret hex: %w", err)
|
||||
}
|
||||
|
||||
signature, err := hex.DecodeString(tj.Signature)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token: invalid signature hex: %w", err)
|
||||
}
|
||||
|
||||
pubkey, err := hex.DecodeString(tj.Pubkey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token: invalid pubkey hex: %w", err)
|
||||
}
|
||||
|
||||
return &Token{
|
||||
KeysetID: tj.KeysetID,
|
||||
Secret: secret,
|
||||
Signature: signature,
|
||||
Pubkey: pubkey,
|
||||
Expiry: tj.Expiry,
|
||||
Scope: tj.Scope,
|
||||
Kinds: tj.Kinds,
|
||||
KindRanges: tj.KindRanges,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ParseFromHeader extracts and parses a token from HTTP headers.
|
||||
// Supports:
|
||||
// - X-Cashu-Token: cashuA...
|
||||
// - Authorization: Cashu cashuA...
|
||||
func ParseFromHeader(header string) (*Token, error) {
|
||||
// Try X-Cashu-Token format (raw token)
|
||||
if strings.HasPrefix(header, Prefix) {
|
||||
return Parse(header)
|
||||
}
|
||||
|
||||
// Try Authorization format
|
||||
if strings.HasPrefix(header, "Cashu ") {
|
||||
tokenStr := strings.TrimPrefix(header, "Cashu ")
|
||||
return Parse(strings.TrimSpace(tokenStr))
|
||||
}
|
||||
|
||||
return nil, ErrInvalidPrefix
|
||||
}
|
||||
|
||||
// Validate performs basic validation on the token.
|
||||
// Does NOT verify the cryptographic signature - use Verifier for that.
|
||||
func (t *Token) Validate() error {
|
||||
if t.IsExpired() {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
|
||||
if len(t.KeysetID) != 14 {
|
||||
return fmt.Errorf("token: invalid keyset ID length: %d", len(t.KeysetID))
|
||||
}
|
||||
|
||||
if len(t.Secret) != 32 {
|
||||
return fmt.Errorf("token: invalid secret length: %d", len(t.Secret))
|
||||
}
|
||||
|
||||
if len(t.Signature) != 33 {
|
||||
return fmt.Errorf("token: invalid signature length: %d", len(t.Signature))
|
||||
}
|
||||
|
||||
if len(t.Pubkey) != 32 {
|
||||
return fmt.Errorf("token: invalid pubkey length: %d", len(t.Pubkey))
|
||||
}
|
||||
|
||||
if t.Scope == "" {
|
||||
return errors.New("token: missing scope")
|
||||
}
|
||||
|
||||
// Validate kind ranges
|
||||
for i, r := range t.KindRanges {
|
||||
if len(r) != 2 {
|
||||
return fmt.Errorf("token: kind range %d must have 2 elements", i)
|
||||
}
|
||||
if r[0] > r[1] {
|
||||
return fmt.Errorf("token: kind range %d min > max: %d > %d", i, r[0], r[1])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone creates a copy of the token.
|
||||
func (t *Token) Clone() *Token {
|
||||
clone := &Token{
|
||||
KeysetID: t.KeysetID,
|
||||
Secret: make([]byte, len(t.Secret)),
|
||||
Signature: make([]byte, len(t.Signature)),
|
||||
Pubkey: make([]byte, len(t.Pubkey)),
|
||||
Expiry: t.Expiry,
|
||||
Scope: t.Scope,
|
||||
}
|
||||
|
||||
copy(clone.Secret, t.Secret)
|
||||
copy(clone.Signature, t.Signature)
|
||||
copy(clone.Pubkey, t.Pubkey)
|
||||
|
||||
if len(t.Kinds) > 0 {
|
||||
clone.Kinds = make([]int, len(t.Kinds))
|
||||
copy(clone.Kinds, t.Kinds)
|
||||
}
|
||||
|
||||
if len(t.KindRanges) > 0 {
|
||||
clone.KindRanges = make([][]int, len(t.KindRanges))
|
||||
for i, r := range t.KindRanges {
|
||||
clone.KindRanges[i] = make([]int, len(r))
|
||||
copy(clone.KindRanges[i], r)
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// String returns the encoded token string.
|
||||
func (t *Token) String() string {
|
||||
s, _ := t.Encode()
|
||||
return s
|
||||
}
|
||||
336
pkg/cashu/token/token_test.go
Normal file
336
pkg/cashu/token/token_test.go
Normal file
@@ -0,0 +1,336 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeTestToken() *Token {
|
||||
secret := make([]byte, 32)
|
||||
signature := make([]byte, 33)
|
||||
pubkey := make([]byte, 32)
|
||||
|
||||
for i := range secret {
|
||||
secret[i] = byte(i)
|
||||
}
|
||||
for i := range signature {
|
||||
signature[i] = byte(i + 32)
|
||||
}
|
||||
for i := range pubkey {
|
||||
pubkey[i] = byte(i + 64)
|
||||
}
|
||||
|
||||
signature[0] = 0x02 // Valid compressed point prefix
|
||||
|
||||
return New(
|
||||
"0a1b2c3d4e5f67",
|
||||
secret,
|
||||
signature,
|
||||
pubkey,
|
||||
time.Now().Add(time.Hour),
|
||||
ScopeRelay,
|
||||
)
|
||||
}
|
||||
|
||||
func TestTokenEncodeDecode(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3, 7)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
|
||||
encoded, err := tok.Encode()
|
||||
if err != nil {
|
||||
t.Fatalf("Encode failed: %v", err)
|
||||
}
|
||||
|
||||
// Should have correct prefix
|
||||
if encoded[:6] != Prefix {
|
||||
t.Errorf("Encoded token should start with %s, got %s", Prefix, encoded[:6])
|
||||
}
|
||||
|
||||
// Decode
|
||||
decoded, err := Parse(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
|
||||
// Compare fields
|
||||
if decoded.KeysetID != tok.KeysetID {
|
||||
t.Errorf("KeysetID mismatch: %s != %s", decoded.KeysetID, tok.KeysetID)
|
||||
}
|
||||
if hex.EncodeToString(decoded.Secret) != hex.EncodeToString(tok.Secret) {
|
||||
t.Error("Secret mismatch")
|
||||
}
|
||||
if hex.EncodeToString(decoded.Signature) != hex.EncodeToString(tok.Signature) {
|
||||
t.Error("Signature mismatch")
|
||||
}
|
||||
if hex.EncodeToString(decoded.Pubkey) != hex.EncodeToString(tok.Pubkey) {
|
||||
t.Error("Pubkey mismatch")
|
||||
}
|
||||
if decoded.Expiry != tok.Expiry {
|
||||
t.Errorf("Expiry mismatch: %d != %d", decoded.Expiry, tok.Expiry)
|
||||
}
|
||||
if decoded.Scope != tok.Scope {
|
||||
t.Errorf("Scope mismatch: %s != %s", decoded.Scope, tok.Scope)
|
||||
}
|
||||
|
||||
// Check kinds
|
||||
if len(decoded.Kinds) != len(tok.Kinds) {
|
||||
t.Errorf("Kinds length mismatch: %d != %d", len(decoded.Kinds), len(tok.Kinds))
|
||||
}
|
||||
for i, k := range decoded.Kinds {
|
||||
if k != tok.Kinds[i] {
|
||||
t.Errorf("Kinds[%d] mismatch: %d != %d", i, k, tok.Kinds[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Check kind ranges
|
||||
if len(decoded.KindRanges) != len(tok.KindRanges) {
|
||||
t.Errorf("KindRanges length mismatch: %d != %d", len(decoded.KindRanges), len(tok.KindRanges))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenKindPermissions(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
|
||||
tests := []struct {
|
||||
kind int
|
||||
expected bool
|
||||
}{
|
||||
{0, true}, // Explicit kind
|
||||
{1, true}, // Explicit kind
|
||||
{3, true}, // Explicit kind
|
||||
{2, false}, // Not in list
|
||||
{7, false}, // Not in list
|
||||
{30000, true}, // Start of range
|
||||
{35000, true}, // Middle of range
|
||||
{39999, true}, // End of range
|
||||
{29999, false}, // Just before range
|
||||
{40000, false}, // Just after range
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := tok.IsKindPermitted(tt.kind)
|
||||
if result != tt.expected {
|
||||
t.Errorf("IsKindPermitted(%d) = %v, want %v", tt.kind, result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenWildcardKind(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(WildcardKind)
|
||||
|
||||
// All kinds should be permitted
|
||||
for _, kind := range []int{0, 1, 100, 1000, 30000, 65535} {
|
||||
if !tok.IsKindPermitted(kind) {
|
||||
t.Errorf("Wildcard should permit kind %d", kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenReadOnly(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
|
||||
// No kinds set - should be read-only by kinds check
|
||||
if tok.HasWritePermission() {
|
||||
t.Error("Token with no kinds should not have write permission")
|
||||
}
|
||||
|
||||
tok.SetKinds(1)
|
||||
if !tok.HasWritePermission() {
|
||||
t.Error("Token with kinds should have write permission")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenExpiry(t *testing.T) {
|
||||
// Token that expires in 1 hour
|
||||
tok := makeTestToken()
|
||||
if tok.IsExpired() {
|
||||
t.Error("Token should not be expired yet")
|
||||
}
|
||||
|
||||
// Token that expired 1 hour ago
|
||||
tok.Expiry = time.Now().Add(-time.Hour).Unix()
|
||||
if !tok.IsExpired() {
|
||||
t.Error("Token should be expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenTimeRemaining(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
remaining := tok.TimeRemaining()
|
||||
|
||||
// Should be close to 1 hour
|
||||
if remaining < 59*time.Minute || remaining > 61*time.Minute {
|
||||
t.Errorf("TimeRemaining = %v, expected ~1 hour", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenValidate(t *testing.T) {
|
||||
// Valid token
|
||||
tok := makeTestToken()
|
||||
if err := tok.Validate(); err != nil {
|
||||
t.Errorf("Validate failed for valid token: %v", err)
|
||||
}
|
||||
|
||||
// Expired token
|
||||
expired := makeTestToken()
|
||||
expired.Expiry = time.Now().Add(-time.Hour).Unix()
|
||||
if err := expired.Validate(); err != ErrTokenExpired {
|
||||
t.Errorf("Validate should return ErrTokenExpired, got %v", err)
|
||||
}
|
||||
|
||||
// Invalid keyset ID
|
||||
badKeyset := makeTestToken()
|
||||
badKeyset.KeysetID = "short"
|
||||
if err := badKeyset.Validate(); err == nil {
|
||||
t.Error("Validate should fail for short keyset ID")
|
||||
}
|
||||
|
||||
// Invalid secret length
|
||||
badSecret := makeTestToken()
|
||||
badSecret.Secret = []byte{1, 2, 3}
|
||||
if err := badSecret.Validate(); err == nil {
|
||||
t.Error("Validate should fail for wrong secret length")
|
||||
}
|
||||
|
||||
// Invalid kind range
|
||||
badRange := makeTestToken()
|
||||
badRange.KindRanges = [][]int{{100, 50}} // min > max
|
||||
if err := badRange.Validate(); err == nil {
|
||||
t.Error("Validate should fail for invalid kind range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFromHeader(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
// Test X-Cashu-Token format
|
||||
parsed, err := ParseFromHeader(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFromHeader failed for raw token: %v", err)
|
||||
}
|
||||
if parsed.KeysetID != tok.KeysetID {
|
||||
t.Error("Parsed token has wrong KeysetID")
|
||||
}
|
||||
|
||||
// Test Authorization format
|
||||
parsed, err = ParseFromHeader("Cashu " + encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFromHeader failed for Authorization format: %v", err)
|
||||
}
|
||||
if parsed.KeysetID != tok.KeysetID {
|
||||
t.Error("Parsed token has wrong KeysetID")
|
||||
}
|
||||
|
||||
// Test invalid format
|
||||
_, err = ParseFromHeader("Bearer xyz")
|
||||
if err != ErrInvalidPrefix {
|
||||
t.Errorf("Expected ErrInvalidPrefix, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenClone(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(1, 2, 3)
|
||||
tok.AddKindRange(100, 200)
|
||||
|
||||
clone := tok.Clone()
|
||||
|
||||
// Modify original
|
||||
tok.Secret[0] = 0xFF
|
||||
tok.Kinds[0] = 999
|
||||
tok.KindRanges[0][0] = 999
|
||||
|
||||
// Clone should be unchanged
|
||||
if clone.Secret[0] == 0xFF {
|
||||
t.Error("Clone secret was modified when original changed")
|
||||
}
|
||||
if clone.Kinds[0] == 999 {
|
||||
t.Error("Clone kinds was modified when original changed")
|
||||
}
|
||||
if clone.KindRanges[0][0] == 999 {
|
||||
t.Error("Clone kind ranges was modified when original changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenMatchesScope(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
tok.Scope = ScopeNIP46
|
||||
|
||||
if !tok.MatchesScope(ScopeNIP46) {
|
||||
t.Error("Should match ScopeNIP46")
|
||||
}
|
||||
if tok.MatchesScope(ScopeRelay) {
|
||||
t.Error("Should not match ScopeRelay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenPubkeyHex(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
hexPubkey := tok.PubkeyHex()
|
||||
|
||||
// Should be 64 characters (32 bytes * 2)
|
||||
if len(hexPubkey) != 64 {
|
||||
t.Errorf("PubkeyHex length = %d, want 64", len(hexPubkey))
|
||||
}
|
||||
|
||||
// Should decode back to original
|
||||
decoded, err := hex.DecodeString(hexPubkey)
|
||||
if err != nil {
|
||||
t.Fatalf("PubkeyHex is not valid hex: %v", err)
|
||||
}
|
||||
for i, b := range decoded {
|
||||
if b != tok.Pubkey[i] {
|
||||
t.Errorf("PubkeyHex[%d] mismatch", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenString(t *testing.T) {
|
||||
tok := makeTestToken()
|
||||
s := tok.String()
|
||||
|
||||
if s[:6] != Prefix {
|
||||
t.Errorf("String() should start with prefix, got %s", s[:6])
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTokenEncode(b *testing.B) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3, 7)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tok.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTokenParse(b *testing.B) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3, 7)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
Parse(encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkTokenIsKindPermitted(b *testing.B) {
|
||||
tok := makeTestToken()
|
||||
tok.SetKinds(0, 1, 3, 7, 10, 20, 30, 40, 50)
|
||||
tok.AddKindRange(30000, 39999)
|
||||
tok.AddKindRange(20000, 29999)
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
tok.IsKindPermitted(35000)
|
||||
}
|
||||
}
|
||||
138
pkg/cashu/verifier/middleware.go
Normal file
138
pkg/cashu/verifier/middleware.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
)
|
||||
|
||||
// ContextKey is the type for context keys.
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
// TokenContextKey is the context key for the verified token.
|
||||
TokenContextKey ContextKey = "cashu_token"
|
||||
|
||||
// PubkeyContextKey is the context key for the user's pubkey.
|
||||
PubkeyContextKey ContextKey = "cashu_pubkey"
|
||||
)
|
||||
|
||||
// TokenFromContext extracts the verified token from the request context.
|
||||
func TokenFromContext(ctx context.Context) *token.Token {
|
||||
if tok, ok := ctx.Value(TokenContextKey).(*token.Token); ok {
|
||||
return tok
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PubkeyFromContext extracts the user's pubkey from the request context.
|
||||
func PubkeyFromContext(ctx context.Context) []byte {
|
||||
if pubkey, ok := ctx.Value(PubkeyContextKey).([]byte); ok {
|
||||
return pubkey
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Middleware creates an HTTP middleware that verifies Cashu tokens.
|
||||
func Middleware(v *Verifier, requiredScope string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Add token and pubkey to context
|
||||
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
|
||||
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MiddlewareForKind creates middleware that also checks kind permission.
|
||||
func MiddlewareForKind(v *Verifier, requiredScope string, kind int) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tok, err := v.VerifyRequestForKind(r.Context(), r, requiredScope, kind)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
|
||||
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// OptionalMiddleware creates middleware that verifies tokens if present,
|
||||
// but allows requests without tokens to proceed.
|
||||
func OptionalMiddleware(v *Verifier, requiredScope string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
|
||||
if err == nil {
|
||||
// Token present and valid - add to context
|
||||
ctx := context.WithValue(r.Context(), TokenContextKey, tok)
|
||||
ctx = context.WithValue(ctx, PubkeyContextKey, tok.Pubkey)
|
||||
r = r.WithContext(ctx)
|
||||
} else if err != ErrMissingToken {
|
||||
// Token present but invalid - reject
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
// No token or valid token - proceed
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeError writes an appropriate HTTP error response.
|
||||
func writeError(w http.ResponseWriter, err error) {
|
||||
switch err {
|
||||
case ErrMissingToken:
|
||||
http.Error(w, "Missing token", http.StatusUnauthorized)
|
||||
case ErrTokenExpired:
|
||||
http.Error(w, "Token expired", http.StatusGone)
|
||||
case ErrUnknownKeyset:
|
||||
http.Error(w, "Unknown keyset", http.StatusMisdirectedRequest)
|
||||
case ErrInvalidSignature:
|
||||
http.Error(w, "Invalid signature", http.StatusUnauthorized)
|
||||
case ErrScopeMismatch:
|
||||
http.Error(w, "Scope mismatch", http.StatusForbidden)
|
||||
case ErrKindNotPermitted:
|
||||
http.Error(w, "Kind not permitted", http.StatusForbidden)
|
||||
case ErrAccessRevoked:
|
||||
http.Error(w, "Access revoked", http.StatusForbidden)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// RequireToken is a helper that extracts and verifies a token inline.
|
||||
// Returns the token or writes an error response and returns nil.
|
||||
func RequireToken(v *Verifier, w http.ResponseWriter, r *http.Request, requiredScope string) *token.Token {
|
||||
tok, err := v.VerifyRequest(r.Context(), r, requiredScope)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return nil
|
||||
}
|
||||
return tok
|
||||
}
|
||||
|
||||
// RequireKind is a helper that also checks kind permission inline.
|
||||
func RequireKind(v *Verifier, w http.ResponseWriter, r *http.Request, requiredScope string, kind int) *token.Token {
|
||||
tok, err := v.VerifyRequestForKind(r.Context(), r, requiredScope, kind)
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return nil
|
||||
}
|
||||
return tok
|
||||
}
|
||||
186
pkg/cashu/verifier/verifier.go
Normal file
186
pkg/cashu/verifier/verifier.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// Package verifier implements Cashu token verification with optional re-authorization.
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
|
||||
"next.orly.dev/pkg/cashu/bdhke"
|
||||
"next.orly.dev/pkg/cashu/keyset"
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
cashuiface "next.orly.dev/pkg/interfaces/cashu"
|
||||
)
|
||||
|
||||
// Errors.
|
||||
var (
|
||||
ErrTokenExpired = errors.New("verifier: token expired")
|
||||
ErrUnknownKeyset = errors.New("verifier: unknown keyset")
|
||||
ErrInvalidSignature = errors.New("verifier: invalid signature")
|
||||
ErrScopeMismatch = errors.New("verifier: scope mismatch")
|
||||
ErrKindNotPermitted = errors.New("verifier: kind not permitted")
|
||||
ErrAccessRevoked = errors.New("verifier: access revoked")
|
||||
ErrMissingToken = errors.New("verifier: missing token")
|
||||
)
|
||||
|
||||
// Config holds verifier configuration.
|
||||
type Config struct {
|
||||
// Reauthorize enables re-checking authorization on each verification.
|
||||
// This provides "stateless revocation" at the cost of an extra check.
|
||||
Reauthorize bool
|
||||
}
|
||||
|
||||
// DefaultConfig returns sensible default configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Reauthorize: true, // Enable stateless revocation by default
|
||||
}
|
||||
}
|
||||
|
||||
// Verifier validates Cashu tokens and checks permissions.
|
||||
type Verifier struct {
|
||||
keysets *keyset.Manager
|
||||
authz cashuiface.AuthzChecker
|
||||
claimValidator cashuiface.ClaimValidator
|
||||
config Config
|
||||
}
|
||||
|
||||
// New creates a new verifier.
|
||||
func New(keysets *keyset.Manager, authz cashuiface.AuthzChecker, config Config) *Verifier {
|
||||
return &Verifier{
|
||||
keysets: keysets,
|
||||
authz: authz,
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// SetClaimValidator sets an optional claim validator.
|
||||
func (v *Verifier) SetClaimValidator(cv cashuiface.ClaimValidator) {
|
||||
v.claimValidator = cv
|
||||
}
|
||||
|
||||
// Verify validates a token's cryptographic signature and checks expiry.
|
||||
func (v *Verifier) Verify(ctx context.Context, tok *token.Token, remoteAddr string) error {
|
||||
// Basic validation
|
||||
if err := tok.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if tok.IsExpired() {
|
||||
return ErrTokenExpired
|
||||
}
|
||||
|
||||
// Find keyset
|
||||
ks := v.keysets.FindByID(tok.KeysetID)
|
||||
if ks == nil {
|
||||
return fmt.Errorf("%w: %s", ErrUnknownKeyset, tok.KeysetID)
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
valid, err := v.verifySignature(tok, ks)
|
||||
if err != nil {
|
||||
return fmt.Errorf("verifier: signature check failed: %w", err)
|
||||
}
|
||||
if !valid {
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
|
||||
// Re-check authorization if enabled
|
||||
if v.config.Reauthorize && v.authz != nil {
|
||||
if err := v.authz.CheckAuthorization(ctx, tok.Pubkey, tok.Scope, remoteAddr); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrAccessRevoked, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyForScope verifies a token and checks that it has the required scope.
|
||||
func (v *Verifier) VerifyForScope(ctx context.Context, tok *token.Token, requiredScope string, remoteAddr string) error {
|
||||
if err := v.Verify(ctx, tok, remoteAddr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !tok.MatchesScope(requiredScope) {
|
||||
return fmt.Errorf("%w: expected %s, got %s", ErrScopeMismatch, requiredScope, tok.Scope)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyForKind verifies a token and checks that the specified kind is permitted.
|
||||
func (v *Verifier) VerifyForKind(ctx context.Context, tok *token.Token, kind int, remoteAddr string) error {
|
||||
if err := v.Verify(ctx, tok, remoteAddr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !tok.IsKindPermitted(kind) {
|
||||
return fmt.Errorf("%w: kind %d", ErrKindNotPermitted, kind)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// verifySignature checks the BDHKE signature.
|
||||
func (v *Verifier) verifySignature(tok *token.Token, ks *keyset.Keyset) (bool, error) {
|
||||
// Parse signature as curve point
|
||||
C, err := secp256k1.ParsePubKey(tok.Signature)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("invalid signature format: %w", err)
|
||||
}
|
||||
|
||||
// Verify: C == k * HashToCurve(secret)
|
||||
return bdhke.Verify(tok.Secret, C, ks.PrivateKey)
|
||||
}
|
||||
|
||||
// ExtractFromRequest extracts and parses a token from an HTTP request.
|
||||
// Checks headers in order: X-Cashu-Token, Authorization (Cashu scheme).
|
||||
func (v *Verifier) ExtractFromRequest(r *http.Request) (*token.Token, error) {
|
||||
// Try X-Cashu-Token header first
|
||||
if header := r.Header.Get("X-Cashu-Token"); header != "" {
|
||||
return token.ParseFromHeader(header)
|
||||
}
|
||||
|
||||
// Try Authorization header
|
||||
if header := r.Header.Get("Authorization"); header != "" {
|
||||
return token.ParseFromHeader(header)
|
||||
}
|
||||
|
||||
return nil, ErrMissingToken
|
||||
}
|
||||
|
||||
// VerifyRequest extracts, parses, and verifies a token from an HTTP request.
|
||||
func (v *Verifier) VerifyRequest(ctx context.Context, r *http.Request, requiredScope string) (*token.Token, error) {
|
||||
tok, err := v.ExtractFromRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := v.VerifyForScope(ctx, tok, requiredScope, r.RemoteAddr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// VerifyRequestForKind extracts, parses, and verifies a token for a specific kind.
|
||||
func (v *Verifier) VerifyRequestForKind(ctx context.Context, r *http.Request, requiredScope string, kind int) (*token.Token, error) {
|
||||
tok, err := v.ExtractFromRequest(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := v.VerifyForScope(ctx, tok, requiredScope, r.RemoteAddr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !tok.IsKindPermitted(kind) {
|
||||
return nil, fmt.Errorf("%w: kind %d", ErrKindNotPermitted, kind)
|
||||
}
|
||||
|
||||
return tok, nil
|
||||
}
|
||||
396
pkg/cashu/verifier/verifier_test.go
Normal file
396
pkg/cashu/verifier/verifier_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
package verifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/decred/dcrd/dcrec/secp256k1/v4"
|
||||
|
||||
"next.orly.dev/pkg/cashu/bdhke"
|
||||
"next.orly.dev/pkg/cashu/issuer"
|
||||
"next.orly.dev/pkg/cashu/keyset"
|
||||
"next.orly.dev/pkg/cashu/token"
|
||||
cashuiface "next.orly.dev/pkg/interfaces/cashu"
|
||||
)
|
||||
|
||||
func setupVerifier() (*Verifier, *issuer.Issuer, *keyset.Manager) {
|
||||
store := keyset.NewMemoryStore()
|
||||
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
issuerConfig := issuer.DefaultConfig()
|
||||
iss := issuer.New(manager, cashuiface.AllowAllChecker{}, issuerConfig)
|
||||
|
||||
verifierConfig := DefaultConfig()
|
||||
ver := New(manager, cashuiface.AllowAllChecker{}, verifierConfig)
|
||||
|
||||
return ver, iss, manager
|
||||
}
|
||||
|
||||
func issueTestToken(iss *issuer.Issuer, scope string, kinds []int) (*token.Token, error) {
|
||||
secret, err := bdhke.GenerateSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
blindResult, err := bdhke.Blind(secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pubkey := make([]byte, 32)
|
||||
for i := range pubkey {
|
||||
pubkey[i] = byte(i)
|
||||
}
|
||||
|
||||
req := &issuer.IssueRequest{
|
||||
BlindedMessage: blindResult.B.SerializeCompressed(),
|
||||
Pubkey: pubkey,
|
||||
Scope: scope,
|
||||
Kinds: kinds,
|
||||
}
|
||||
|
||||
resp, err := iss.Issue(context.Background(), req, "127.0.0.1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return issuer.BuildToken(resp, secret, blindResult.R, pubkey, scope, kinds, nil)
|
||||
}
|
||||
|
||||
func TestVerifySuccess(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1, 2, 3})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
err = ver.Verify(context.Background(), tok, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Errorf("Verify failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyExpired(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
// Expire the token
|
||||
tok.Expiry = time.Now().Add(-time.Hour).Unix()
|
||||
|
||||
err = ver.Verify(context.Background(), tok, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Verify should fail for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyInvalidSignature(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
// Corrupt the signature
|
||||
tok.Signature[10] ^= 0xFF
|
||||
|
||||
err = ver.Verify(context.Background(), tok, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Verify should fail for invalid signature")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyUnknownKeyset(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
// Change keyset ID
|
||||
tok.KeysetID = "00000000000000"
|
||||
|
||||
err = ver.Verify(context.Background(), tok, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Verify should fail for unknown keyset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyForScope(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeNIP46, []int{24133})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
// Should pass for correct scope
|
||||
err = ver.VerifyForScope(context.Background(), tok, token.ScopeNIP46, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Errorf("VerifyForScope failed for correct scope: %v", err)
|
||||
}
|
||||
|
||||
// Should fail for wrong scope
|
||||
err = ver.VerifyForScope(context.Background(), tok, token.ScopeRelay, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("VerifyForScope should fail for wrong scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyForKind(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1, 2, 3})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
// Should pass for permitted kind
|
||||
err = ver.VerifyForKind(context.Background(), tok, 1, "127.0.0.1")
|
||||
if err != nil {
|
||||
t.Errorf("VerifyForKind failed for permitted kind: %v", err)
|
||||
}
|
||||
|
||||
// Should fail for non-permitted kind
|
||||
err = ver.VerifyForKind(context.Background(), tok, 100, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("VerifyForKind should fail for non-permitted kind")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyReauthorization(t *testing.T) {
|
||||
store := keyset.NewMemoryStore()
|
||||
manager := keyset.NewManager(store, keyset.DefaultActiveWindow, keyset.DefaultVerifyWindow)
|
||||
manager.Init()
|
||||
|
||||
iss := issuer.New(manager, cashuiface.AllowAllChecker{}, issuer.DefaultConfig())
|
||||
|
||||
// Create verifier that denies authorization
|
||||
config := DefaultConfig()
|
||||
config.Reauthorize = true
|
||||
ver := New(manager, cashuiface.DenyAllChecker{}, config)
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
// Should fail due to reauthorization check
|
||||
err = ver.Verify(context.Background(), tok, "127.0.0.1")
|
||||
if err == nil {
|
||||
t.Error("Verify should fail when reauthorization fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromRequest(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
header string
|
||||
value string
|
||||
}{
|
||||
{"X-Cashu-Token", "X-Cashu-Token", encoded},
|
||||
{"Authorization Cashu", "Authorization", "Cashu " + encoded},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set(tt.header, tt.value)
|
||||
|
||||
extracted, err := ver.ExtractFromRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtractFromRequest failed: %v", err)
|
||||
}
|
||||
|
||||
if extracted.KeysetID != tok.KeysetID {
|
||||
t.Errorf("KeysetID mismatch: %s != %s", extracted.KeysetID, tok.KeysetID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFromRequestMissing(t *testing.T) {
|
||||
ver, _, _ := setupVerifier()
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
_, err := ver.ExtractFromRequest(req)
|
||||
if err != ErrMissingToken {
|
||||
t.Errorf("Expected ErrMissingToken, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRequest(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Cashu-Token", encoded)
|
||||
|
||||
verified, err := ver.VerifyRequest(context.Background(), req, token.ScopeRelay)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRequest failed: %v", err)
|
||||
}
|
||||
|
||||
if verified.KeysetID != tok.KeysetID {
|
||||
t.Error("VerifyRequest returned wrong token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
// Handler that checks context
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctxTok := TokenFromContext(r.Context())
|
||||
if ctxTok == nil {
|
||||
t.Error("Token not in context")
|
||||
}
|
||||
pubkey := PubkeyFromContext(r.Context())
|
||||
if pubkey == nil {
|
||||
t.Error("Pubkey not in context")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := Middleware(ver, token.ScopeRelay)(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Cashu-Token", encoded)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("Status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareUnauthorized(t *testing.T) {
|
||||
ver, _, _ := setupVerifier()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := Middleware(ver, token.ScopeRelay)(handler)
|
||||
|
||||
// Request without token
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptionalMiddleware(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrapped := OptionalMiddleware(ver, token.ScopeRelay)(handler)
|
||||
|
||||
// With token
|
||||
req1 := httptest.NewRequest("GET", "/", nil)
|
||||
req1.Header.Set("X-Cashu-Token", encoded)
|
||||
rec1 := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec1, req1)
|
||||
|
||||
if rec1.Code != http.StatusOK {
|
||||
t.Errorf("With token: Status = %d, want 200", rec1.Code)
|
||||
}
|
||||
|
||||
// Without token
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(rec2, req2)
|
||||
|
||||
if rec2.Code != http.StatusOK {
|
||||
t.Errorf("Without token: Status = %d, want 200", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireToken(t *testing.T) {
|
||||
ver, iss, _ := setupVerifier()
|
||||
|
||||
tok, err := issueTestToken(iss, token.ScopeRelay, []int{1})
|
||||
if err != nil {
|
||||
t.Fatalf("issueTestToken failed: %v", err)
|
||||
}
|
||||
|
||||
encoded, _ := tok.Encode()
|
||||
|
||||
// With valid token
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-Cashu-Token", encoded)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
result := RequireToken(ver, rec, req, token.ScopeRelay)
|
||||
if result == nil {
|
||||
t.Error("RequireToken should return token")
|
||||
}
|
||||
|
||||
// Without token
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
rec2 := httptest.NewRecorder()
|
||||
|
||||
result2 := RequireToken(ver, rec2, req2, token.ScopeRelay)
|
||||
if result2 != nil {
|
||||
t.Error("RequireToken should return nil for missing token")
|
||||
}
|
||||
if rec2.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Status = %d, want 401", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to parse point
|
||||
func mustParsePoint(data []byte) *secp256k1.PublicKey {
|
||||
pk, err := secp256k1.ParsePubKey(data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return pk
|
||||
}
|
||||
106
pkg/interfaces/cashu/cashu.go
Normal file
106
pkg/interfaces/cashu/cashu.go
Normal file
@@ -0,0 +1,106 @@
|
||||
// Package cashu defines interfaces for the Cashu access token system.
|
||||
// Implement these interfaces to integrate with your authorization backend.
|
||||
package cashu
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
// AuthzChecker determines if a pubkey is authorized for a given scope.
|
||||
// Implement this interface to integrate with your access control system.
|
||||
type AuthzChecker interface {
|
||||
// CheckAuthorization returns nil if the pubkey is authorized for the scope,
|
||||
// or an error describing why authorization failed.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: Context for cancellation and timeouts
|
||||
// - pubkey: User's Nostr pubkey (32 bytes)
|
||||
// - scope: Token scope (e.g., "relay", "nip46", "api")
|
||||
// - remoteAddr: Client's remote address (for IP-based checks)
|
||||
//
|
||||
// The implementation should check if the user has sufficient permissions
|
||||
// for the requested scope. This is called during token issuance.
|
||||
CheckAuthorization(ctx context.Context, pubkey []byte, scope string, remoteAddr string) error
|
||||
}
|
||||
|
||||
// ReauthorizationChecker is an optional extension of AuthzChecker that
|
||||
// supports re-checking authorization during token verification.
|
||||
// This enables "stateless revocation" - tokens become invalid immediately
|
||||
// when the user is removed from the access list.
|
||||
type ReauthorizationChecker interface {
|
||||
AuthzChecker
|
||||
|
||||
// ReauthorizationEnabled returns true if authorization should be
|
||||
// re-checked on every token verification.
|
||||
ReauthorizationEnabled() bool
|
||||
}
|
||||
|
||||
// ClaimValidator validates custom claims in tokens.
|
||||
// Implement this for application-specific claim validation.
|
||||
type ClaimValidator interface {
|
||||
// ValidateClaims validates custom claims embedded in a token.
|
||||
// Returns nil if claims are valid, error otherwise.
|
||||
ValidateClaims(claims map[string]any) error
|
||||
}
|
||||
|
||||
// KindPermissionChecker validates event kind permissions.
|
||||
// This is typically implemented by the token itself, but can be
|
||||
// extended for additional validation logic.
|
||||
type KindPermissionChecker interface {
|
||||
// IsKindPermitted returns true if the given event kind is allowed.
|
||||
IsKindPermitted(kind int) bool
|
||||
|
||||
// HasWritePermission returns true if any kinds are permitted.
|
||||
HasWritePermission() bool
|
||||
}
|
||||
|
||||
// Common error types that implementations may return.
|
||||
type AuthzError struct {
|
||||
Code string
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *AuthzError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// Predefined authorization error codes.
|
||||
const (
|
||||
ErrCodeNotAuthorized = "not_authorized"
|
||||
ErrCodeBanned = "banned"
|
||||
ErrCodeBlocked = "blocked"
|
||||
ErrCodeInvalidScope = "invalid_scope"
|
||||
ErrCodeRateLimited = "rate_limited"
|
||||
ErrCodeInsufficientAccess = "insufficient_access"
|
||||
)
|
||||
|
||||
// NewAuthzError creates a new authorization error.
|
||||
func NewAuthzError(code, message string) *AuthzError {
|
||||
return &AuthzError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// Common authorization errors.
|
||||
var (
|
||||
ErrNotAuthorized = NewAuthzError(ErrCodeNotAuthorized, "not authorized for this scope")
|
||||
ErrBanned = NewAuthzError(ErrCodeBanned, "user is banned")
|
||||
ErrBlocked = NewAuthzError(ErrCodeBlocked, "IP address is blocked")
|
||||
ErrInvalidScope = NewAuthzError(ErrCodeInvalidScope, "invalid scope requested")
|
||||
)
|
||||
|
||||
// AllowAllChecker is a simple implementation that allows all requests.
|
||||
// Useful for testing or open relays.
|
||||
type AllowAllChecker struct{}
|
||||
|
||||
// CheckAuthorization always returns nil (allowed).
|
||||
func (AllowAllChecker) CheckAuthorization(ctx context.Context, pubkey []byte, scope string, remoteAddr string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DenyAllChecker is a simple implementation that denies all requests.
|
||||
// Useful for testing or temporarily disabling token issuance.
|
||||
type DenyAllChecker struct{}
|
||||
|
||||
// CheckAuthorization always returns ErrNotAuthorized.
|
||||
func (DenyAllChecker) CheckAuthorization(ctx context.Context, pubkey []byte, scope string, remoteAddr string) error {
|
||||
return ErrNotAuthorized
|
||||
}
|
||||
Reference in New Issue
Block a user