Implement session-scoped ndebit authorization for cash-in security #8

Open
opened 2026-06-13 21:58:17 +00:00 by padreug · 1 comment
Owner

Migrated from aiolabs/lamassu-next#8 — opened by @padreug on 2026-01-25.\n\n## Problem

The current mock-machine implementation auto-approves all debit requests, creating a vulnerability:

  1. Anyone who sees/copies the QR code (clink:ndebit...?amount=X) could claim those sats
  2. No binding between "person who inserted cash" and "person who scans QR"
  3. Race condition: malicious actor could copy URI and claim before legitimate customer

Research Summary

Analysis of Lightning.Pub, ShockWallet, and CLINK spec reveals the intended security model:

How ndebit Authorization Works

  1. Pointer = Authorization anchor (maps to user account in Lightning.Pub)
  2. DebitAccess = Permission record per (pointer, requestor_pubkey) pair
  3. Rules = Budget/frequency/expiration constraints
DebitAccess {
  app_user_id: string      // The pointer value
  npub: string             // Requesting app's pubkey
  authorized: boolean      // Explicit user approval required
  rules: {
    frequency?: [intervals, unit, max_sats]
    expiration?: [expires_at_unix]
  }
}

Authorization Flow

First request from app → authRequired (user sees modal in wallet)
User approves → DebitAccess created with authorized=true + rules
Subsequent requests → auto-process if rules pass
User denies → DebitAccess with authorized=false (banned)

Key Insight: Pointer Scoping

The pointer doesn't have to be the user ID - it can be a session identifier. This enables session-scoped authorization:

Session 1: pointer="atm-session-abc123" → separate DebitAccess record
Session 2: pointer="atm-session-def456" → separate DebitAccess record

Flow

1. Customer inserts $50 cash
   ↓
2. ATM generates unique session pointer
   sessionId = "session-" + timestamp + "-" + randomId()
   ↓
3. ATM creates pre-authorization via Lightning.Pub API
   POST /api/app/debit/authorize
   {
     user_identifier: "atm",
     authorized_npub: "*",           // Allow any wallet to claim
     pointer: sessionId,
     rules: [{
       type: "expiration",
       expires_at_unix: now + 5min   // Session timeout
     }, {
       type: "frequency", 
       intervals: 1,
       unit: "day",
       amount: 50000                 // Exact amount (one-time cap)
     }]
   }
   ↓
4. ATM displays QR with session pointer
   clink:ndebit1<pubkey><relay><sessionId>?amount=50000
   ↓
5. Customer scans with ShockWallet
   - Wallet creates invoice for 50000 sats
   - Wallet sends Kind 21002 debit request to ATM's pubkey
   ↓
6. Lightning.Pub receives request
   - Looks up DebitAccess(sessionId, customerPubkey)
   - Since "*" authorization exists, creates specific record
   - Validates rules (not expired, within budget)
   - Pays the invoice
   ↓
7. ATM receives payment confirmation
   - Marks session as redeemed
   - Invalidates/expires the session pointer
   ↓
8. Any subsequent claims against sessionId fail
   - DebitAccess already has total_debits >= max amount
   - Or expiration has passed

Security Properties

Property How It's Achieved
Only one redemption Frequency rule: amount = exact_invoice_amount
Time-bounded Expiration rule: 5-minute window
Session isolation Unique pointer per cash insertion
No pre-auth required ATM pre-creates authorization before displaying QR
Audit trail DebitAccess records track all claims

Implementation Tasks

1. Lightning.Pub API Extension (may need upstream contribution)

Need API endpoint to pre-create debit authorization:

// Proposed: POST /api/app/debit/authorize
interface CreateDebitAuthRequest {
  user_identifier: string      // ATM user account
  pointer: string              // Session-scoped pointer
  authorized_npub?: string     // "*" for any, or specific pubkey
  rules: DebitRule[]
}

Alternatively, use existing RespondToDebit with type: 'authorize' but triggered proactively.

2. ATM Session Manager

class AtmSessionManager {
  async createCashInSession(amountSats: number): Promise<Session> {
    const sessionId = `session-${Date.now()}-${crypto.randomUUID()}`
    
    // Pre-authorize this session
    await this.lightningPub.createDebitAuthorization({
      pointer: sessionId,
      rules: [
        { type: 'expiration', expiresAt: Date.now() + 5 * 60 * 1000 },
        { type: 'frequency', intervals: 1, unit: 'day', amount: amountSats }
      ]
    })
    
    // Generate session-scoped ndebit
    const ndebit = ndebitEncode({
      pubkey: this.atmPubkey,
      relay: this.relayUrl,
      pointer: sessionId
    })
    
    return { sessionId, ndebit, amount: amountSats }
  }
  
  async invalidateSession(sessionId: string): Promise<void> {
    // Remove or expire the DebitAccess record
    await this.lightningPub.removeDebitAuthorization(sessionId)
  }
}

3. Update mock-machine.mjs

Replace current auto-approve with session-based flow:

async function insertCash(usdAmount) {
  const sats = usdAmount * SATS_PER_USD
  
  // Create session-scoped authorization
  currentSession = await atmSessionManager.createCashInSession(sats)
  
  // Generate QR with session pointer
  const uri = `clink:${currentSession.ndebit}?amount=${sats}`
  ndebitQR = await QRCode.toDataURL(uri)
  
  atmState = ATM_STATE.WAITING_FOR_SCAN
  broadcastState()
}

// On successful payment callback
function onPaymentReceived(sessionId, amount) {
  if (sessionId === currentSession?.sessionId) {
    atmState = ATM_STATE.COMPLETE
    // Session auto-expires, but we can explicitly invalidate
    atmSessionManager.invalidateSession(sessionId)
  }
}

Alternative: Simplified First-Scan Lock

If Lightning.Pub API extension is not feasible, simpler approach:

  1. ATM listens for debit requests after cash insertion
  2. First valid request locks the session to that specific pubkey
  3. ATM only approves requests from that pubkey
  4. Session expires after timeout
let lockedToPubkey = null

function handleDebitRequest(request) {
  if (atmState !== ATM_STATE.WAITING_FOR_SCAN) return reject()
  
  if (!lockedToPubkey) {
    // First scan locks the session
    lockedToPubkey = request.pubkey
  }
  
  if (request.pubkey !== lockedToPubkey) {
    return reject('Session locked to different user')
  }
  
  // Process the request
  approve(request)
}

This is simpler but has a small race window between QR display and first scan.

Priority

P1 - Security critical for production deployment

References

  • CLINK Debits Spec: /clink/specs/clink-debits.md
  • Lightning.Pub DebitManager: src/services/main/debitManager.ts
  • DebitAccess Entity: src/services/storage/entity/DebitAccess.ts
  • lamassu-next#3 (Hold Invoices) - Related cash-out security
> _Migrated from [aiolabs/lamassu-next#8](https://git.atitlan.io/aiolabs/lamassu-next/issues/8) — opened by @padreug on 2026-01-25._\n\n## Problem The current mock-machine implementation **auto-approves all debit requests**, creating a vulnerability: 1. Anyone who sees/copies the QR code (`clink:ndebit...?amount=X`) could claim those sats 2. No binding between "person who inserted cash" and "person who scans QR" 3. Race condition: malicious actor could copy URI and claim before legitimate customer ## Research Summary Analysis of Lightning.Pub, ShockWallet, and CLINK spec reveals the intended security model: ### How ndebit Authorization Works 1. **Pointer** = Authorization anchor (maps to user account in Lightning.Pub) 2. **DebitAccess** = Permission record per `(pointer, requestor_pubkey)` pair 3. **Rules** = Budget/frequency/expiration constraints ``` DebitAccess { app_user_id: string // The pointer value npub: string // Requesting app's pubkey authorized: boolean // Explicit user approval required rules: { frequency?: [intervals, unit, max_sats] expiration?: [expires_at_unix] } } ``` ### Authorization Flow ``` First request from app → authRequired (user sees modal in wallet) User approves → DebitAccess created with authorized=true + rules Subsequent requests → auto-process if rules pass User denies → DebitAccess with authorized=false (banned) ``` ### Key Insight: Pointer Scoping The **pointer** doesn't have to be the user ID - it can be a **session identifier**. This enables session-scoped authorization: ``` Session 1: pointer="atm-session-abc123" → separate DebitAccess record Session 2: pointer="atm-session-def456" → separate DebitAccess record ``` ## Recommended Solution: Session-Scoped Pointers ### Flow ``` 1. Customer inserts $50 cash ↓ 2. ATM generates unique session pointer sessionId = "session-" + timestamp + "-" + randomId() ↓ 3. ATM creates pre-authorization via Lightning.Pub API POST /api/app/debit/authorize { user_identifier: "atm", authorized_npub: "*", // Allow any wallet to claim pointer: sessionId, rules: [{ type: "expiration", expires_at_unix: now + 5min // Session timeout }, { type: "frequency", intervals: 1, unit: "day", amount: 50000 // Exact amount (one-time cap) }] } ↓ 4. ATM displays QR with session pointer clink:ndebit1<pubkey><relay><sessionId>?amount=50000 ↓ 5. Customer scans with ShockWallet - Wallet creates invoice for 50000 sats - Wallet sends Kind 21002 debit request to ATM's pubkey ↓ 6. Lightning.Pub receives request - Looks up DebitAccess(sessionId, customerPubkey) - Since "*" authorization exists, creates specific record - Validates rules (not expired, within budget) - Pays the invoice ↓ 7. ATM receives payment confirmation - Marks session as redeemed - Invalidates/expires the session pointer ↓ 8. Any subsequent claims against sessionId fail - DebitAccess already has total_debits >= max amount - Or expiration has passed ``` ### Security Properties | Property | How It's Achieved | |----------|-------------------| | **Only one redemption** | Frequency rule: `amount = exact_invoice_amount` | | **Time-bounded** | Expiration rule: 5-minute window | | **Session isolation** | Unique pointer per cash insertion | | **No pre-auth required** | ATM pre-creates authorization before displaying QR | | **Audit trail** | DebitAccess records track all claims | ## Implementation Tasks ### 1. Lightning.Pub API Extension (may need upstream contribution) Need API endpoint to pre-create debit authorization: ```typescript // Proposed: POST /api/app/debit/authorize interface CreateDebitAuthRequest { user_identifier: string // ATM user account pointer: string // Session-scoped pointer authorized_npub?: string // "*" for any, or specific pubkey rules: DebitRule[] } ``` Alternatively, use existing `RespondToDebit` with `type: 'authorize'` but triggered proactively. ### 2. ATM Session Manager ```typescript class AtmSessionManager { async createCashInSession(amountSats: number): Promise<Session> { const sessionId = `session-${Date.now()}-${crypto.randomUUID()}` // Pre-authorize this session await this.lightningPub.createDebitAuthorization({ pointer: sessionId, rules: [ { type: 'expiration', expiresAt: Date.now() + 5 * 60 * 1000 }, { type: 'frequency', intervals: 1, unit: 'day', amount: amountSats } ] }) // Generate session-scoped ndebit const ndebit = ndebitEncode({ pubkey: this.atmPubkey, relay: this.relayUrl, pointer: sessionId }) return { sessionId, ndebit, amount: amountSats } } async invalidateSession(sessionId: string): Promise<void> { // Remove or expire the DebitAccess record await this.lightningPub.removeDebitAuthorization(sessionId) } } ``` ### 3. Update mock-machine.mjs Replace current auto-approve with session-based flow: ```javascript async function insertCash(usdAmount) { const sats = usdAmount * SATS_PER_USD // Create session-scoped authorization currentSession = await atmSessionManager.createCashInSession(sats) // Generate QR with session pointer const uri = `clink:${currentSession.ndebit}?amount=${sats}` ndebitQR = await QRCode.toDataURL(uri) atmState = ATM_STATE.WAITING_FOR_SCAN broadcastState() } // On successful payment callback function onPaymentReceived(sessionId, amount) { if (sessionId === currentSession?.sessionId) { atmState = ATM_STATE.COMPLETE // Session auto-expires, but we can explicitly invalidate atmSessionManager.invalidateSession(sessionId) } } ``` ## Alternative: Simplified First-Scan Lock If Lightning.Pub API extension is not feasible, simpler approach: 1. ATM listens for debit requests after cash insertion 2. First valid request locks the session to that specific pubkey 3. ATM only approves requests from that pubkey 4. Session expires after timeout ```javascript let lockedToPubkey = null function handleDebitRequest(request) { if (atmState !== ATM_STATE.WAITING_FOR_SCAN) return reject() if (!lockedToPubkey) { // First scan locks the session lockedToPubkey = request.pubkey } if (request.pubkey !== lockedToPubkey) { return reject('Session locked to different user') } // Process the request approve(request) } ``` This is simpler but has a small race window between QR display and first scan. ## Priority P1 - Security critical for production deployment ## References - CLINK Debits Spec: `/clink/specs/clink-debits.md` - Lightning.Pub DebitManager: `src/services/main/debitManager.ts` - DebitAccess Entity: `src/services/storage/entity/DebitAccess.ts` - lamassu-next#3 (Hold Invoices) - Related cash-out security
padreug changed title from [reserved] migration number alignment to Implement session-scoped ndebit authorization for cash-in security 2026-06-14 06:54:41 +00:00
padreug reopened this issue 2026-06-14 06:54:41 +00:00
Author
Owner

@padreug commented on 2026-05-13 (lamassu-next#8):

ndebit (CLINK kind-21002 + response 21003) is Lightning.Pub-specific — it lives in the CLINK protocol (https://github.com/shocknet/CLINK) embedded in LP. LNbits has zero knowledge of CLINK and the nostr-native-transport work on aiolabs/lnbits (see #22) does not add CLINK support.

This means the session-scoped ndebit pre-authorization design in this issue — the proposed POST /api/app/debit/authorize on LP — stays scoped to Lightning.Pub. The LNbits-side migration neither blocks nor obviates it.

What changes / doesn't change with the migration:

  • The ATM's CLINK / ndebit flow continues to talk to Lightning.Pub for debit pre-auth, GetLiveDebitRequests subscription, RespondToDebit, and the actual PayInvoice that resolves the ndebit request.
  • After ndebit pre-auth resolves to "approve this invoice", that final PayInvoice call could in principle target an LNbits wallet via the new transport's pay_invoice RPC — but only if you decide LNbits is the funds-bearing side. If LP is still the wallet, no change.
  • The "did the ndebit succeed?" detection (issue #17) has an LNbits equivalent via subscribe_payments — but that's outcome parity, not protocol parity. It does not consume ndebit semantics.

Future LNbits CLINK adoption: possible but deferred. If at some point LNbits should grow first-class CLINK (e.g. so an LNbits wallet can be the target of an ndebit request), that's a new protocol module alongside lnbits/core/services/nostr_transport/, not part of it. The relay-pool and NIP-44 plumbing in the transport branch is the natural building block, but the decision is non-trivial and worth its own design.

Net: this issue stays valid and LP-scoped. No re-architecture needed.

> _@padreug commented on 2026-05-13 ([lamassu-next#8](https://git.atitlan.io/aiolabs/lamassu-next/issues/8#issuecomment-560)):_ ## CLINK scope note + LNbits migration impact **ndebit (CLINK kind-21002 + response 21003) is Lightning.Pub-specific** — it lives in the CLINK protocol (https://github.com/shocknet/CLINK) embedded in LP. LNbits has zero knowledge of CLINK and the `nostr-native-transport` work on `aiolabs/lnbits` (see #22) does **not** add CLINK support. This means the session-scoped ndebit pre-authorization design in this issue — the proposed `POST /api/app/debit/authorize` on LP — stays scoped to **Lightning.Pub**. The LNbits-side migration neither blocks nor obviates it. **What changes / doesn't change with the migration:** - The ATM's CLINK / ndebit flow continues to talk to **Lightning.Pub** for debit pre-auth, `GetLiveDebitRequests` subscription, `RespondToDebit`, and the actual `PayInvoice` that resolves the ndebit request. - After ndebit pre-auth resolves to "approve this invoice", that final `PayInvoice` call could in principle target an **LNbits** wallet via the new transport's `pay_invoice` RPC — but only if you decide LNbits is the funds-bearing side. If LP is still the wallet, no change. - The "did the ndebit succeed?" detection (issue #17) has an LNbits equivalent via `subscribe_payments` — but that's *outcome parity*, not protocol parity. It does not consume ndebit semantics. **Future LNbits CLINK adoption:** possible but deferred. If at some point LNbits should grow first-class CLINK (e.g. so an LNbits wallet can be the *target* of an ndebit request), that's a new protocol module alongside `lnbits/core/services/nostr_transport/`, not part of it. The relay-pool and NIP-44 plumbing in the transport branch is the natural building block, but the decision is non-trivial and worth its own design. **Net:** this issue stays valid and LP-scoped. No re-architecture needed.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
aiolabs/bitspire#8
No description provided.