Skip to content

Export CPS-style key-material API for batch decrypt/derive/signened indices#688

Closed
disassembler wants to merge 1 commit into
masterfrom
sl/with-decrypted-key-material
Closed

Export CPS-style key-material API for batch decrypt/derive/signened indices#688
disassembler wants to merge 1 commit into
masterfrom
sl/with-decrypted-key-material

Conversation

@disassembler

Copy link
Copy Markdown
Contributor

Description

Background

cardano-crypto-wallet is a new Haskell package in cardano-base that provides
authenticated encryption and HD key derivation for wallet root keys. It replaces
the dependency on the older cardano-crypto C library for wallet key operations.

The envelope format ("v2") stores each root key as a CBOR-encoded structure
containing:

  • a random 32-byte salt and 24-byte nonce generated fresh on every write
  • a 32-byte wrapping key derived from the user passphrase via Argon2id
    (128 MiB memory, 3 iterations, parallelism 4)
  • the 64-byte extended secret key encrypted with XChaCha20-Poly1305 AEAD
  • the 32-byte public key and 32-byte chain code bound as AEAD additional data
    (authenticated but not encrypted)

The plaintext secret key is held exclusively in sodium_malloc'd memory
(MLockedSizedBytes) for its entire lifetime — the allocator marks the pages
non-swappable (mlock) and zeroes them on free. It never touches the GC heap.


CPS-style key-material API (this PR)

Motivation

Every call to encryptedSign or encryptedDerivePrivate today runs the full
Argon2id KDF — by design, this costs ~128 MiB of memory and ~300 ms of CPU on
typical hardware. That is appropriate for a single interactive passphrase check,
but it is prohibitively expensive when a wallet needs to derive a batch of child
keys or sign a batch of transactions from the same root.

The existing high-level API (encryptedSign, encryptedDerivePrivate) also
re-encrypts after every operation, meaning each call is:

passphrase + ciphertext → [Argon2id KDF] → wrapping key → [XChaCha20 decrypt]
→ plaintext key → [operation] → [fresh salt + nonce] → [XChaCha20 encrypt]
→ new ciphertext

For a batch of N operations, that is N Argon2id invocations and N
encrypt/decrypt round-trips.

New exports

-- Abstract type (no constructors exported)
KeyMaterial   -- :: Validity -> *
Validated     -- :: Validity  (phantom, from TypeData)

-- Decrypt root key once; callback receives validated key material
withDecryptedKeyMaterial
  :: ByteArrayAccess passphrase
  => EncryptedKey
  -> passphrase
  -> (KeyMaterial Validated -> IO (Either XPrvError a))
  -> IO (Either XPrvError a)

-- Derive a child in locked memory; CPS so child is freed on callback return
deriveKeyMaterial
  :: DerivationScheme
  -> KeyMaterial Validated
  -> DerivationIndex
  -> (KeyMaterial Validated -> IO (Either XPrvError a))
  -> IO (Either XPrvError a)

-- Sign without a passphrase round-trip
signWithKeyMaterial
  :: ByteArrayAccess msg
  => KeyMaterial Validated
  -> msg
  -> IO (Either XPrvError Signature)

-- Read the 32-byte public key out of in-memory key material
keyMaterialPublicBytes
  :: KeyMaterial Validated -> ByteString

Intended usage pattern

result <- withDecryptedKeyMaterial rootKey passphrase $ \km -> do
  -- one Argon2id invocation total
  sigs <- forM messages $ \msg -> signWithKeyMaterial km msg
  children <- forM indices $ \idx ->
    deriveKeyMaterial DerivationScheme2 km idx $ \childKm -> do
      -- childKm is in locked memory; freed when this lambda returns
      pure (Right (keyMaterialPublicBytes childKm))
  pure (Right (sigs, children))

N operations, 1 KDF invocation.


Security analysis

Secret key lifecycle

KeyMaterial Validated wraps a SecretKey, which is a newtype over
MLockedSizedBytes 64. The locked-memory guarantees are:

  • Allocated via sodium_malloc / mlsbNew (a libsodium allocator backed by
    mlock-ed pages with guard pages on both sides).
  • Never moved by the GCMLockedSizedBytes is a stable pointer into
    C-allocated memory; Haskell's garbage collector cannot relocate it.
  • Cannot be swapped to diskmlock prevents the OS from paging the
    region out.
  • Zeroed on freemlsbFinalize calls sodium_free, which zeroes the
    region before releasing it.

KeyMaterial constructors are not exported. The only way to obtain a
KeyMaterial Validated is through withDecryptedKeyMaterial or
deriveKeyMaterial. Both are CPS: the callback receives the key material and
the bracket around mlsbNew/mlsbFinalize guarantees the memory is zeroed when
the callback returns — whether normally or via exception.

Why CPS is more secure than returning KeyMaterial

If withDecryptedKeyMaterial returned IO (Either XPrvError (KeyMaterial Validated)) instead of taking a callback, the caller could let the KeyMaterial
value escape into a lazy data structure, be captured by a closure, or be held
alive across a GC cycle. In that model the locked memory lives until the
KeyMaterial's finalizer fires — which in Haskell is non-deterministic.

With CPS, the secret key's lifetime is lexically bounded by the callback.
The compiler and runtime cannot extend it beyond that scope, and the bracket
ensures cleanup even in the presence of asynchronous exceptions.

Validated phantom type

KeyMaterial carries a phantom type parameter drawn from type data Validity = Validated | Unchecked. Validated is only produced by validateKeyMaterial,
which calls the C function cardano_crypto_wallet_validate to verify that the
32-byte public key in the material is the correct Ed25519 public key for the
64-byte secret key. This check prevents a confused-deputy attack where a
maliciously crafted EncryptedKey could cause signing under a key the user did
not intend.

Unchecked is an internal-only constructor; it is not exported. Callers can
only ever interact with KeyMaterial Validated.

encryptedDerivePublic and unsafePerformIO

The function uses unsafePerformIO to call the C derivation function, which
requires pointers. This is safe because:

  1. The function is deterministic: given the same (PublicKey, ChainCode, DerivationIndex, DerivationScheme), it always returns the same result.
  2. The PinnedSizedBytes values it reads are immutable for the duration of
    the call — psbUseAsCPtr provides a stable pointer into pinned memory that
    cannot be moved or mutated by concurrent Haskell threads.
  3. No mutable state is written; the C function only reads its inputs and writes
    to freshly allocated output buffers that are immediately consumed.
  4. The hardened-index guard (childIndex >= 0x80000000) is a pure check that
    short-circuits before any I/O.

What is NOT provided here

  • Passphrase caching: the caller must supply the passphrase to
    withDecryptedKeyMaterial. This package never stores a passphrase or derived
    wrapping key between calls. Passphrase retention policy is the caller's
    responsibility.
  • Root key derivation from mnemonic: out of scope. This package operates on
    already-constructed EncryptedKey values.
  • Multi-threading: KeyMaterial values should not be shared across threads.
    The locked-memory allocator is thread-safe for allocation/deallocation, but
    concurrent reads of the same SecretKey pointer are untested and unnecessary —
    derive per-thread if needed.

Version

cardano-crypto-wallet 0.2.0.0. The breaking changes in this version are:

The new CPS API is additive and does not break existing callsites.

Checklist

  • Commit sequence broadly makes sense and commits have useful messages
  • New tests are added if needed and existing tests are updated
  • All visible changes are prepended to the latest section of a CHANGELOG.md for the affected packages.
    New section is never added with the code changes. (See RELEASING.md)
  • When applicable, versions are updated in .cabal and CHANGELOG.md files according to the
    versioning process.
  • The version bounds in .cabal files for all affected packages are updated.
    If you change the bounds in a cabal file, that package itself must have a version increase. (See RELEASING.md)
  • Self-reviewed the diff

Callers can now decrypt the root key once with withDecryptedKeyMaterial,
then batch-derive children via deriveKeyMaterial and sign via
signWithKeyMaterial, amortising the expensive Argon2id KDF over many
operations. Secret key material stays in sodium_malloc'd memory for the
duration of the callback and is zeroed on return.
Comment on lines +730 to +736
deriveKeyMaterial ::
DerivationScheme ->
KeyMaterial Validated ->
DerivationIndex ->
(KeyMaterial Validated -> IO (Either XPrvError a)) ->
IO (Either XPrvError a)
deriveKeyMaterial = legacyDerivePrivate

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of adding a new function, I'd recommend simply renaming legacyDerivePrivate -> deriveKeyMaterial, since legacyDerivePrivate was a local implementation detail and was not previously exported

KeyMaterial Validated ->
msg ->
IO (Either XPrvError Signature)
signWithKeyMaterial keyMaterial msg =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a copy and paste of most of the body of the encryptedSign function.
DRY

encryptedChainCode,

-- * CPS-style key-material API
KeyMaterial,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a plan to rename KeyMaterial to Ext[ended]KeyMaterial, mentioned in #679
Since now we want to expose it, it makes sense to rename it now.


-- | Extract the 32-byte public key from in-memory key material.
keyMaterialPublicBytes :: KeyMaterial Validated -> ByteString
keyMaterialPublicBytes = publicKeyByteString . kmPublicKey

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PublicKey is exposed together with various tooling. So, it would be better to expose a separate accessor for PUblicKey and users can then use publicKeyByteString, publicKeyByteArray or otherexposed function that operate on PublicKey.

@lehins

lehins commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Closed in favor of more complete #689

@lehins lehins closed this Jul 14, 2026
@lehins lehins mentioned this pull request Jul 14, 2026
6 tasks
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.

2 participants