Skip to content

Instantly share code, notes, and snippets.

@jclyne
Last active February 6, 2026 21:38
Show Gist options
  • Select an option

  • Save jclyne/aadef00cd0b8035d9e2d73760282bb68 to your computer and use it in GitHub Desktop.

Select an option

Save jclyne/aadef00cd0b8035d9e2d73760282bb68 to your computer and use it in GitHub Desktop.
Design: Passcode Tokenization and Reuse in Moneybot-Core

Design: Passcode Tokenization and Reuse in Moneybot-Core

Problem Statement

When a customer interacts with Moneybot (the AI-powered Cash App assistant), certain operations — such as enabling or disabling a Cash Card — require passcode verification via Plasma blockers. The plaintext passcode must never be visible to the LLM, pass through the MCP tool layer, or flow through any intermediary service (such as kgoose). The passcode must travel exclusively from the client to moneybot-core. Additionally, a customer may be asked for their passcode multiple times within a single Moneybot session if multiple operations require verification.

Goals

  1. Remove passcode from the LLM context — The plaintext passcode must never pass through the MCP tool layer, be visible to the LLM, or flow through any upstream service (e.g., kgoose). It is handled exclusively by the client and moneybot-core.
  2. Tokenize once, reuse within a session — Collect the customer's passcode once per Moneybot session via a dedicated public gRPC call from the client. Tokenize it via Fidelius and store the encrypted token in DynamoDB, scoped to the active chat session.
  3. Transparent blocker resolution — When a Plasma flow returns a passcode verification blocker, moneybot-core checks for an existing tokenized passcode. If one exists, it automatically submits it to Plasma without prompting the customer again. If none exists, it returns a blocker response to the client indicating passcode collection is required.

Current State: Janus VerifyPasscodeAppApi

The existing client-facing passcode verification flow is implemented in Janus at POST /2.0/cash/verify-passcode. This is the endpoint the Cash App client currently calls for passcode verification outside of the Moneybot context.

Janus Flow

Client (Cash App)
  │
  │  POST /2.0/cash/verify-passcode
  │  Headers: x-plasma-flow-token
  │  Body: { passcode: "1234", blocker_descriptor_id: "passcode_verification" }
  │
  ▼
VerifyPasscodeAppApi (Janus)
  │
  ├─ 1. Extract flow_token from x-plasma-flow-token header
  ├─ 2. Resolve customer_token from Plasma principal or Franklin
  ├─ 3. Check Franklin routing (legacy fallback)
  │
  ├─ 4. Tokenize passcode via Fidelius
  │     └─ fideliusClient.tokenForCustomerPasscode(CustomerPasscode(passcode))
  │        ├─ Creates payload: { passcode: "1234", token: "P_<random>" }
  │        ├─ Calls Fidelius PutSecret (category: "customer_passcode")
  │        └─ Returns FideliusToken (e.g., "F_abc123")
  │
  ├─ 5. Build BlockerInputs with VerifyPasscodeInputs(fidelius_token_id)
  │
  ├─ 6. Submit to Plasma via plasmaClient.submitBlockerInputs()
  │     └─ Includes retry on PlasmaClientException
  │
  ├─ 7. Map Plasma response status:
  │     ├─ REQUIREMENT_ALREADY_ENDED → FAILURE
  │     ├─ FLOW_ALREADY_ENDED → SUCCESS
  │     └─ SUCCESS → read ui_dialog.status (SUCCESS | INVALID_PASSCODE | TOO_MANY_ATTEMPTS)
  │
  └─ 8. On SUCCESS, optionally retrieve protected data (e.g., card CVV/PAN)

Key Janus Implementation Details

  • Fidelius tokenization (RealFideliusClient): Generates a CustomerPasscode object with the plaintext passcode and a random P_-prefixed token. Serializes to JSON and stores via Fidelius PutSecret with category "customer_passcode". Returns a Fidelius token ID.
  • Blocker submission: Wraps the Fidelius token in VerifyPasscodeInputs proto and submits to Plasma as BlockerInputs.
  • Retry logic: Single automatic retry on PlasmaClientException.
  • Protected data: Feature-flagged retrieval of sensitive card info (CVV, PAN) via Postcard service on successful verification.
  • Rate limiting: Enforced via permits before verification attempts.

Proposed Design

Architecture Overview

The new design introduces a publicly available gRPC endpoint on moneybot-core for passcode submission, a DynamoDB-backed encrypted passcode token store, and automatic passcode blocker resolution during Plasma flows.

┌────────────────────────────────────────────────────────────────────────────┐
│                            Client (Cash App)                               │
│                                                                            │
│  1. Start conversation → session established (Cash-Goose-Session-Id)       │
│  2. User says "enable my card"                                             │
│  3. Tool returns: passcode_required blocker                                │
│  4. Client renders secure passcode input inline in the chat UI             │
│  5. Client calls: SubmitPasscode gRPC directly to moneybot-core            │
│     (passcode + session_id — bypasses kgoose entirely)                     │
│  6. Client retries the original operation (or signals ready)               │
└────────────────────────────────────────────────────────────────────────────┘
         │                              │
         │ MCP tools (via kgoose)       │ Public gRPC (direct)
         ▼                              ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                          moneybot-core                                      │
│                                                                             │
│  Transport Layer                                                            │
│  ┌──────────────────────┐  ┌──────────────────────────────┐                 │
│  │  MCP Tools           │  │  PasscodeService gRPC        │                 │
│  │  (enable_card, etc.) │  │  SubmitPasscode()            │                 │
│  └──────────┬───────────┘  └──────────────┬───────────────┘                 │
│             │                              │                                │
│  Service Layer                             │                                │
│  ┌──────────▼───────────┐  ┌──────────────▼───────────────┐                 │
│  │  Card Interactors    │  │  SubmitPasscodeInteractor     │                │
│  │  (enable/disable)    │  │  - tokenize via Fidelius      │                │
│  │                      │  │  - store token in DynamoDB    │                │
│  │  SubmitBlocker       │  └──────────────────────────────┘                 │
│  │  Interactor          │                                                   │
│  │  - check for cached  │                                                   │
│  │    passcode token    │                                                   │
│  └──────────┬───────────┘                                                   │
│             │                                                               │
│  Adapter Layer                                                              │
│  ┌──────────▼───────────┐  ┌────────────────┐  ┌────────────────────────┐   │
│  │  PlasmaFlowAdapter   │  │  Fidelius       │  │  DynamoDB              │   │
│  │  - submit tokenized  │  │  Adapter        │  │  - flow_sessions       │   │
│  │    passcode to       │  │  - PutSecret    │  │  - passcode_tokens     │   │
│  │    Plasma            │  │                 │  │    (new table)         │   │
│  └──────────────────────┘  └────────────────┘  └────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘

New Components

1. Proto Definition: PasscodeService

// squareup/cash/moneybotcore/api/v1beta1/passcode_service.proto

service PasscodeService {
  // Tokenizes and stores a customer passcode for the active session.
  // The plaintext passcode is tokenized via Fidelius and never stored directly.
  rpc SubmitPasscode(SubmitPasscodeRequest) returns (SubmitPasscodeResponse) {
    option (squareup.misk.wire_rpc) = {
      method: "POST"
      path: "/moneybot-core/passcode"
    };
  }
}

message SubmitPasscodeRequest {
  // The active Moneybot session ID (matches Cash-Goose-Session-Id header)
  string session_id = 1;
  // The plaintext passcode from the customer (4-digit PIN)
  string passcode = 2;
}

message SubmitPasscodeResponse {
  // Whether the passcode was successfully tokenized and stored
  Status status = 1;
  // Error message if status is not SUCCESS
  string error_message = 2;

  enum Status {
    SUCCESS = 0;
    INVALID_INPUT = 1;
    TOKENIZATION_FAILED = 2;
    STORAGE_FAILED = 3;
  }
}

2. DynamoDB Table: passcode_tokens

A new DynamoDB table scoped to the moneybot session lifecycle.

Attribute Type Description
session_id (PK) String The Moneybot session ID
fidelius_token String Encrypted — The Fidelius token ID returned from tokenization
customer_token String Encrypted — The customer token for validation
created_at Number Epoch millis when the token was stored
ttl Number Epoch seconds for DynamoDB TTL (matches session lifetime, default 24h)

Encryption: Both fidelius_token and customer_token are encrypted at rest using Tink AEAD with a dedicated encryption key (moneybot-core-passcode-token-key), following the same pattern as the existing flow_sessions table.

3. Service Layer: Repository Interface

// service/repository/PasscodeTokenRepository.kt
interface PasscodeTokenRepository {
  /** Stores a tokenized passcode for the given session. */
  suspend fun savePasscodeToken(
    sessionId: String,
    fideliusToken: String,
    customerToken: CustomerToken,
  )

  /** Retrieves the stored Fidelius token for a session, or null if none exists. */
  suspend fun getPasscodeToken(sessionId: String): PasscodeTokenData?

  /** Deletes the stored passcode token for a session. */
  suspend fun deletePasscodeToken(sessionId: String)
}

data class PasscodeTokenData(
  val sessionId: String,
  val fideliusToken: String,
  val customerToken: CustomerToken,
  val createdAt: Long,
)

4. Service Layer: SubmitPasscodeInteractor

// service/interactor/SubmitPasscodeInteractor.kt
class SubmitPasscodeInteractor @Inject constructor(
  private val fideliusRepository: FideliusRepository,
  private val passcodeTokenRepository: PasscodeTokenRepository,
) {
  suspend fun execute(
    sessionId: String,
    customerToken: CustomerToken,
    passcode: String,
  ): SubmitPasscodeResult {
    // 1. Validate input
    // 2. Tokenize passcode via Fidelius
    val fideliusToken = fideliusRepository.tokenizePasscode(passcode)
    // 3. Store encrypted token in DynamoDB scoped to session
    passcodeTokenRepository.savePasscodeToken(sessionId, fideliusToken, customerToken)
    // 4. Return success
    return SubmitPasscodeResult.Success
  }
}

5. Modified Blocker Resolution

The PlasmaFlowAdapter.buildBlockerInputs() method is enhanced to check for a cached passcode token before requiring client input:

// Current: Reads passcode from blockerInput JSON (from LLM — this is being removed)
// Proposed: Reads tokenized passcode from DynamoDB (submitted directly by client via public gRPC)

suspend fun buildBlockerInputs(
  sessionId: String,
  blockerDescriptorId: String,
  blockerType: String,
  blockerInput: JsonObject?,
): BlockerInputs {
  val builder = BlockerInputs.Builder()
    .blocker_descriptor_id(blockerDescriptorId)

  if (blockerType == BlockerType.PASSCODE_VERIFICATION.name) {
    // Look up cached tokenized passcode for this session
    val cachedToken = passcodeTokenRepository.getPasscodeToken(sessionId)
      ?: throw PasscodeNotAvailableException("No passcode token found for session")

    builder.verify_passcode_inputs(
      VerifyPasscodeInputs(cachedToken.fideliusToken, null)
    )
  }

  return builder.build()
}

Flow Diagrams

Flow 1: Passcode Not Yet Submitted (First Encounter)

Customer              Client (Cash App)          moneybot-core             Plasma         Fidelius       DynamoDB
   │                       │                          │                      │                │              │
   │  "Enable my card"     │                          │                      │                │              │
   │──────────────────────>│                          │                      │                │              │
   │                       │  MCP: enable_card        │                      │                │              │
   │                       │─────────────────────────>│                      │                │              │
   │                       │                          │  createFlow()        │                │              │
   │                       │                          │─────────────────────>│                │              │
   │                       │                          │  blocker: passcode   │                │              │
   │                       │                          │<─────────────────────│                │              │
   │                       │                          │                      │                │              │
   │                       │                          │  lookup passcode     │                │              │
   │                       │                          │  token for session   │                │              │
   │                       │                          │───────────────────────────────────────>│              │
   │                       │                          │  (not found)         │                │              │
   │                       │                          │<──────────────────────────────────────│              │
   │                       │                          │                      │                │              │
   │                       │  BlockerRequired:        │                      │                │              │
   │                       │  { type: PASSCODE,       │                      │                │              │
   │                       │    requiresPasscode:true }│                      │                │              │
   │                       │<─────────────────────────│                      │                │              │
   │                       │                          │                      │                │              │
   │  Secure passcode       │                          │                      │                │              │
   │  input (inline chat)  │                          │                      │                │              │
   │<──────────────────────│                          │                      │                │              │
   │                       │                          │                      │                │              │
   │  Enters "1234"        │                          │                      │                │              │
   │──────────────────────>│                          │                      │                │              │
   │                       │                          │                      │                │              │
   │                       │  gRPC: SubmitPasscode    │                      │                │              │
   │                       │  { session_id, "1234" }  │                      │                │              │
   │                       │─────────────────────────>│                      │                │              │
   │                       │                          │  tokenize("1234")    │                │              │
   │                       │                          │─────────────────────────────────────>│              │
   │                       │                          │  "F_token_abc"       │                │              │
   │                       │                          │<────────────────────────────────────│              │
   │                       │                          │                      │                │              │
   │                       │                          │  store(session_id,   │                │              │
   │                       │                          │   "F_token_abc")     │                │              │
   │                       │                          │───────────────────────────────────────────────────>│
   │                       │                          │  OK                  │                │              │
   │                       │                          │<─────────────────────────────────────────────────│
   │                       │                          │                      │                │              │
   │                       │  { status: SUCCESS }     │                      │                │              │
   │                       │<─────────────────────────│                      │                │              │
   │                       │                          │                      │                │              │
   │                       │  MCP: submit_blocker     │                      │                │              │
   │                       │  { type: PASSCODE }      │                      │                │              │
   │                       │─────────────────────────>│                      │                │              │
   │                       │                          │  lookup passcode     │                │              │
   │                       │                          │  token for session   │                │              │
   │                       │                          │───────────────────────────────────────────────────>│
   │                       │                          │  "F_token_abc"       │                │              │
   │                       │                          │<─────────────────────────────────────────────────│
   │                       │                          │                      │                │              │
   │                       │                          │  submitBlockerInputs │                │              │
   │                       │                          │  (fidelius_token)    │                │              │
   │                       │                          │─────────────────────>│                │              │
   │                       │                          │  SUCCESS             │                │              │
   │                       │                          │<─────────────────────│                │              │
   │                       │                          │                      │                │              │
   │                       │  { success: "Card        │                      │                │              │
   │                       │    enabled" }             │                      │                │              │
   │                       │<─────────────────────────│                      │                │              │
   │                       │                          │                      │                │              │
   │  "Your card is        │                          │                      │                │              │
   │   now enabled!"       │                          │                      │                │              │
   │<──────────────────────│                          │                      │                │              │

Flow 2: Passcode Already Cached (Subsequent Operation in Same Session)

Customer              Client (Cash App)          moneybot-core             Plasma            DynamoDB
   │                       │                          │                      │                   │
   │  "Now disable it"     │                          │                      │                   │
   │──────────────────────>│                          │                      │                   │
   │                       │  MCP: disable_card       │                      │                   │
   │                       │─────────────────────────>│                      │                   │
   │                       │                          │  createFlow()        │                   │
   │                       │                          │─────────────────────>│                   │
   │                       │                          │  blocker: passcode   │                   │
   │                       │                          │<─────────────────────│                   │
   │                       │                          │                      │                   │
   │                       │                          │  lookup passcode     │                   │
   │                       │                          │  token for session   │                   │
   │                       │                          │──────────────────────────────────────────>│
   │                       │                          │  "F_token_abc" ✓     │                   │
   │                       │                          │<─────────────────────────────────────────│
   │                       │                          │                      │                   │
   │                       │                          │  submitBlockerInputs │                   │
   │                       │                          │  (fidelius_token)    │                   │
   │                       │                          │─────────────────────>│                   │
   │                       │                          │  SUCCESS             │                   │
   │                       │                          │<─────────────────────│                   │
   │                       │                          │                      │                   │
   │                       │  { success: "Card        │                      │                   │
   │                       │    disabled" }            │                      │                   │
   │                       │<─────────────────────────│                      │                   │
   │                       │                          │                      │                   │
   │  "Your card is        │                          │                      │                   │
   │   now disabled!"      │                          │                      │                   │
   │<──────────────────────│                          │                      │                   │

Key difference: No passcode prompt, no gRPC SubmitPasscode call — the cached Fidelius token is automatically retrieved and submitted to Plasma. The customer is never asked for their passcode a second time.

Flow 3: Cached Passcode is Invalid (e.g., Customer Changed Passcode)

Customer              Client (Cash App)          moneybot-core             Plasma            DynamoDB
   │                       │                          │                      │                   │
   │  "Enable my card"     │                          │                      │                   │
   │──────────────────────>│                          │                      │                   │
   │                       │  MCP: enable_card        │                      │                   │
   │                       │─────────────────────────>│                      │                   │
   │                       │                          │  createFlow()        │                   │
   │                       │                          │─────────────────────>│                   │
   │                       │                          │  blocker: passcode   │                   │
   │                       │                          │<─────────────────────│                   │
   │                       │                          │                      │                   │
   │                       │                          │  lookup cached token │                   │
   │                       │                          │──────────────────────────────────────────>│
   │                       │                          │  "F_token_abc" ✓     │                   │
   │                       │                          │<─────────────────────────────────────────│
   │                       │                          │                      │                   │
   │                       │                          │  submitBlockerInputs │                   │
   │                       │                          │  (stale token)       │                   │
   │                       │                          │─────────────────────>│                   │
   │                       │                          │  INVALID_PASSCODE    │                   │
   │                       │                          │<─────────────────────│                   │
   │                       │                          │                      │                   │
   │                       │                          │  delete stale token  │                   │
   │                       │                          │──────────────────────────────────────────>│
   │                       │                          │                      │                   │
   │                       │  BlockerRequired:        │                      │                   │
   │                       │  { type: PASSCODE,       │                      │                   │
   │                       │    requiresPasscode:true, │                      │                   │
   │                       │    reason: "invalid" }    │                      │                   │
   │                       │<─────────────────────────│                      │                   │
   │                       │                          │                      │                   │
   │  "Your passcode was   │                          │                      │                   │
   │   incorrect. Please   │                          │                      │                   │
   │   enter it again."    │                          │                      │                   │
   │<──────────────────────│                          │                      │                   │
   │                       │                          │                      │                   │
   │  Enters new passcode  │                          │                      │                   │
   │──────────────────────>│                          │                      │                   │
   │                       │  gRPC: SubmitPasscode    │                      │                   │
   │                       │  { session_id, "5678" }  │                      │                   │
   │                       │  ... (continues as Flow 1)                      │                   │

Behavioral Summary

Scenario Cached Token Exists? Plasma Verification Customer Prompted?
First passcode blocker in session No N/A — blocker returned to client Yes
Subsequent passcode blocker (same session) Yes Auto-submitted No
Cached token is invalid/expired Yes → deleted Fails → blocker returned Yes (re-prompt)
Session TTL expires No (TTL eviction) N/A — blocker returned to client Yes

Security Considerations

  1. Plaintext passcode never touches the LLM — The passcode is collected via a secure input rendered inline in the chat UI and submitted directly to moneybot-core's public gRPC endpoint, bypassing kgoose entirely. The MCP tool layer never sees the plaintext.

  2. Fidelius tokenization — The plaintext passcode is immediately tokenized via Fidelius PutSecret and only the opaque token ID is retained. moneybot-core never stores the plaintext passcode.

  3. Encryption at rest — The Fidelius token and customer token stored in DynamoDB are encrypted using Tink AEAD (moneybot-core-passcode-token-key), matching the existing flow_sessions encryption pattern.

  4. Session-scoped lifetime — Passcode tokens are scoped to a single Moneybot session with a 24-hour TTL. When the session ends or expires, the token is evicted. The token cannot be used across sessions.

  5. Customer token validation — When retrieving a cached passcode token, the interactor validates that the requesting customer token matches the one stored with the passcode token. This prevents cross-customer token reuse.

  6. gRPC authentication — The SubmitPasscode endpoint is a public gRPC endpoint authenticated directly by the Cash App client (customer-authenticated), not via kgoose service-to-service auth. This ensures the passcode flows directly from the client to moneybot-core without passing through any intermediary service.

  7. Stale token cleanup — If Plasma rejects a cached token (e.g., customer changed their passcode), the stale token is immediately deleted from DynamoDB to force re-collection.

Open Questions

  1. Passcode verification response — Should the SubmitPasscode gRPC call also validate the passcode against Janus/Plasma before storing, or should it only tokenize and store (deferring validation to when a blocker is actually encountered)?
  2. Token refresh — Should there be a mechanism for the client to update a cached passcode token (e.g., if the customer realizes they entered the wrong one), or is delete-and-resubmit sufficient?
  3. Multi-flow sessions — If a session has multiple concurrent Plasma flows, should there be one passcode token per session or per flow?
  4. Rate limiting — Should moneybot-core enforce passcode attempt rate limits (matching Janus's RateLimitConfigFranklinMirror.PasscodeConfirmationAttemptsPerPayment), or defer to Plasma's built-in rate limiting?
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment