Skip to content

feat(samples): custody API signing challenge example (challenge + verify + ES256 assertions + withdrawal) - #77

Merged
enforser merged 2 commits into
mainfrom
custody-api-signing-example
Nov 12, 2026
Merged

enforser merged 2 commits into
mainfrom
custody-api-signing-example

Conversation

@enforser

@enforser enforser commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

What

A single-file Python example (samples/python/custody_withdrawal.py) for the custody API signing challenge and authorized withdrawals.

Prerequisite (stated prominently in the README and the example docstring): this API is not self-serve. Integrators must contact Gemini support, who enable the custody API for the account group, configure the custody account's approved security policy with a positive API signer threshold, and register the customer's API signer keypairs (COSE_Key-encoded P-256 public keys) on the account. Every withdrawal fails closed until that enrollment is complete.

Why the verification step matters

The hard part of integrating this API is client-side: a verifier must reconstruct the canonical authorization context byte-for-byte and re-derive the challenge. Any re-serialization (different key order, whitespace, re-encoding of the embedded signing strings) produces a different digest. The example therefore embeds the verification engine in the same file and runs it on every flow:

  • rebuilds the canonical context from the complete received action/policy signing strings (never re-serialized, never trusting a server-supplied digest)
  • SHA-256 → unpadded base64url → GEMINI-API-SIGNATURE-V1:<challenge>
  • validates the response against a locally trusted intent: account, single-leg transfer shape, asset/amount/address/memo, policy quorum, expiry window
  • raises field-specific VerificationErrors for every mismatch

The verification section is deliberately stdlib-only and keyless, so integrators can audit it or lift it into their own language.

Files

File Purpose
samples/python/custody_withdrawal.py End-to-end flow + embedded verifier: signed POST /v1/custody/challenge → local challenge verification → ES256 (COSE P-256) assertion per approved signer → signed POST /v2/withdraw/:network/:ticker
samples/README.md New "Custody Withdrawals (Support-Enrolled Accounts Only)" section with the prerequisite, flow, and usage; endpoint table/structure updates
samples/python/requirements.txt Adds cryptography (ES256 signing in the example only; the verification section stays stdlib-only)

Contract

Implements the /v1/custody/challenge wire contract, version "1" (message prefix GEMINI-API-SIGNATURE-V1:). The protocol constants are carried as code so drift against a future contract version fails loudly.

Validation

  • End-to-end against production: XRP withdrawal with a 3-of-3 signer threshold — challenge minted, challenge verified by the embedded verifier against the live response, three ES256 assertions built, withdrawal accepted (HTTP 200)
  • The assertion builder was exercised against a real registered P-256 key: the COSE encoding matches the registered public key byte-for-byte
  • Note: on the wire, a transfer leg omits memo (rather than sending null) when a transfer has no memo; the verifier treats absent and null identically

Naming (commit 2)

Customer-facing language no longer carries the MPC prefix:

  • withdraw V2 request field mpcAuthorization → authorization
  • signer key env vars MPC_SIGNER_KEY_<n> → API_SIGNER_KEY_<n>
  • README/docstring prose says custody API / custody account instead of MPC custody

Internal server-side naming keeps the mpc prefix. Companion gemini monorepo PR renames the wire field and the customer-facing error messages/reasons identically: gemini/gemini#178681

@sdlc-pr-bot

sdlc-pr-bot Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Change Control Evidence Check

Authorization — ❌ Fail

Evidence:

  • Linear ticket: ⚠️ No ticket linked
  • Change owner: Matthew Fors
  • Type of change: Normal
Testing — ✅ Pass

Evidence:

  • Tests from CI checks: ➖ No tests ran
  • Security scans: ✅ Passed
  • Testing summary: ✅ Found in PR description
    • Summary: XRP withdrawal functionality was tested end-to-end against production with a 3-of-3 signer threshold, resulting in successful challenge verification, three ES256 assertions, and HTTP 200 acceptance. The assertion builder was validated against a real registered P-256 key with byte-for-byte COSE encoding match to the registered public key.
  • Evidence link: View run
Approval — ✅ Pass

Evidence:

  • Approver: Victor Reventos Rosario
  • Approval source: GitHub PR Review
  • Approval timestamp: 2026-11-12 17:07 UTC
Segregation of Duties — ✅ Pass

Validated:

  • PR author: Matthew Fors
  • Commit author(s): enforser
  • Approver: Victor Reventos Rosario
  • Result: Implementer and approver are different people ✅

Last checked: 2026-11-12 17:07 UTC

↩ Re-run Clarissa if tickets, CI, or approvals have changed.

data=b"",
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
Single-file end-to-end Python example for MPC custody withdrawals:
POST /v1/custody/challenge -> independent local challenge verification
(rebuilds the canonical signing context and re-derives the SHA-256 challenge;
never trusts a server-supplied digest) -> ES256 (COSE P-256) assertions from
approved signers -> POST /v2/withdraw.

The verification engine is embedded in the same file (stdlib-only section).
README documents the support-enrollment prerequisite: custody API enablement,
account security policy with an API signer threshold, and signer keypair
registration must all be arranged with Gemini support before the API is
usable. Validated end-to-end against production (XRP, 3-of-3 threshold).
@enforser
enforser force-pushed the custody-api-signing-example branch from 2020765 to 1d0ad7c Compare November 10, 2026 23:37
@enforser
enforser marked this pull request as ready for review November 10, 2026 23:38
@switchboard-pr-review-assigner
switchboard-pr-review-assigner Bot requested review from deeputom and removed request for mars-steven November 10, 2026 23:40
@nostradamus-bot

Copy link
Copy Markdown

Nostradamus Risk Rating — Medium

samples/python/custody_withdrawal.py implements HMAC-SHA384 API signing and hand-rolled ES256/COSE P-256 assertions for MPC custody withdrawals — a privileged financial operation — in example code that integrators are likely to adopt verbatim; correctness of the custom COSE CBOR encoder and challenge-verification engine is the primary security concern. The change is confined entirely to samples/ with no production server-side attack surface introduced, placing it at Medium (Impact: High × Likelihood: Medium per NIST SP 800-30r1).

vjames19
vjames19 previously approved these changes Nov 11, 2026
The public API surface should not carry MPC verbiage: the withdraw V2
request field is `authorization` (was `mpcAuthorization`), signer key
env vars are `API_SIGNER_KEY_<n>`, and prose refers to the custody
API/account rather than the MPC custody API/account. Server-side
internal naming keeps the mpc prefix.

Companion gemini monorepo PR renames the wire field and customer-facing
error messages/reasons identically.
@enforser enforser changed the title feat(samples): MPC custody API signing challenge example (challenge + verify + ES256 assertions + withdrawal) feat(samples): custody API signing challenge example (challenge + verify + ES256 assertions + withdrawal) Nov 12, 2026
@enforser
enforser merged commit cebf8ee into main Nov 12, 2026
8 of 9 checks passed
@linear-code

linear-code Bot commented Nov 12, 2026

Copy link
Copy Markdown

ONC-10207

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants