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.
- 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.
- 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.
- 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.
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.
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)
- Fidelius tokenization (RealFideliusClient): Generates a
CustomerPasscodeobject with the plaintext passcode and a randomP_-prefixed token. Serializes to JSON and stores via FideliusPutSecretwith category"customer_passcode". Returns a Fidelius token ID. - Blocker submission: Wraps the Fidelius token in
VerifyPasscodeInputsproto and submits to Plasma asBlockerInputs. - 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.
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) │ │
│ └──────────────────────┘ └────────────────┘ └────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
// 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;
}
}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.
// 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,
)// 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
}
}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()
}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!" │ │ │ │ │
│<──────────────────────│ │ │ │ │
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.
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) │ │
| 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 |
-
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.
-
Fidelius tokenization — The plaintext passcode is immediately tokenized via Fidelius
PutSecretand only the opaque token ID is retained. moneybot-core never stores the plaintext passcode. -
Encryption at rest — The Fidelius token and customer token stored in DynamoDB are encrypted using Tink AEAD (
moneybot-core-passcode-token-key), matching the existingflow_sessionsencryption pattern. -
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.
-
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.
-
gRPC authentication — The
SubmitPasscodeendpoint 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. -
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.
- Passcode verification response — Should the
SubmitPasscodegRPC 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)? - 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?
- Multi-flow sessions — If a session has multiple concurrent Plasma flows, should there be one passcode token per session or per flow?
- Rate limiting — Should moneybot-core enforce passcode attempt rate limits (matching Janus's
RateLimitConfigFranklinMirror.PasscodeConfirmationAttemptsPerPayment), or defer to Plasma's built-in rate limiting?