Skip to content

feat(module): let the module hold the key, over confidential calls - #23

Merged
senamakel merged 1 commit into
mainfrom
module-holds-the-key
Aug 14, 2026
Merged

feat(module): let the module hold the key, over confidential calls#23
senamakel merged 1 commit into
mainfrom
module-holds-the-key

Conversation

@senamakel

Copy link
Copy Markdown
Member

What changes

Three methods, each reachable only by a confidential call:

DeriveAccount(SecretMaterial)  -> DerivedAccount   // address + public key, no key out
SignTransaction(SignRequest)   -> SignedTransaction // derive, build, sign, assemble
ExportKey(ExportRequest)       -> ExportedKey       // the one method that discloses a key

BuildUnsigned / AttachSignature are unchanged and not deprecated.

Why both flows stay

The existing split — host holds the key, module returns digests, host signs — is
the right shape whenever the backend is reachable: a service in its own
process, across a socket. There the bus cannot say what is on the other end, so
the key must not go.

tinybus can now say for a loaded backend. A confidential message goes to a
module whose artifact the host hashed against a digest an operator asserted, or
to nobody: never to a transport peer, never fanned out, never printed by
monitor. For a host that loads this module the new flow is strictly better —
it stops linking the derivation and signing stack entirely.

This is admission control, not isolation. A loaded module shares the host's
address space and can read host memory directly; it never needed the bus to
reach a secret. What the rule buys is that the bus will not be the delivery
mechanism
for code nobody allowlisted. A backend whose compromise must not
reach a key belongs in a separate process — where this design makes it
ineligible, and where the original pair is the one to use.

The parts worth reviewing

  • Debug is hand-written on SecretMaterial and ExportedKey, printing
    <redacted>. A derived one would put a live recovery phrase into every log
    line, panic message and error report that ever formatted a request — the exact
    leak this arrangement prevents, arriving through the back door. Asserted, not
    left to review.
  • Chain agreement is enforced. SignTransaction refuses a request whose
    secret.chain disagrees with its transaction's chain. Without it a Solana
    phrase walked with EVM rules would sign a real EVM transaction from an address
    the user has never seen, and nothing downstream would object.
  • The wildcard Scheme arm refuses rather than guesses. Scheme is
    #[non_exhaustive], so a future variant compiles against this build and
    arrives at that match. Falling back to either arm would sign real bytes with
    the wrong scheme.
  • Wiping is described honestly. The phrase is zeroized after each call. That
    bounds this module's copy — keeping it out of core dumps and swap — and does
    not reach the JSON frame the bus decoded from or reallocations String made
    while decoding. The doc says exactly that rather than implying more.
  • wire stays dependency-free. The new types are serde-only; cargo check -p tinywallet --no-default-features --features wire still passes, so a host
    can take the contract without linking a chain library. That is also why
    SecretMaterial cannot implement Drop itself — zeroize is a dependency.

A drift trap this surfaced

module_export!'s methods = [...] is hand-maintained and unchecked by the
compiler
— a method missing there is simply not advertised to a host. The E2E's
EXPECTED_METHODS guard compares it against another hand-written list, so
neither caught three new interface methods. Both lists are updated and the
coupling is now written down in both places.

Tests

7 new unit tests plus an E2E case against the real dlopen'd module.

The two that carry the argument:

  • the_one_shot_path_agrees_with_the_split_path_byte_for_byte — signing from
    the phrase must equal the split flow exactly, or moving the key has changed
    what gets broadcast. Repeated for Solana, because it is the ed25519 arm and the
    only chain whose payload is the message rather than a digest, so the secp256k1
    case says nothing about it.
  • refuses_a_confidential_call_to_an_unattested_module (E2E) — the module is
    loaded from a bare path with no modules.toml, so nobody vouched for it and
    the broker refuses to carry the phrase. The same call unflagged is then
    carried and answered, so the test cannot pass by the method being broken or
    unadvertised. This is the negative half; a positive result alone would be
    equally consistent with a broker that hands secrets to anyone.

Also covered: the published BIP-39 vector address, that DeriveAccount returns a
public key and not the secret, and that a rejected phrase is not quoted back in
the error.

cargo test --workspace                      341 passed, 0 failed
TINYWALLET_TEST_MODULE=… --test module_e2e  1 passed (real loader)
clippy --workspace --all-targets -D warnings  clean
fmt --check                                   clean
cargo check -p tinywallet --no-default-features --features wire  clean

Follow-up

Needs a release before openhuman can use it, and openhuman then drops its
tinywallet feature set from 71 packages to 15.

Adds DeriveAccount, SignTransaction and ExportKey. Each takes a recovery
phrase and may only be reached by a confidential call, which tinybus
delivers to a module whose artifact the host hashed against a digest an
operator asserted, or to nobody.

The existing BuildUnsigned/AttachSignature pair is unchanged and not
deprecated. It is still the right shape whenever the backend is reachable
across a transport, where the bus cannot say what is on the other end.
The new set is for a loaded backend, where it is strictly better: the host
stops linking the derivation and signing stack at all.

This is admission control, not isolation. A loaded module shares the
host's address space and never needed the bus to reach a secret; what the
rule buys is that the bus will not deliver one to code nobody allowlisted.

Notes:
- SecretMaterial and ExportedKey implement Debug by hand and print
  <redacted>. A derived one would put a live recovery phrase into every
  log line and panic message that formatted a request.
- SignTransaction refuses a request whose derivation chain disagrees with
  its transaction's chain, which would otherwise sign from an address the
  user never saw.
- The phrase is wiped after each call. That bounds this module's own copy;
  it does not reach the JSON frame the bus decoded from, and the doc says
  so rather than implying more.
- module_export!'s method list is hand-maintained and unchecked by the
  compiler, so the three new methods are added there too and the E2E's
  drift guard extended to match.

Co-authored-by: Medulla <medulla@tinyhumans.ai>
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@senamakel, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 38 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 59af72fa-6199-4b64-b46f-dfb7f511b0c8

📥 Commits

Reviewing files that changed from the base of the PR and between 6e8b69c and a538ce5.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (5)
  • crates/tinywallet-module/Cargo.toml
  • crates/tinywallet-module/src/service/mod.rs
  • crates/tinywallet-module/src/service/test.rs
  • crates/tinywallet-module/tests/module_e2e.rs
  • src/wire/mod.rs

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@senamakel
senamakel merged commit 2b91757 into main Aug 14, 2026
11 checks passed
@senamakel
senamakel deleted the module-holds-the-key branch August 14, 2026 07:58
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.

1 participant