Skip to main content

Instant Payment Memo Encryption

A cross-organization instant payment settles as a real MsgSend of wholesale CBDC between the two banks' ops accounts. The payment's retail details — who paid whom, and the concept — travel in the transaction memo. That memo is encrypted so only the two banks can read it: on a block explorer it appears as one opaque token, not a payment.

This page is meant to be read end-to-end: it starts with a plain-language explanation, then covers the cryptographic building blocks, the scheme and its wire format, the full send→settle→read sequence, the security model, and the key design decisions behind it. For where it sits in the broader chain flow, see Chain Integration.

The idea in plain language

No cryptography background needed — an analogy carries the whole thing.

The problem. A blockchain is like a glass pipe that every payment travels through in full view: anyone can look inside and read what's written on the parcel. For an interbank payment that parcel would normally spell out who paid whom and why — details neither bank wants on public display.

The fix: seal it in a strongbox. Before sending, the bank locks the payment details inside a strongbox. Now the glass pipe only shows a sealed box, not its contents.

The clever part: how can both banks open it — and nobody else? This is the piece that surprises people. Each bank has published a personal padlock to the world: anyone can click one of these padlocks shut, but only that bank holds the key to open it. So the sending bank:

  1. Locks the strongbox with one key.
  2. Makes two copies of that key.
  3. Puts one copy inside the recipient's padlock and the other inside its own padlock, and attaches both little locked boxes to the parcel.

When the parcel arrives, the recipient bank opens its padlock (with the key only it holds), takes out the copy of the strongbox key, and opens the strongbox. The sending bank can do exactly the same with its own copy. Everyone else on the pipe sees a sealed strongbox plus two padlocked boxes they have no key to — so the details stay private to just the two banks.

Why no secret handshake is needed. The banks never have to meet, call each other, or agree on a password beforehand. A bank's public padlock is enough for anyone to lock something up for it — but only that bank can ever open it. That is the entire trick: locking is public, opening is private.

Where our platform fits. Today the platform safeguards each bank's keys on their behalf, so it does the locking and opening for them. That keeps the details sealed against the outside world — anyone browsing the public ledger — which is exactly the goal. (The wholesale amount and which two banks transacted do stay visible, the way a bank-to-bank wire is inherently visible; only the retail who-and-why is sealed.)

The rest of this page makes each of these steps precise. The "strongbox" is AES-256-GCM encryption; the "personal padlock" is each bank's public key; and the way a key copy is locked into a padlock without a prior handshake is Diffie–Hellman key agreement.

What it hides — and what it doesn't

Still visible on-chain to anyoneSealed in the encrypted memo
The two bank ops-account addresses (from / to)The retail sender & recipient (alias, name, org)
The wholesale coin amount and denomThe internal paymentId
That a bank-to-bank transfer occurred, and whenThe free-text reference (payment concept)

Hiding the wholesale amount or the bank-to-bank relationship would require confidential-transaction cryptography (Pedersen commitments / range proofs) and is out of scope. The goal here is keeping retail identities and the concept off the explorer.

Custodial model, by design

The API holds both banks' keys (encrypted at rest with CUSTODY_SECRET), so it can technically decrypt either side. This protects confidentiality on-chain / in the explorer and binds each memo to the two org public keys — so a future non-custodial migration needs no format change. It is not end-to-end encryption against the platform operator.

Cryptographic building blocks

A short primer on each primitive and the role it plays here. If these are familiar, skip to the scheme. None of them are novel — the design is a standard multi-recipient hybrid encryption scheme (the same shape as JOSE / JWE) built from primitives already in the codebase.

  • Public-key cryptography. Each party has a public key it shares and a private key it keeps. Anyone can encrypt to a public key; only the private key decrypts. Each bank's ops account is one such keypair.
  • Elliptic-curve cryptography on the secp256k1 curve. The curve Bitcoin and Ethereum use: short keys (33-byte compressed public keys) at a high security level. It's already the curve these accounts sign chain transactions with, so no new key material is introduced — the encryption reuses the account keypair.
  • Elliptic-Curve Diffie–Hellman (ECDH). A key-agreement primitive: combining my private key with your public key yields the same shared secret as your private key with my public key. Someone who sees only the two public keys cannot compute it. This is what lets a bank re-derive the wrapping key from the on-chain ephemeral public key.
  • HKDF-SHA256. A key-derivation function built on SHA-256: it turns a raw ECDH secret into a uniformly-random symmetric key, bound to a salt and an info label. Using it (rather than an ECDH output directly) gives a proper key plus domain separation — each bank's wrapping key is distinct and can't be confused for the other's.
  • AES-256-GCM. Authenticated encryption (AEAD) built on AES: it hides the data and emits an authentication tag, so any tampering makes decryption fail rather than return forged plaintext. Used both to seal the memo and to wrap the content key. It requires a unique nonce / IV per encryption — not secret, never reused with the same key.
  • Hybrid encryption / ECIES. The pattern of encrypting bulk data with a fast symmetric key, then encrypting that key to a public key via ECDH. Our envelope is the multi-recipient variant: one content key, wrapped once per bank.
  • Ephemeral keys. A throwaway keypair generated per payment, so wrapping keys are never reused across payments — a step toward forward secrecy in the wrapping layer.

The scheme: envelope encryption, sealed to both parties

Each org's instant-payment ops account (instant_payment_accounts) already holds a secp256k1 keypair — a public key plus an AES-256-GCM–encrypted private key. The memo is encrypted to both the sender's and the recipient's account pubkeys, so either bank can decrypt independently.

The memo text is encrypted once under a fresh random content key; only that 32-byte key is then wrapped separately for each bank.

The Memo JSON holds the paymentId, both parties, amount, and reference; each rcp entry carries kid, iv, tag, and the wrapped key wk.

Step by step:

  1. Encrypt the memo once. Generate a fresh 256-bit content key (CEK). Encrypt the memo JSON a single time with AES-256-GCMct + iv + tag.
  2. One ephemeral key. Generate a throwaway ephemeral keypair. Its public half epk is published once and doubles as the HKDF salt; the private half is used, then discarded.
  3. Wrap the CEK for each bank. For each party pubkey P:
    • S = ECDH(ephemeralPriv, P) — the shared secret (32-byte X coordinate).
    • KEK = HKDF-SHA256(S, salt=epk, info="ip-memo-kek|" + P).
    • wk = AES-256-GCM(KEK, CEK) — the wrapped content key, tagged by kid = sha256(P)[:8].

Because ECDH(ephemeralPriv, P) differs per pubkey P, one ephemeral key safely produces a distinct wrapping key for each bank. Both wraps carry the same CEK, so both banks recover the identical memo.

End-to-end sequence

The same primitives, seen over time — from sealing the memo on the sender's node, through settlement, to a bank recovering it off the chain. The chain (and anyone watching it) only ever handles the opaque token.

The sender never contacts the recipient to set this up: the recipient's public key (already on its ops account) is enough to seal a memo it alone can open. Decryption is symmetric — the recipient reproduces the exact shared secret from its own private key and the published epk.

On-chain format

The memo string is a compact token:

ip1.<base64url(JSON envelope)>

The ip1. prefix is the only cleartext part — a scheme + version marker the reconciliation scanner filters on, and the discriminator that selects the decrypt path over the legacy cleartext path. Everything after the dot is opaque base64url.

FieldVisibilityPurpose
ip1.publicScheme + version marker. Cheap prefix the block scanner routes on.
epkpublicEphemeral public key. Each bank combines it with its private key to reproduce the shared secret. Also the HKDF salt.
ctsecretThe memo, AES-256-GCM encrypted under the CEK. Opaque without the key.
iv / tagpublicNonce and GCM auth tag for ct. The tag makes tampering fail loudly instead of decrypting to garbage.
rcp[].kidpublicKey id — sha256(bankPubkey)[:8]. Lets a bank find its entry. A hash of a public key: leaks nothing.
rcp[].wksecretThe content key, wrapped to this bank's key. Only the matching private key can unwrap it.
rcp[].iv / tagpublicNonce and auth tag for the wrap.

Encoding adds ~33%, so the token lands around 1 KB — comfortably under the tx.max_memo_characters ceiling (guarded at DEFAULT_MAX_MEMO_BYTES, failing before the sender is debited if exceeded).

Decryption

Decryption is the mirror image and fully deterministic — every piece of randomness (CEK, ephemeral key, IVs) was captured in the envelope. A bank needs only its own private key:

kid = sha256(myPublicKey).slice(0, 8); // find my entry
entry = envelope.rcp.find((r) => r.kid === kid);
S = ECDH(myPrivateKey, envelope.epk); // same S the sender computed
KEK = HKDF_SHA256(S, /*salt*/ epk, /*info*/ 'ip-memo-kek|' + myPublicKey);
CEK = AES_GCM_unwrap(KEK, entry.wk); // recover the shared content key
memo = AES_GCM_decrypt(CEK, envelope.ct); // back to the plaintext memo

The ECDH step runs inside InstantPaymentCustodyService (deriveSharedSecret), so the decrypted private key never leaves custody — the same containment used for transaction signing. As long as the account's key and CUSTODY_SECRET still exist, any historical memo can be re-derived from its on-chain token at any time.

Security model

Who the adversary is. Anyone who can read the chain — a block-explorer user, an archive node, another chain participant. They see the transaction, but not the private keys.

What is guaranteed

  • Confidentiality. The memo contents (parties, paymentId, reference) are readable only by a holder of the sender's or the recipient's ops-account private key. Recovering the content key from the public envelope would require breaking ECDH on secp256k1 or AES-256.
  • Integrity & authenticity. Every ciphertext carries an AES-GCM tag, so a memo modified on-chain fails to decrypt rather than yielding a different, attacker-chosen memo. Decryption fails closed.
  • Independent access. Each bank decrypts with its own key alone — no shared secret is provisioned ahead of time and neither bank needs the other's private key.

Trust assumptions

  • Custodial. The platform stores both banks' private keys (encrypted with CUSTODY_SECRET), so it can decrypt any memo. Confidentiality here is against external observers, not the operator — this is a deliberate fit with the existing custody model, not an oversight.
  • CUSTODY_SECRET. The same root of trust that already protects transaction signing now also gates memo decryption. Lose it and both break; it never leaves the server.

Non-goals (explicitly out of scope)

  • Amounts and counterparties. The wholesale coin amount and the two bank ops-addresses are inherent to the MsgSend and stay public; hiding them needs confidential transactions.
  • Secrecy against the operator. True end-to-end encryption would require non-custodial, bank-held keys.
  • Existence metadata. The ip1. marker (and the visible transfer between two known bank ops accounts) reveals that an instant payment happened — just not its retail detail.

Key design decisions

Each row is a decision, the choice made, and the reason — including a couple of honest trade-offs.

DecisionChoiceWhy
Multi-recipient shapeEnvelope: encrypt the memo once, wrap the CEK per bankEncrypt-twice would duplicate the whole memo; the envelope adds only ~48 bytes per bank and scales to more recipients (e.g. a future regulator/oversight key) without re-encrypting.
Ephemeral key countOne ephemeral keypair for both banksECDH(ephPriv, P) already differs per pubkey P, so one published epk yields distinct wrapping keys — a smaller envelope with no security loss.
KDFHKDF-SHA256 with salt=epk, info="ip-memo-kek|"+PA raw SHA-256(S) would work but gives no domain separation; the salt + info bind each KEK to this payment and to the specific recipient so the two wraps can't be confused.
Symmetric modeAES-256-GCM (AEAD) everywhereTamper-evidence for free: a mangled on-chain memo fails to decrypt instead of forging one. An unauthenticated mode would need a separate MAC.
On-chain markerCleartext ip1. prefix, opaque remainderCheap prefix routing + versioning for the scanner. The trade-off — leaking "this is an instant payment" — is already implied by a visible MsgSend between two known bank ops accounts.
Private-key handlingECDH inside custody (deriveSharedSecret)The decrypted private key never leaves InstantPaymentCustodyService, mirroring how signing is contained. The cipher only ever sees the resulting shared secret.
DB vs on-chain copyCleartext in the DB, encrypted only on-chainThe initiating node serves its own UI from the DB row (never exposed through a DTO), so there's no decrypt on the hot read path — only the ledger copy is sealed.
RolloutAlways on, read path auto-detects formatOne production code path. Memos starting with ip1. decrypt; older cleartext memos still parse, so history keeps reconciling.
Key rotationDeferred — one key per org todayNo rotation flow exists yet; the kid field already allows matching against multiple historical keys when one is added, so the format won't need to change.
Key reuseReuse the account's signing key for ECDHAvoids a second key-management surface. The trade-off: the same key signs and agrees keys; acceptable given the custodial model, though a stricter design would separate signing and encryption keys.

Timing

Sealing the memo happens inside the payment's preparation window, so its cost is already part of totalMs. The TimingCollector brackets the encrypt call (markMemoEncryptionStart / markMemoEncryptionEnd) and reports it as encryptionMs — a component of preparationMs, not an extra phase in the preparation → response sum. The payment detail view renders it as an indented sub-line under Preparation so its share is attributable without changing the phase totals.

Rollout & backward compatibility

Encryption is always on for new cross-org payments. The read path auto-detects the format: memos starting with ip1. are decrypted; older cleartext-JSON memos (written before this change) still parse via the legacy path, so historical payments keep reconciling. The plaintext memo is also persisted in the DB row as the initiating node's own record (never exposed through a DTO); only the on-chain copy is encrypted.

Where it lives

ConcernFile (apps/api/src/modules/instant-payments/)
Encrypt / decrypt + envelope encodingservices/memo-cipher.service.ts (MemoCipherService)
ECDH inside custodyservices/instant-payment-custody.service.ts (deriveSharedSecret)
Sealing on sendservices/instant-payment-send.service.ts
Decrypt on reconcileservices/instant-payment-block-scanner.service.ts
Envelope types & ip1. constantstypes/memo.types.ts

Primitives come from Node's crypto (hkdfSync, AES-256-GCM) and elliptic (secp256k1 ECDH) — no new dependencies.