* fix(keystore): close nine input-validation and at-rest defects
Each of these let a caller get a signature, or a file, that differed from what
was asked for or what a human would have been shown. One test per defect.
* parse_u64 was `parse_u128(..)? as u64` — a wrapping cast on the path that
feeds `nonce` and `gas_limit`, so `0x10000000000000005` and `0x5` produced
BYTE-IDENTICAL signed transactions. Now checked.
* UnsignedTx accepted unknown fields, so a camelCase or typo'd key silently
defaulted to 0. Now `deny_unknown_fields`.
* An absent or blank `to` fell through to TxKind::Create: a transfer whose
recipient failed to render became a contract deployment that burned the
value. Deployment now requires an explicit `create: true`.
* fee_mode was fail-open — anything not exactly "legacy" signed EIP-1559, so
`""` and `"eip1599"` both signed with max_fee_per_gas 0 while ignoring
gas_price, and `" legacy"` was not legacy. Now a trimmed closed set.
* access_list was hardcoded to Default::default() and was not even a field, so
a caller's EIP-2930 list was dropped and the signature covered a different
transaction. Now refused with a reason rather than stripped.
* sign_message signed unfiltered bytes, so bidi overrides and zero-width
characters could render as one thing and sign as another. Refused, never
rewritten — the signed bytes must stay exactly what the caller supplied —
plus an 8 KiB cap, because a signer must not sign what cannot be displayed.
* import_keystore_json handed caller-supplied KDF params straight to scrypt:
n = u32::MAX asks for a ~4 TiB allocation and aborts the module process from
a single hostile file. Clamped before any derivation is attempted.
* The import temp file was written at the default umask (0644) under a
guessable `$TMPDIR/logos-ks-import-<nanos>.json` and NEVER removed, leaving
a world-readable copy of the caller's vault — salt and KDF params included —
in shared temp. Now O_EXCL, 0600, random name, unlinked on drop.
* The vault directory was 0755 and each vault file 0644; there was no
set_permissions anywhere in the crate. Now 0700 and 0600.
Also wipe decrypted key material on drop: `signer.to_bytes()` hands back the raw
secp256k1 secret, and decrypt_key's output was dropped unzeroized. Scoped
honestly — this covers buffers this crate owns, not copies made across the IPC
boundary.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(keystore): human approval replaces the unlocked-signer cache
Signing is now vault access. There is no cached signer to steal and no
`unlock` to leave one behind.
The cache is DELETED, not policy-stubbed. `Unlocked`, the `unlocked` map,
`unlock`, `timed_unlock`, `lock`, `is_unlocked` and `live_signer` are gone, and
with them the shape that made this worth fixing: entries carried an OPTIONAL
deadline and the only caller passed `None`, so one `unlock` was an unlimited,
process-lifetime signer that any module able to reach this one could spend.
Keeping those methods "for the unit tests" is exactly what would have preserved
the machinery, so the tests were rewritten instead.
In their place, `src/approval.rs` — pure, offline, and therefore covered by
`cargo test --no-default-features`:
* ONE PARSE. The intent is parsed once into typed legs; the lines a human
reads and the bytes that get signed come from that same value, and
`approve` re-derives the commitment before signing, so they cannot drift.
* bundle_id = SHA-256 over OUR canonical re-encoding, never the requester's
bytes — reformatting cannot change what the commitment covers. Deliberately
not keccak256: a commitment must never be mistakable for a signable digest.
* AT MOST ONE RENDERED RECORD. Acknowledging a second demotes the first.
Without this, an async UI update between "the human read handle A" and "the
human clicked Approve" submits handle B with every other invariant intact —
a signature over something nobody saw.
* A bundle is one decision: N legs, one password entry, one key derivation.
* The receipt, not the handle, authorises collecting a result. The handle is
announced on an event plane that carries no token, so it must suffice for
nothing on its own. Results are idempotent until `ack_result`, because a
dropped reply must not cost the human a second password entry.
* Settled records are retained briefly so a requester always learns WHY.
Dropping them immediately turned "your approval expired" into "no such
handle", which a requester cannot tell from a bug of its own.
The module surface splits by caller identity: only the configured approver
(default `signer_ui`) may acknowledge/approve/reject; any named module may
request and collect its own result. `HostAnchor` is refused on both — it is one
undifferentiated bag covering the shells, core_service and every relayed CLI
token, so admitting it would make a plain `logosctl call … approve` a legal
bypass of the human. Who may approve is read from a config file at context-ready
rather than a method: a `set_approver` call would be a remotely-writable answer
to "who may authorise a signature".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): the spec now describes the module that exists
docs/specs.md still documented the API whose REMOVAL is the security fix: it
described `unlock`, `timed_unlock`, `lock`, `is_unlocked`, `sign_transaction`
and `sign_message` across the method index, both mermaid diagrams, the run
examples, the unit-test list and the security model, and mentioned none of
`request_approval`, `acknowledge`, `approve`, `fetch_result` or the tier gate.
A reader following it would have written a caller that cannot compile.
Rewritten against the trait as it stands:
* the three tiers, and that A and B return an IDENTICAL refusal so a caller
cannot probe which one it failed;
* the request → acknowledge → approve → fetch lifecycle, handle vs receipt,
the at-most-one-Rendered rule, and why the 3000 ms window bounds the ACK
and never the human;
* both diagrams redrawn with three parties (requester / approver / human),
showing the password crossing ONLY the signer_ui → keystore edge;
* "In memory" now states the real invariant — a signer is a local inside
approve(), zeroized before return — and names the defect it replaced
(`unlock` passed ttl: None while eviction only fired for Some, so every
unlock was a permanent, unattributable signing oracle);
* the unit-test list replaced with the tests that actually exist, including
the whole approval.rs suite;
* `delete_account` recorded as a known uncounted password oracle, and rate
limiting named as a deliberate out-of-scope follow-up rather than omitted.
Caller identity is documented from MEASUREMENT, not from the design intent.
Against a live daemon at protocol 0.6.0, driven by a purpose-built probe
module, a genuine module→module call reports `unknown` — not just the CLI, and
not changed by --access-policy enforce. The handshake shows why: a module's
outbound client announces `requestModule for origin: "core"`, the bootstrap
anchor, because this host builds LogosAPIClient with the anchor name rather
than the module's own; authorize() then refuses to name an anchor, since
"core" and "capability_module" share one value. Naming therefore needs a host
that assigns per-plugin identity via LogosAPI::forIdentity — Basecamp and
standalone do, logoscore does not. The gate stays fail-closed, so the failure
mode is "nothing can be signed", never "anyone can sign"; the spec says so
plainly and states that the end-to-end proof must be hosted in Basecamp.
The runtime doctest drove unlock/sign_message/sign_transaction and so could
only fail. It now asserts the property that replaced them: the CLI sees
`unknown` and is refused at Tier B and Tier A alike. Its unsigned-tx fixture,
which nothing read any more, is gone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): state the measured caller-identity result, not an unconfirmed cause
module_initializer.cpp:103 constructs new LogosAPI(moduleName, ...), so the
announced origin "core" contradicts the host code. The previous wording
asserted a mechanism I had not confirmed; it now records the observation and
names the contradiction as an open upstream question.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): a correct refusal is not evidence the gate was tested
On a host where no caller can be named, Tier A/B refusing everyone shows only
that identity was ABSENT. It says nothing about whether authorization itself is
sound -- and work on logos-protocol#72 reports an old-host + old-module pairing
authorizing wrongly, which is the same token-store confusion seen from the
access side rather than the naming side.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): naming the caller is not the same as naming it correctly
Once the identity mechanism is repaired, the caller is expected to resolve as
module:"core" -- the bootstrap anchor the handshake announces -- rather than
unknown. That is populated and WRONG, which is worse than absent for one
surface in particular: acknowledge() reports the requester into the render
lines, so the human would be told a signature was requested by "core" when it
was wallet_backend_module. Tier A and result collection stay safe (approver
mismatch; receipt-based collection), so the failure is confined to Tier B --
but it means a green identity result must not be read as Tier B being usable.
Reasoned from module_proxy.cpp's authorize() and the measured handshake, not
yet observed; recorded now so a future green run is not over-read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): record the anchor-key mitigation, and why receipt-based collection bounds this
The previous note predicted module:"core" once identity is repaired. Upstream
is landing the mitigation that note argued for -- applying the credential
store's anchor-key rule to the caller-keyed store too -- so the name is
suppressed and the verdict falls through to unknown instead. Recorded so the
spec does not predict an outcome that will not occur.
The two unknowns are not the same and the spec now says so: today's means the
mechanism could not answer; the post-fix one means the mechanism answered that
this caller cannot be honestly named. Both refuse, so keystore stays
fail-closed rather than fail-misattributed.
Also makes explicit why receipt-based collection bounds the damage: authorising
fetch_result/ack_result on the receipt rather than the caller name is what kept
a misattributed identity an attribution problem instead of letting any module
collect any other's signed payloads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): the "core" origin is fixed -- it was a Rust SDK hardcode
Resolved upstream: logos-rust-sdk src/plugin.rs hardcoded CString::new("core")
as the origin of a module's OUTBOUND client. That is why it never contradicted
module_initializer.cpp:103 -- that line builds the PROVIDER-side API, while the
outbound client is constructed separately. C++ was never affected
(logos_lp_client.h passes a real origin), which is why the symptom looked
host-shaped. lidl-gen now emits LOGOS_MODULE_NAME from the contract and latches
it before the install hook; unset yields empty rather than a guess. Measured
RED->GREEN on a real fleet.
Also records the escalation this understated. "core" is a bootstrapKeys() name,
so on a pre-0.8 module saveToken("core", ...) lands in the OUTBOUND namespace
where the credential check reads -- an ordinary capability-minted pair token
became the provider's own credential. Measured: the caller reported
caller_kind=host (a module authorizing AS THE HOST) and the victim's own anchor
was destroyed, locking the host out of it. Refusing HostAnchor at Tier A AND B
was protecting against a module arriving wearing the anchor, not just against a
CLI relay -- a sharper reason than the one originally given. Tier A held
regardless, so no signature was ever reachable.
The prediction of module:"core" is now history rather than forecast, and is
kept as the record of why a populated-but-wrong identity is worse here than an
absent one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): the anchor is not an identity, so admitting it admits an unbounded set
The previous wording justified refusing HostAnchor by the historical escalation
and by the anchor being a bag shared with the shells. Both are true and both
decay: once the escalation is fixed, someone can argue the refusal is obsolete
and relax Tier B on "the host is trusted anyway".
The durable form does not depend on any defect: core and capability_module
share one token VALUE, so nothing presenting it is distinguishable from
anything else presenting it -- including a module that acquired it legitimately.
A tier admitting HostAnchor admits an unbounded set, not a trusted party.
authorize() already encodes this by refusing to name an anchor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): state what the gate's premise costs, measured
The scope decision (platform caller identity is authoritative; validating the
token holder is capability_module's job) stands. But it was taken while the
consequence was abstract, and it no longer is.
capability_module.requestModule takes the requesting identity as a plain
argument and checks only that the name IS LOADED, not that the caller IS it
(capability_module_impl.cpp:65-93, whose own comment says so). Measured on
shipped tooling: requesting with an arbitrary origin mints a token the victim
records under that name. Substitute signer_ui and any loaded module reaches
Tier A, using only public SDK surface.
What survives is the part worth documenting: it does NOT yield a signature
(approve() needs the vault password), cannot redirect a human's approval onto
another payload (single-Rendered rule + bundle_id echo -> refusal, not
misapplication), and cannot collect another requester's signatures
(receipt-based collection). It DOES yield intent disclosure via acknowledge()
and denial of service via reject()/demotion.
So the design degrades to disclosure and DoS rather than to unauthorised
signing, and it does so via choices that do not depend on identity at all. The
tier gate was never intended to be the only thing between a hostile module and
a signature; this records that it isn't.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): --access-policy enforce does not mitigate this, and say why
Two additions from an independent source review of capability_module.
First, the anti-mitigation, which matters because it is the natural first
response: the access-policy arm (capability_module_plugin.cpp:99-100) filters on
the SAME self-asserted fromModuleName, so a module asserting the name of an
ALLOWED caller passes the allowlist. A fully populated policy under
--access-policy enforce therefore protects against nothing, because the input it
filters is chosen by the requester. The TODO there covers the empty-map
fail-open; the populated case is documented nowhere, so it is easy to deploy
enforce and believe this closed.
Second, precision about what the known-caller gate does provide: existence, not
identity. A name never loaded IS refused, so an invented or reserved namespace
cannot be forged.
Also records the sequencing, which reorders apparently unrelated work:
currentCaller() is unknown on every host today, so requestModule has nothing
trustworthy to switch to. The chain is qt-host provenance -> caller identity
live fleet-wide -> requestModule stops trusting its argument, making the qt-host
defect the gate on a real authorization improvement rather than just a broken
feature.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): caller identity is LIVE -- measured, and impersonation is closed
Three upstream landings change what this module can claim, verified at the source
and measured on a logoscore daemon built from master (2026-08-26).
MEASURED. A module calling keystore is now named correctly:
{"kind":"module","identity":"caller_probe"}. Tier B admits it and returns a
handle + receipt -- the first end-to-end proof the gate admits a legitimate
caller rather than merely refusing everyone. The CLI now reports kind=host and is
still refused, which is the intended property. The per-dispatch
"No such method LogosAPI::currentCallerJson" warning is gone, logos_host_qt
carries the accessor, and the closure holds exactly one logos-qt-host.
IMPERSONATION CLOSED. capability_module.requestModule now derives the requester
from logos::currentCaller() (capability_module_impl.cpp:77-92), refuses when
there is no named caller rather than falling back to the argument, and binds the
delivered token to the derived name -- fromModuleName appears nowhere in the
binding path. Confirmed live: "ignoring leftover fromModuleName='signer_ui'
(token-bound caller is 'core')".
ANTI-MITIGATION RETRACTED. A previous revision said --access-policy enforce was
not a mitigation because the policy arm filtered the same self-asserted name. It
now filters the derived callerName, so a registered policy is enforced against a
real identity. The residual gap is the documented TODO: a target with NO
registered restriction remains unrestricted. Records why keystore does not simply
register one -- registerRestriction is per-target, not per-method, and this
module needs a wide Tier B and a narrow Tier A, so a blanket allowlist naming
only signer_ui would lock out wallet_backend_module.
Keeps the defence-in-depth result, which was tested rather than asserted: while
the hole was open the design degraded to disclosure and DoS, never to
unauthorised signing, because the password, the bundle_id echo and receipt-based
collection do not depend on caller identity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* test(keystore): the CLI is now named 'host', not 'unknown'
Caller identity is live, so the CLI no longer reads as unnameable -- it presents
a host bootstrap anchor and is named honestly as host. The refusals that follow
are unchanged and are now for the right reason: the caller IS named and is not
admitted, rather than being refused because nothing could name it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): five residuals that bound what live identity guarantees
Identity working changes what the gate can do; it does not make the name a fact.
From an independent source review of the landed changes, each item bounding a
claim this document makes:
1. The name is TOKEN-BOUND, not verified -- logos_caller_scope.h chooses that
word over 'authenticated' deliberately; it is as strong as the recording.
2. Only the QtRO path yields a caller. plain tcp/tcp_ssl and in-process paths
carry no name, and because requestModule now fails closed on an unnamed
dispatch those deployments STOP WORKING rather than degrading. This module
therefore requires QtRO to be usable at all -- a deployment constraint.
3. Same-process impersonation is not closed: isolateIdentity is idempotent and
forIdentity returns the SAME store for an already-isolated name, so native
code inside the shell can take another plugin's identity. In Basecamp every
ui_qml plugin shares one process, so Tier A is a code-authority boundary
enforced by a name -- it holds against another MODULE (separate process) and
not against hostile native code already in the shell.
4. A lying fromModuleName is warned about, not refused: no counter, no event, so
attempts are not programmatically observable.
5. registerRestriction still authenticates by a self-presented authToken rather
than currentCaller() -- a secret not a name, so a different class, but the
policy-WRITING path did not move with the policy-CHECKING path.
Also notes a quieter failure mode that is NOT the one fixed: the Q_INVOKABLE
declaration is unguarded while its body is #if-guarded on protocol >= 0.6, so
under a sub-0.6 protocol invokeMethod succeeds and returns an EMPTY string
rather than failing loudly. The check that would catch it (caller-invokable) is
exposed as a flake check but is not wired into CI.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* docs(keystore): why impersonation is closed in principle, not just in one path
Ignoring the self-asserted argument would be worth little if the naming map
could be poisoned instead. It cannot, and that is the actual closure argument:
To be named Y, attacker X must make the naming scan match X's token against key
Y. That caller-keyed map has exactly two writers -- the host, filing each
module's root token under its real name (module_manager.cpp:226), and
capability_module itself. Writing via informModuleToken is gated on the TARGET's
own credential (module_proxy.cpp:413-421), which no API hands out: requestModule
returns a fresh UUID, never the target's token. Anchor role labels are masked out
of the naming fold (module_proxy.cpp:332-337), so a caller filed under
core/capability_module resolves to Unknown and is refused rather than named.
Also records that the fail-closed behaviour is pinned by tests
(requestModule_rejects_unnamed_caller, requestModule_denies_spoofed_fromModuleName)
and that a 'fall back when mocks/old hosts omit the caller' commit was REVERTED
in favour of simulating identity in the harness -- which is precisely the
fallback that would have re-opened the hole.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(keystore): fetch_result returned 'results' while everything documented 'signed'
The end-to-end run collected a signature and printed an empty string. The
approval had worked; the requester was reading the wrong key.
fetch_result serialised { ok, results: [...] } while docs/specs.md, the method
index and the sequence diagram all promise { ok, signed: [...] }. A requester
written against the documentation gets Value::Null and no error -- the call
succeeded, so nothing looks wrong. Renamed to 'signed' to match the contract.
approve() kept its own key but it is now 'signed_count', because it is a COUNT
and not the signatures: the approver authorises a bundle and never receives
what it authorised. Only the requester can collect that, and only with the
receipt handed to it at request time -- so a compromised approver can cause a
signature to exist but cannot walk away with it. Naming both 'signed' invited
exactly the confusion that produced the empty string. The spec said
{ ok, signed: [..] } for approve too, which was wrong in a way that understated
the design; it now states the property.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(doctest): the runtime doc-test called a binary that does not exist
CI was red with 11 of 20 steps failing on exit 127 -- "./logos/bin/logosctl: No
such file or directory". Every command in the run section had been renamed from
`logoscore` to `logosctl`, and the subcommand surface with it
(load-module -> module load, module-info -> module show, stop -> daemon stop,
status -> daemon status, plus a `daemon start --detach` that has no equivalent).
`github:logos-co/logos-logoscore-cli#cli` ships one binary and it is called
`logoscore`; its subcommands are load-module / module-info / call / stop /
status, and the daemon is started with `-D -m <dir>`. The rename anticipated a
CLI this repo does not build against, so the whole run section addressed
something that was not there. Restored to the surface that exists, keeping the
new content (caller_identity, the Tier A/B refusals).
Also adds a positive control, because the failure should not have been able to
hide: `check_file: logos/bin/logosctl` reported **PASS** on a path that did not
exist, so the build section was green while every later step died. A step that
actually RUNS `logoscore --version` cannot pass on a tree where the binary is
missing. A suite of assertions that never executes the thing under test is
unfalsifiable by construction.
VERIFIED by running the whole sequence by hand against a real daemon before
pushing: load-module, module-info (contains request_approval and
caller_identity), create_mnemonic, import_private_key -> the expected Foundry
address, list_accounts, caller_identity -> "kind":"host", request_approval and
pending -> "not authorized", stop, and status -> "status":"not_running".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
52 KiB
logos-evm-keystore-module — Reference Specification
Purpose
logos-evm-keystore-module is the keystore for the Logos multi-chain EVM
wallet. It is a Rust cdylib Logos module (type: core, interface: cdylib)
that owns everything to do with private keys: generating and importing them,
encrypting them at rest as scrypt vaults, and producing secp256k1 signatures
only when a human has approved them — both EIP-191
personal_sign messages and signed EIP-1559 / legacy (EIP-155) transactions.
Its defining property is isolation: the module does no networking, and a
raw private key never crosses the module's IPC boundary. The only values that
leave the module are addresses, signed payloads (signature hex / raw
signed-transaction hex), and (re-)encrypted keystore JSON. The crypto core is
built on well-established crates — alloy
(signing + transaction encoding), eth-keystore
(Web3 Secure Storage scrypt vaults), and coins-bip39 / coins-bip32 (BIP-39 /
BIP-32 HD derivation, consumed through alloy's re-export).
Where it sits in the EVM wallet system
The EVM wallet is built from seven repositories that talk to each other as process-isolated Logos modules over a typed RPC bridge:
| Repo | Role | Talks to |
|---|---|---|
logos-evm-net-proxy |
Fail-closed HTTP/RPC client library crate (not a module) | — |
logos-evm-keystore-module |
Key management + signing (this repo) | nothing — leaf module |
logos-evm-eth-rpc-module |
Multi-chain JSON-RPC transport (concurrency: multi) |
net-proxy |
logos-evm-token-list-module |
Token-list fetch/merge per chain | net-proxy |
logos-evm-uniswap-module |
Uniswap V2/V3/V4 price oracle + swap building (concurrency: multi) |
eth-rpc |
logos-evm-wallet-backend-module |
Coordinator + tx builder (alloy) | keystore, eth-rpc, token-list, uniswap |
logos-evm-wallet-ui |
Universal C++ ui_qml app |
wallet-backend |
This module is a leaf: it declares no dependencies ("dependencies": [])
and calls no other module. It is driven by the logos-evm-wallet-backend-module
coordinator (which requests an approval as the signing leg of its
send pipeline, and never handles a vault password) or directly by the headless logosctl
runtime. Keeping the keystore a dependency-free leaf is deliberate: the component
that holds private keys has the smallest possible attack surface and pulls in no
network-capable code.
Overall architecture
The module is two layers that are deliberately decoupled by a Cargo feature:
- The crypto core (
rust-lib/src/keystore.rs) — pure, offline, Logos-free Rust. TheKeystorestruct manages a directory of scrypt vault files. It holds no decrypted keys: a signer is derived per approval and zeroized before the call returns. Unit-tested on its own withcargo test --no-default-features. - The Logos glue (
rust-lib/src/glue.rs) — theKeystoreModulecontract trait and its implementation, compiled only behind the defaultlogos_modulefeature. It marshals every structured value across the IPC boundary as a JSON string, wires the on-context-ready hook to a persistence path, and emits theaccounts_changedevent.
flowchart TB
subgraph Callers["Callers (over Logos bridge)"]
BE["wallet_backend_module<br/>(requester: request_approval)"]
SU["signer_ui<br/>(the ONLY approver)"]
LC["logosctl daemon<br/>(Tier C only)"]
end
subgraph Module["keystore_module (Rust cdylib, type: core)"]
direction TB
DISP["Generated C-ABI dispatch + install()<br/>(injected from generated/provider_gen.rs)"]
subgraph Glue["Logos glue — src/glue.rs (feature logos_module)"]
TRAIT["trait KeystoreModule<br/>(the IPC contract)"]
IMPL["KeystoreModuleImpl<br/>{ ks, approvals, approver }"]
GATE["Tier gate<br/>current_caller() → A / B / C"]
EV["KeystoreModuleEvents::accounts_changed<br/>→ emit_accounts_changed(count)"]
CTX["on_context_ready(ctx)<br/>ks = Keystore::new(ctx.instance_persistence_path/keystore)"]
end
subgraph Core["Crypto core — src/keystore.rs (no network, no Logos)"]
KS["struct Keystore { dir }<br/>(no signer cache)"]
VAULTS["scrypt vault files<br/><lowercase-hex-addr>.json"]
MEM["signer: a LOCAL inside approve()<br/>derived per approval, then zeroized"]
end
end
subgraph Deps["External crates (all offline)"]
ALLOY["alloy<br/>signers / consensus / eips / k256"]
ETHKS["eth-keystore<br/>(scrypt encrypt/decrypt)"]
BIP39["coins-bip39 / coins-bip32<br/>(via alloy re-export)"]
end
BE -->|Tier B: request_approval| DISP
SU -->|Tier A: acknowledge / approve| DISP
LC -->|Tier C only| DISP
DISP --> TRAIT
TRAIT --> GATE
GATE --> IMPL
IMPL --> KS
IMPL -.->|on account set change| EV
EV -.->|event| Callers
CTX --> KS
KS --> VAULTS
KS --> MEM
KS --> ALLOY
KS --> ETHKS
KS --> BIP39
classDef boundary fill:#1f2937,stroke:#60a5fa,color:#e5e7eb;
class Module boundary;
Key isolation, restated against the diagram: raw private-key bytes exist only
inside Core — encrypted in the vault files, and decrypted only into a local
variable inside approve(), which zeroizes it before returning. There is no
map, no cache and no TTL, so there is no window during which some other caller
could reach a live signer. Keys are never returned through TRAIT. Only
Address strings, signature/transaction hex, and re-encrypted keystore JSON
travel back across the DISP boundary — and a signature only ever leaves as the
result of a Rendered request a human approved.
Communication with dependencies
This module is a leaf — it makes no outbound calls to any other module
and opens no sockets. The interesting flow is therefore how a caller drives it.
The canonical flow has three parties, not two: a requester that may ask
but never approve, the approver (signer_ui) that renders and takes the vault
password, and the human. logosctl can reach Tier C only.
sequenceDiagram
autonumber
participant BE as wallet_backend_module (requester)
participant KS as keystore_module (this repo)
participant SU as signer_ui (the ONLY approver)
participant H as the human
participant DISK as scrypt vault dir
Note over KS: on_context_ready(ctx) → Keystore::new(...)<br/>approver read from keystore.json (default "signer_ui")
BE->>KS: request_approval({ address, purpose, legs })
Note right of KS: caller must be a NAMED module (Tier B)
KS-->>BE: { ok, handle, receipt } %% receipt returned exactly once
KS--)SU: event approval_offered(handle) %% handle only — no token, no intent
SU->>KS: acknowledge(handle)
Note right of KS: Tier A — refuses anyone but the approver.<br/>Demotes any other Rendered record.
KS-->>SU: { bundle_id, requester, render_lines }
SU->>H: render_lines VERBATIM + bundle_id
Note over H: no timeout on the human
H->>SU: vault password
SU->>KS: approve(handle, bundle_id, password)
KS->>DISK: decrypt vault (scrypt) → signer (a local)
Note right of KS: re-parse intent, re-derive commitment,<br/>compare to bundle_id, sign every leg, ZEROIZE
KS-->>SU: { ok, signed_count: n } %% a COUNT — the approver never gets the signatures
KS--)BE: event approval_settled(handle, "approved")
BE->>KS: fetch_result(handle, receipt)
KS-->>BE: { ok, signed: [...] } %% idempotent until ack_result
BE->>KS: ack_result(handle, receipt)
Note over BE: backend broadcasts raw tx via eth_rpc_module<br/>(keystore never touches the network)
The signed values the backend collects are what it hands to eth_rpc_module for
eth_sendRawTransaction. The keystore itself never performs that broadcast — it
has no network code at all. Note the password crosses only the signer_ui →
keystore edge: the requester never sees it, never sees render_lines, and
cannot produce a signature.
Full API reference
The contract is the KeystoreModule trait in rust-lib/src/glue.rs. The builder
derives the module's .lidl interface from this trait (codegen.rust = { crate: "rust-lib", trait: "KeystoreModule", source: "src/glue.rs" }); every
non-defaulted trait method becomes a callable module method. on_context_ready
is defaulted, so it is a framework hook and not part of the IPC contract.
Conventions
- Structured returns are JSON strings. Methods that return
Stringreturn a JSON object that is either{ "ok": true, … }on success or{ "ok": false, "error": "<message>" }on failure. Theerr()helper produces the error shape; the message text comes fromKeystoreError(Display). - Boolean methods (
has_address,delete_account,ack_result,cancel_approval,reject) return a barebooland are fail-soft: any internal error (bad address, wrong password, keystore not yet initialized) maps tofalserather than an error object. - Addresses are accepted with or without a
0xprefix, in any case; no EIP-55 checksum is required (parse_addressdecodes the 20 raw bytes directly). ThelogosctlCLI auto-types a0x…argument as a number, so in CLI examples addresses are passed as bare hex. - Numeric transaction fields cross as hex (
0x…) or decimal strings to avoid precision loss over JSON; empty/missing numeric fields default to0. logosctl callargument typing:true/false→ bool, integers → int, else string;@fileloads file contents as the argument.
Method index
| Method | Params | Returns | Mutates accounts? |
|---|---|---|---|
create_mnemonic |
words: i64 |
{ ok, phrase } |
no |
import_mnemonic |
params_json: String |
{ ok, address } |
yes → event |
new_account |
password: String |
{ ok, address } |
yes → event |
import_private_key |
priv_hex: String, password: String |
{ ok, address } |
yes → event |
import_keystore_json |
key_json, password, new_password: String |
{ ok, address } |
yes → event |
export_keystore_json |
address, password: String |
{ ok, keystore } |
no |
list_accounts |
— | { ok, accounts: [..] } |
no |
has_address |
address: String |
bool |
no |
delete_account |
address, password: String |
bool |
yes → event |
request_approval |
intent_json: String |
{ ok, handle, receipt, state } |
no |
approval_status |
handle, receipt: String |
{ ok, state, reason? } |
no |
fetch_result |
handle, receipt: String |
{ ok, signed: [..] } |
no |
ack_result |
handle, receipt: String |
bool |
no |
cancel_approval |
handle, receipt: String |
bool |
no |
pending |
— | { ok, pending: [..] } |
no |
acknowledge |
handle: String |
{ ok, bundle_id, requester, render_lines } |
no |
approve |
handle, bundle_id, password: String |
{ ok, signed_count: n } |
no |
reject |
handle: String |
bool |
no |
caller_identity |
— | { ok, kind, identity, approver } |
no |
create_mnemonic(words: i64) -> String
Generate a fresh BIP-39 English mnemonic. Does not persist anything — the
caller decides whether to import the phrase. The phrase is produced from
rand::thread_rng() and returned in cleartext (it is not a private key per se,
but it is seed material — treat it as a secret).
words— word count. Must be one of12 | 15 | 18 | 21 | 24; any other value returns an error.
Success: { "ok": true, "phrase": "word1 word2 … wordN" }
Error: { "ok": false, "error": "word count must be 12/15/18/21/24, got 13" }
logosctl call keystore_module create_mnemonic 12
# → {"ok":true,"phrase":"… twelve words …"}
import_mnemonic(params_json: String) -> String
Derive a single account from a mnemonic along the BIP-44 Ethereum path
m/44'/60'/0'/0/<accountIndex> and persist its scrypt vault. The argument is a
JSON object (ImportMnemonicParams):
| Field | Type | Required | Meaning |
|---|---|---|---|
phrase |
string | yes | The BIP-39 mnemonic |
passphrase |
string | no (default "") |
Optional BIP-39 passphrase (the "25th word"); applied only if non-empty |
accountIndex (alias account_index) |
u32 | no (default 0) |
HD index to derive |
password |
string | yes | Vault password used to scrypt-encrypt the derived key on disk |
Both accountIndex and account_index are accepted (serde alias). On success
emits accounts_changed.
Success: { "ok": true, "address": "0x…" }
Error: { "ok": false, "error": "<parse / derivation error>" }
logosctl call keystore_module import_mnemonic \
@mnemonic.json # {"phrase":"test test … junk","accountIndex":0,"password":"pw"}
# → {"ok":true,"address":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"}
new_account(password: String) -> String
Create a brand-new random account (PrivateKeySigner::random()) and persist its
scrypt vault under password. Emits accounts_changed.
password— vault password.
Success: { "ok": true, "address": "0x…" }
Error: { "ok": false, "error": "vault error: …" } (e.g. I/O failure)
logosctl call keystore_module new_account hunter2
import_private_key(priv_hex: String, password: String) -> String
Import a raw secp256k1 private key and persist its scrypt vault. The key is
consumed and immediately re-encrypted — it is not retained in cleartext beyond the
call, and it is never returned. Emits accounts_changed.
priv_hex— 32-byte private key in hex, with or without a0xprefix.password— vault password.
Success: { "ok": true, "address": "0x…" }
Error: { "ok": false, "error": "invalid private key: …" }
logosctl call keystore_module import_private_key \
ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 pw
# → {"ok":true,"address":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"}
import_keystore_json(key_json: String, password: String, new_password: String) -> String
Import an existing scrypt keystore JSON (Web3 Secure Storage format),
decrypting it with password and re-encrypting it under new_password into a
fresh local vault. The decrypted key never leaves the module. Emits
accounts_changed.
key_json— the source keystore JSON (a string; written to a short-lived temp file becauseeth-keystoreis path-based, then decrypted).password— password that decrypts the incoming keystore JSON.new_password— password used to re-encrypt the vault stored locally.
Success: { "ok": true, "address": "0x…" }
Error: { "ok": false, "error": "vault error: …" } (wrong password, malformed JSON, etc.)
export_keystore_json(address: String, password: String) -> String
Export an account as scrypt keystore JSON without touching the on-disk vault. The password is verified (the vault is decrypted to prove the password is correct) and then the canonical on-disk JSON contents are returned. The exported JSON is itself scrypt-encrypted — it is not a cleartext key.
address— account to export (with/without0x, any case).password— the vault password (must decrypt, else error).
Success: { "ok": true, "keystore": "<keystore JSON string>" }
Error: { "ok": false, "error": "vault error: …" } (wrong password or missing vault)
list_accounts() -> String
List the addresses of all persisted vaults. Vaults are discovered by reading the
keystore directory and parsing each <addr>.json filename back into an address;
the list is sorted.
Success: { "ok": true, "accounts": ["0x…", "0x…"] } (empty array if none)
Error: { "ok": false, "error": "keystore not initialized (context not ready)" }
logosctl call keystore_module list_accounts
# → {"ok":true,"accounts":["0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"]}
has_address(address: String) -> bool
Return true iff a vault file exists for address. Fail-soft: an invalid address
or uninitialized keystore returns false.
address— address to check.
logosctl call keystore_module has_address f39fd6e51aad88f6f4ce6ab8827279cfffb92266
# → true
delete_account(address: String, password: String) -> bool
Permanently delete an account's vault. Password-gated: the vault is decrypted
with password first; only on success is the file removed. Emits accounts_changed. Returns true only when a vault existed, the
password was correct, and the file was removed; false otherwise (missing vault,
wrong password, parse error).
address— account to delete.password— its vault password (required to authorize deletion).
logosctl call keystore_module delete_account <address> pw
# → true
Human-approved signing
There is no way to make this module sign anything except by a human approving
it. The methods that used to sign on demand — unlock, timed_unlock, lock,
is_unlocked, sign_transaction, sign_message, sign_digest — are deleted
from the contract, not merely gated. There is no unlocked-signer cache: the
signing key is derived from the vault password inside approve(), used, and
zeroized before the call returns.
The three tiers
Every request is classified by the caller identity the platform reports
(logos_rust_sdk::current_caller()).
| Tier | Methods | Admits |
|---|---|---|
| A | pending, acknowledge, approve, reject |
the configured approver only (default signer_ui) |
| B | request_approval, approval_status, fetch_result, ack_result, cancel_approval |
any named module; fetch/ack/cancel/status additionally require the receipt |
| C | account management (create_mnemonic … delete_account) |
ungated / password-gated |
Tiers A and B both return the identical string {"ok":false,"error":"not authorized"}
on refusal, so a caller cannot use the error text to probe which tier it failed.
Caller identity — live, and what it reports
Caller identity works. Measured 2026-08-26 against a logoscore daemon built from
master, with keystore_module and a purpose-built caller_probe module:
| call | caller_identity() reports |
|---|---|
logosctl → keystore |
{"kind":"host","identity":""} |
logosctl → probe (current_caller() directly) |
HostAnchor |
| probe module → keystore (real module plane) | {"kind":"module","identity":"caller_probe"} |
probe → keystore request_approval (Tier B) |
{"ok":true,"handle":"ksh_…","receipt":"ksc_…"} |
Row 3 is the positive path: a named module is named correctly. Row 4 is the first end-to-end proof that the tier gate admits a legitimate caller rather than merely refusing everyone.
The host now carries the accessor the plugin pulls (currentCallerJson present in
logos_host_qt, and exactly one logos-qt-host in the closure), and the
No such method LogosAPI::currentCallerJson warning that used to fire on every
dispatch is gone. Three defects had to be fixed for this, in this order — the
sequencing is worth keeping because it looked like unrelated work:
- qt-host provenance.
logos-qt-sdkexported thelogos-qt-hostit was itself built against — by propagation, by a baked absolute-store-pathHINTS, and by forwarding headers containing literal#include "/nix/store/…". So the module host linked a staleLogosAPIwith noQ_INVOKABLE currentCallerJson, the plugin's cross-imageinvokeMethodfailed, and the pushed caller document was empty. - The announced origin.
logos-rust-sdkhardcoded"core"as the origin of a module's outbound client.lidl-gennow emitsLOGOS_MODULE_NAMEfrom the contract and latches it before the install hook. (C++ was never affected.) - Naming the anchor.
authorize()enforced "an anchor key is never a module name" on the credential store but not on the caller-keyed one, so"core"arriving there would have been spelled as a module.
HostAnchor is refused at Tier A and Tier B, and that is deliberate. The CLI now
reports honestly as the host rather than as unknown, and is still refused — which is
the intended property, not a gap. The rule does not depend on any past defect:
"core" and "capability_module" hold one token value under two keys, so nothing
presenting it is distinguishable from anything else presenting it. A tier admitting
HostAnchor admits an unbounded set, not a trusted party. It must not be relaxed on
the reasoning that "the host is trusted anyway".
Impersonation of the approver: closed upstream
An earlier revision of this document recorded a live exposure: any loaded module could
reach Tier A by naming signer_ui, because capability_module.requestModule took the
requesting identity as a plain argument and checked only that the name was loaded.
That is fixed (capability_module_impl.cpp:77-92):
const logos::LogosCaller caller = logos::currentCaller();
std::string callerName;
if (caller.isHost()) callerName = "core";
else if (caller.isModule() && !caller.name.empty()) callerName = caller.name;
else { /* unnamed / unknown / derived / operator -> REFUSE */ return {}; }
if (!fromModuleName.empty() && fromModuleName != callerName) {
warn("ignoring leftover fromModuleName='%s' (token-bound caller is '%s')", …);
}
Three properties matter here, and all three hold:
- The identity comes from the platform's caller document, not the argument. The argument is dead ABI — it survives only to be warned about.
- It fails closed when there is no named caller. A fallback to the argument on an unnameable dispatch would have re-opened the hole entirely; there is none.
- The binding uses the derived name: the token is delivered as
informModuleTokenTo(…, /*moduleName=*/callerName, …).fromModuleNameappears nowhere in the binding path.
Why it is closed in principle, not merely in this code path. Ignoring the argument
would be worth little if an attacker could instead poison the map the name is recovered
from. It cannot. To be named Y, attacker X would have to make the naming scan match
X's presented token against key Y — and that caller-keyed map has exactly two
writers: the host, which files each module's root token under its real name
(module_manager.cpp:226, whose comment states the purpose: "capability stores
(name, token) so authorize can name the caller from the presented token rather than from
a self-asserted fromModuleName"), and capability_module itself. Writing to it via
informModuleToken is gated on the target's own credential
(module_proxy.cpp:413-421), which no API hands out — requestModule returns a fresh
UUID, never the target's token. And the anchor role labels are masked out of the naming
fold (module_proxy.cpp:332-337, out.fold.offer(match & ~isAnchor, …)), so a caller
filed under core/capability_module resolves to Unknown and is refused rather than
being spelled as a module.
The fail-closed behaviour is pinned by tests, and was contested during development:
requestModule_rejects_unnamed_caller asserts an empty result even when
fromModuleName names a seeded, loaded module, and
requestModule_denies_spoofed_fromModuleName covers the spoof directly. A commit that
added "fall back when mocks/old hosts omit the caller" was reverted in favour of
simulating identity in the test harness — which is the right call, and exactly the
fallback that would have re-opened this.
Confirmed live — the mechanism firing, verbatim from the daemon log:
[capability_module] ignoring leftover fromModuleName='signer_ui' (token-bound caller is 'core')
That is a logosctl request to be minted as signer_ui, overridden to the CLI's real
token-bound identity. Measured in two composing halves: a module's currentCaller() is
its own name (row 3 above), and requestModule binds to currentCaller(), not to its
argument (source, all paths). A forged argument therefore cannot change the binding.
The access policy is now a real control — with one residual gap
A previous revision of this document said --access-policy enforce was not a
mitigation, because the policy arm filtered the same self-asserted name. That is no
longer true and the correction matters: the allowlist is now consulted against the
derived callerName:
auto it = m_restrictions.find(moduleName);
if (it != m_restrictions.end() && it->second.count(callerName) == 0) { /* deny */ }
The residual gap is documented in the code and is a rollout decision, not an oversight:
TODO(access-policy): still fail-OPEN — a target with no registered restriction is unrestricted. Intentional for back-compat during rollout.
So a policy that is registered is now enforced against a real identity; a target with no policy remains reachable by any named module.
Why keystore does not simply register one. registerRestriction is per-target,
not per-method. This module deliberately needs a wide Tier B (any named module may
request) and a narrow Tier A (exactly one may approve). A blanket allowlist
naming only signer_ui would lock out every legitimate requester —
wallet_backend_module among them. So the tier gate inside this module stays the
mechanism that separates asking from approving, and the access policy is a coarse
complement for deployments that want to bound the requester set. Operators who want
both should register the requester set as the restriction and leave the approver
distinction to the tier gate.
What identity still does not guarantee
Identity being live changes what the gate can do; it does not make the name a cryptographic fact. Five residuals, each of which bounds a claim this document makes.
1. The name is token-bound, not verified. logos_caller_scope.h says so in its
own words — token-bound is "the strongest honest word … chosen over 'verified' or
'authenticated' deliberately", because the name is the key under which this module
recorded the token the caller presented. It is exactly as strong as that recording. It
is not a signature, and nothing here should be read as authentication.
2. Only the QtRO path yields a caller at all. Identity is resolved from the
meta-object dispatch. plain tcp / tcp_ssl operator tokens carry no name (the validator
returns a bool), and in-process paths do not go through the proxy. Because
requestModule now fails closed on an unnamed dispatch, a deployment on those
transports does not degrade to a weaker check — it stops working. That is the right
direction for a signer, but it is a deployment constraint, not a detail: this module
requires the QtRO transport to be usable at all.
3. Same-process impersonation is not closed, and this is the one that matters here.
TokenManager::isolateIdentity is idempotent and LogosAPI::forIdentity hands back the
same store for an already-isolated name, so native code running inside the host
process can obtain another plugin's identity. In Basecamp every ui_qml plugin — this
module's approver among them — lives in one process. So the Tier A boundary is a
code-authority boundary enforced by a name, not a process boundary: it holds against
another module (which is a separate process), and it does not hold against hostile
native code already inside the shell. An attacker who has that has already won more
than this gate was defending.
4. A lying fromModuleName is warned about, not refused. capability_module logs
"ignoring leftover fromModuleName=…" and proceeds. The binding is unaffected — that is
what matters — but there is no counter and no event, so an operator cannot observe
attempts programmatically. Worth a metric upstream.
5. registerRestriction was not converted. It still authenticates by a
self-presented authToken compared against the trust-root tokens rather than by
currentCaller(). That is a secret, not a name, so it is a different class from the
hole that was closed — but it means the policy-writing path did not move with the
policy-checking path.
One quieter failure mode is worth knowing because it is not the one that was fixed:
the Q_INVOKABLE currentCallerJson declaration is unguarded while its body is
#if-guarded on protocol ≥ 0.6. Under a sub-0.6 protocol the old symptom would not
reappear as No such method — invokeMethod would succeed and return an empty
string, which collapses to unknown. The loud failure is the one that has been fixed;
a silent one remains reachable on an old protocol. And the check that would catch it
(caller-invokable, the only one that actually calls invokeMethod by name against a
real LogosAPI) is exposed as a flake check but is not wired into CI, so the
property is not continuously verified.
What the gate does and does not guarantee
With identity live and impersonation closed, Tier A means what it is meant to mean: the caller is the configured approver package. Two limits remain worth stating plainly.
module:signer_ui names a plugin package, not a human. A ui_qml plugin's QML view
and its ui-host backend are one identity by design, and this module cannot distinguish
them — it must not pretend to. What the entry asserts is that the operator designated
this package as the code permitted to approve. It does not assert that a human saw
anything, and no token check can make it. (This design routes all keystore calls
through the backend, so it is the backend's identity that is checked.)
Defence in depth is not resting on identity alone, and this was tested rather than asserted. While the impersonation hole was open, the design degraded to intent disclosure and denial of service — never to unauthorised signing — because the properties that stop signing do not depend on who the caller is:
approve()requires the vault password, and no signer is cached; impersonating the approver does not produce one.- The single-
Renderedrule plus the echoedbundle_idmean demoting the human's render makes theirapprove()fail, not misapply. fetch_result/ack_resultauthorise on the per-request receipt, so requesters cannot collect each other's signatures even if they share a name.
Those choices are why a premise failure cost confidentiality and availability rather than integrity, and they should survive any future change to how identity is delivered.
Lifecycle
request_approval ──▶ Offered ──acknowledge──▶ Rendered ──approve──▶ Settled(Approved)
│ │ │ └────▶ Settled(Rejected)
│ └──(no ack in 3000 ms)──▶ Settled(ExpiredNoAck)
└──cancel_approval─────────────────────────────────────────▶ Settled(Cancelled)
handlevsreceipt.request_approvalreturns both, and the receipt is returned exactly once. The handle is what the event plane announces (it carries no token, so anything richer would publish the intent to every subscriber); the receipt is what authorises collecting the result. A module that learns a handle from the event plane still cannot fetch someone else's signatures.- At most one record is
Rendered.acknowledgedemotes any other rendered request, so exactly one thing can be on screen — this is what binds the text the human read to theapprovecall that follows. - The 3000 ms window is on the event path only. It bounds how long the
approver has to acknowledge receipt, not how long the human has to decide. Once
Rendered, there is no timeout on the human. - Settled records are retained for 120 s so a requester polling
approval_statuslearns why a request ended (expired_no_ack,rejected,cancelled) rather than gettingnot_found. - Results are idempotent until
ack_result, so a dropped reply does not cost the human a second password entry.
request_approval(intent_json: String) -> String
Tier B. Returns immediately — it does not block on the human.
The intent is { address, purpose, legs: [...] }, where each leg is one of:
{ "kind": "tx", "chain_id": 1, "tx": { …UnsignedTx… } }
{ "kind": "message", "text": "…" }
{ "kind": "digest", "digest": "0x…32 bytes…", "purpose": "…" }
A bundle of several legs is one human decision: all legs are signed, or
none are. purpose is requester-supplied and is always rendered as claimed by the
requester, never as fact.
# → {"ok":true,"handle":"ksh_…","receipt":"ksc_…","state":"offered"}
Limits: at most 4 pending per requester, 16 total, 64 KiB of intent.
approval_status(handle: String, receipt: String) -> String
Tier B. Bare state for the requester — never the intent, never the results.
{ ok, state, reason? }.
fetch_result(handle: String, receipt: String) -> String
Tier B. Collect the signatures: { ok, signed: [ … ] }, one entry per leg in
request order. Idempotent until ack_result.
ack_result(handle: String, receipt: String) -> bool
Tier B. The requester has the signatures; erase them.
cancel_approval(handle: String, receipt: String) -> bool
Tier B. The requester gave up.
pending() -> String
Tier A. Queue summaries — never leg detail. { ok, pending: [...] }.
acknowledge(handle: String) -> String
Tier A. Claim a request for display:
{ ok, handle, bundle_id, requester, render_lines }.
render_lines are authored by this module from the parsed intent and must be
displayed verbatim — not reformatted, elided, truncated or re-ordered. keystore is
the only party that parsed the intent and therefore the only one that can tell
requester-supplied text from its own; it escapes control characters, bidi controls
and zero-width characters before they enter a line.
The full calldata is always shown in full and never elided. A digest leg
renders an explicit admission that the signer cannot show what it authorises.
approve(handle: String, bundle_id: String, password: String) -> String
Tier A. The human said yes. bundle_id must be the value that was displayed;
a mismatch is refused. The intent is re-parsed and the commitment re-derived
inside this call before anything is signed, so what is signed is what was
committed to.
One key derivation, every leg signed, then wiped → { ok, signed_count: n }.
The approver never receives what it authorised. approve() answers with a
count, not the signatures. Only the requester can collect those, and only
with the receipt it was handed at request time — so a compromised approver can
cause a signature to exist but cannot walk away with it. That is also why this
field is named differently from fetch_result's signed array: the two must
never be mistaken for one another.
At most once per handle: a second approve for a handle that has left
Rendered returns the recorded outcome and never re-signs. Because a scrypt
derivation runs inside the call, callers must use an async entry point with a
timeout comfortably above worst-case KDF — a dispatched call that times out still
executes here.
The bundle_id is SHA-256 over this module's own canonical re-encoding of the
parsed intent — deliberately not keccak256, so a bundle id can never be
mistaken for a signable Ethereum digest.
reject(handle: String) -> bool
Tier A. The human said no.
caller_identity() -> String
Ungated observability: { ok, kind, identity, approver } where kind is one of
unknown | host | module | derived | operator.
Events
The module declares a typed event contract:
pub trait KeystoreModuleEvents {
fn accounts_changed(&self, count: i64);
}
accounts_changed(count) is emitted (via the generated emit_accounts_changed)
whenever the set of persisted accounts changes — after a successful
import_mnemonic, new_account, import_private_key, import_keystore_json, or
delete_account. count is the new total number of vaults
(Keystore::list_accounts().len()). Subscribers (e.g. the wallet UI/backend) can
use it to refresh their account list without polling. All event params are
std-typed (i64).
Configuration & data model
Module manifest (metadata.json)
| Field | Value | Notes |
|---|---|---|
name |
keystore_module |
Module id used by logosctl/lgpm |
version |
1.0.0 |
|
type |
core |
Core (non-UI) module |
interface |
cdylib |
Rust-first cdylib module |
category |
wallet |
|
main |
keystore_module_plugin |
Plugin entry name |
dependencies |
[] |
Leaf — no module deps |
capabilities |
[] |
Declares no capability requirements |
codegen.rust |
{ crate: "rust-lib", trait: "KeystoreModule", source: "src/glue.rs" } |
The contract source for the builder's code generator |
nix |
empty external_libraries / packages / cmake |
No external system libs — the crypto is pure-Rust crates |
There is no concurrency field, so the keystore runs in the default
(single-handler) dispatch mode — calls are processed one at a time. (Contrast
with eth_rpc_module / uniswap_module, which set concurrency: "multi".) See
Concurrency.
Persistence location & on-context-ready
On on_context_ready(ctx) the glue computes the keystore directory as
ctx.instance_persistence_path / "keystore" and constructs Keystore::new(dir).
Until that hook runs, self.ks is None and String-returning methods report
keystore not initialized (context not ready) while bool methods return false.
The instance_persistence_path is supplied by the Logos runtime
(RustModuleContext), so each module instance gets its own isolated vault
directory.
On-disk vault files
- One file per account, named
<lowercase-hex-address>.json(no0xprefix), e.g.f39fd6e51aad88f6f4ce6ab8827279cfffb92266.json. - Each file is a scrypt-encrypted keystore JSON (Web3 Secure Storage format)
produced by
eth-keystore::encrypt_key. The 32-byte secp256k1 private key is the encrypted payload; the password is the scrypt secret. list_accountsworks purely by listing the directory and parsing filenames; no vault is decrypted to enumerate accounts.
In-memory state
pub struct Keystore {
dir: PathBuf, // that is the whole of it — no signer cache
}
There is no unlocked-signer cache, by construction. A decrypted key exists only
as a local inside approve(): derived from the vault password, used to sign every
leg of the bundle, then zeroized (Zeroizing wraps the password, the derived key
and the signer's key bytes) before the call returns. Nothing outside that call
frame can reach a key, so there is no TTL to get wrong and no "unlocked forever"
state to leak — the defect this design replaced was exactly an unlock that passed
ttl: None and therefore never expired.
The only cross-call state is the approval ledger (approval.rs), which holds
intents, render text and — briefly — signed outputs, never keys.
Unsigned-transaction JSON (UnsignedTx)
The tx object of a tx leg (and the transaction the human is shown)
deserializes into:
| Field | Type | Default | Meaning |
|---|---|---|---|
to |
string? | null/empty → contract Create |
recipient address; empty/absent ⇒ contract creation (TxKind::Create) |
value |
string | "0" |
wei value (hex 0x… or decimal), parsed as U256 |
nonce |
string | (required field) | account nonce (hex/decimal u64) |
gas_limit |
string | "0" |
gas limit (u64) |
data |
string | "" |
calldata hex (0x…), parsed to Bytes |
fee_mode |
string | "" → EIP-1559 |
"eip1559" (default) or "legacy" (case-insensitive) |
max_fee_per_gas |
string | "0" |
EIP-1559 only (u128) |
max_priority_fee_per_gas |
string | "0" |
EIP-1559 only (u128) |
gas_price |
string | "0" |
legacy only (u128) |
Numeric parsing accepts 0x/0X hex or plain decimal; empty strings parse to 0
/ U256::ZERO.
Example (EIP-1559, the one used by the doc-test's tx.json):
{
"to": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"value": "0xde0b6b3a7640000",
"nonce": "0x0",
"gas_limit": "0x5208",
"max_fee_per_gas": "0x77359400",
"max_priority_fee_per_gas": "0x3b9aca00",
"fee_mode": "eip1559"
}
Build, run & test
Build
# Crypto core only — no Logos runtime, no generated scaffold needed.
cd rust-lib && cargo test --no-default-features
# Full module (Qt plugin) + installable .lgx package, via nix.
nix build .#install # → result/modules/keystore_module/
nix build .#lgx # → result/*.lgx
The flake.nix is ~15 lines: it delegates to
logos-module-builder.lib.mkLogosModule { src; configFile = ./metadata.json; … },
which reads metadata.json, runs the Rust code generator on the KeystoreModule
trait, injects the generated scaffold at the include!(.../generated/provider_gen.rs)
point in glue.rs, and compiles the staticlib into the Qt plugin. CMakeLists.txt
is the thin bridge (logos_module(NAME keystore_module) via LogosModule.cmake).
The crate is crate-type = ["staticlib", "rlib"] with resolver = "3" and
rust-version = "1.89" so the MSRV-aware resolver picks dependency versions that
compile under the builder's rustc 1.89. The Logos SDK
(logos-rust-sdk) is an optional dependency enabled by the default
logos_module feature, so cargo test --no-default-features builds the crypto
core in isolation without the SDK or generated scaffold.
Run / drive via logosctl
The module is loaded into a headless logosctl daemon and called over IPC.
End-to-end, as the doc-test does it:
# 1. Build logosctl + lgpm from their flakes
nix build 'github:logos-co/logos-logoscore-cli#cli' --out-link ./logos
nix build 'github:logos-co/logos-package-manager#cli' -o lgpm
# 2. Build this module's .lgx, seed the capability module, install
nix build '.#lgx' -o keystore-lgx
mkdir -p modules && cp -RL ./logos/modules/. ./modules/ # bundled capability_module
./lgpm/bin/lgpm --modules-dir ./modules --allow-unsigned install --file keystore-lgx/*.lgx
# 3. Start the daemon, load, and drive
logosctl --config-dir . daemon start --detach
sleep 3
logosctl module load keystore_module
logosctl module show keystore_module # note: no unlock/sign_* methods exist
logosctl call keystore_module create_mnemonic 12
logosctl call keystore_module import_private_key <privkey> pw # → {address}
logosctl call keystore_module list_accounts
logosctl call keystore_module caller_identity # → {"kind":"host", ...}
# Tier A and Tier B are UNREACHABLE from the CLI, by design:
logosctl call keystore_module pending # → {"ok":false,"error":"not authorized"}
logosctl daemon stop
The bundled capability_module (shipped with logosctl) handles the load-time
auth handshake, which is why it is seeded into ./modules before installing this
module.
How the doc-test exercises it
doctests/keystore-module-runtime.test.yaml (rendered to
doctests/outputs/keystore-module-runtime.md, run by doctests/run.sh and CI in
.github/workflows/doctests.yml on ubuntu-latest + macos-latest) runs the
flow above end-to-end. It is fully offline and deterministic: it uses
Foundry's canonical test mnemonic / account 0
(0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266, private key
ac0974…ff80) and asserts:
create_mnemonic 12output containsphrase;import_private_keyreturns the expected address (f39Fd6e51aad…), proving the key stayed inside while only the address came out;list_accountsthen contains that address;caller_identityreports"kind":"host"— the CLI is the host anchor, named honestly;- every Tier A / Tier B method refuses the CLI with the identical
{"ok":false,"error":"not authorized"}— asserted rather than assumed, and now for the right reason: the caller is named and is not admitted, rather than unnameable.
Signing cannot be exercised from logosctl — that is the point, and it is the
property to guard. The Tier B half of the positive path is now reachable
headlessly, since a named module calling request_approval is admitted and gets a
handle and receipt (measured). The Tier A half still needs a real approver
plugin, so the full request → acknowledge → approve → broadcast proof remains a
Basecamp doctest — not because logoscore cannot name callers (it can), but
because approve() requires a human at a rendered surface, and ui-host is a
QCoreApplication that cannot face one.
The CI workflow resolves the commit under test and passes
--release-for logos-evm-keystore-module=<sha> so the spec's {release}
placeholder builds the PR's own code, then publishes a two-column HTML report to
GitHub Pages.
Unit tests (crypto core)
rust-lib/src/keystore.rs carries #[cfg(test)] tests runnable with
cargo test --no-default-features, covering:
hd_derivation_matches_known_vector— BIP-44 derivation of accounts 0/1 from the Foundry mnemonic matches known addresses.private_key_import_matches_address— imported PK yields the expected address.create_mnemonic_lengths— 12/24 words succeed, 13 fails.vault_roundtrip_and_listing— import →has_address/list_accounts.there_is_no_unlocked_state_to_reuse— the structural guarantee: nothing survives a signing call that a later caller could reuse.sign_message_recovers_signer— EIP-191 signature recovers to the signer.a_wrong_password_cannot_sign— the password is the only key to the vault.nonce_that_overflows_u64_is_rejected_not_truncated,unknown_tx_fields_are_rejected,absent_to_is_refused_rather_than_deploying_a_contract,an_access_list_is_refused_rather_than_silently_dropped,fee_mode_is_a_closed_set_and_is_trimmed— the parser refuses what it cannot faithfully render, instead of silently dropping or truncating it.sign_message_refuses_text_that_renders_differently_than_it_signs— no bidi, control or zero-width characters may enter a rendered line.hostile_kdf_params_are_rejected_before_any_derivation,the_vault_directory_and_files_are_not_group_or_world_readable,importing_a_vault_leaves_no_temp_copy_behind.
Approval state machine (rust-lib/src/approval.rs, pure and offline):
approve_refuses_a_handle_that_is_not_the_one_being_rendered— the at-most-one-Renderedrule, which is what binds what the human read to what gets signed.approve_requires_the_bundle_id_that_was_displayed.the_commitment_covers_the_parsed_value_not_the_requesters_bytes— a requester cannot get one thing rendered and another signed.the_receipt_not_the_handle_is_what_authorises_collection— knowing a handle from the event plane does not let you collect someone else's signatures.a_wrong_password_neither_signs_nor_settles_the_record.results_are_idempotent_until_acked_then_erased.a_bundle_is_one_decision_over_several_legs.an_unacknowledged_request_expires_and_says_why— a requester learns the reason, rather than seeing the record vanish.the_render_shows_full_calldata_and_flags_contract_creation,an_opaque_digest_is_rendered_as_opaque.a_requester_cannot_flood_the_queue,an_oversize_or_empty_intent_is_refused_before_parsing.sign_eip1559_recovers_signer/sign_legacy_recovers_signer— decode the raw signed tx (EIP-2718) and recover the original signer; the EIP-1559 envelope starts with0x02.
Security model & invariants
This module is the wallet's secret-holding boundary. Its security properties:
-
Private keys never cross the IPC boundary. No method takes or returns a raw private key out of the module. The only inbound key material is
import_private_key'spriv_hexandimport_keystore_json's decrypt path; both are immediately re-encrypted into a vault and never echoed back. Outbound values are limited to addresses, signature/transaction hex, and re-encrypted keystore JSON. This is asserted by the doc-test (import returns only the address) and by thekeystore.rsdoc comments. -
No network. The crate pulls in narrow alloy features only (
consensus,eips,rlp,signers,signer-local,signer-mnemonic,k256) — noprovider/rpc/network. There is no HTTP client, no socket, no net-proxy. The module cannot exfiltrate a key over the wire because it has no wire. (Network access in the wallet is concentrated ineth_rpc/token_list, which are fail-closed throughlogos-evm-net-proxy; the keystore deliberately stays out of that path.) -
At rest: scrypt vaults. Keys live on disk only as
eth-keystorescrypt-encrypted JSON, one file per account, in the module's isolatedinstance_persistence_path. -
In memory: bounded by one call. A decrypted key exists in RAM only as a local inside
approve()— derived from the vault password, used for every leg of the bundle, then zeroized before the call returns. There is no signer cache, so there is no TTL to misconfigure and no "unlocked" state another caller could ride. The defect this replaced was precisely anunlockthat passedttl: Noneunconditionally while eviction only ever fired forSome, making every unlock a permanent, unattributable signing oracle. -
A signature requires a human. No method signs outside
approve(), andapprove()is reachable only by the configured approver.render == signis enforced rather than assumed: the intent is re-parsed and its commitment re-derived insideapprove()and compared against thebundle_idthat was displayed, so a requester cannot get one thing rendered and another signed. Thebundle_idis SHA-256 over the module's own canonical re-encoding — deliberately not keccak256, so it can never be mistaken for a signable Ethereum digest. -
Password-gated destructive ops.
delete_accountandexport_keystore_jsonboth require the correct vault password (they decrypt to verify) before acting, so neither can be abused by a caller that doesn't already hold the password. Known gap, tracked:delete_accountis an uncounted password oracle that destroys the vault on a correct guess. Rate limiting on vault-decrypting methods is a named follow-up, deliberately out of scope for this landing. -
Replay protection. Every signed transaction binds the
chain_id(EIP-155 for legacy, thechainIdfield for EIP-1559), so a signed tx cannot be replayed on another chain. The chain is also shown to the human on its own render line. -
The gate fails closed, and that was load-bearing while it had to be. Where a caller cannot be named, Tiers A and B refuse everyone — the failure mode is "nothing can be signed", never "anyone can sign". Identity is live now, so the gate admits legitimate callers rather than refusing all of them; the fail-closed direction stays because it is what makes a future regression in the identity path an availability problem instead of a signing one.
Concurrency
The keystore declares no concurrency field in metadata.json, so it runs in
the framework's default single-handler dispatch: the runtime processes one
call at a time. This is appropriate here because the operations mutate shared
state (the approval ledger, vault files) and are fast (local scrypt + signing, no
network latency), so there is no benefit to concurrent dispatch and serial
execution avoids data races on the ledger without extra locking.
Serial dispatch is also what lets the lease be a lazy sweep rather than a
reaper thread: a stale Rendered record is demoted on the next Tier A/Tier B
call — precisely the call it would otherwise block. That is why
concurrency: "multi" is not needed here, despite approve() running a
deliberately slow scrypt derivation inside the call.
This contrasts with the wallet's concurrency: "multi" modules
(eth_rpc_module, uniswap_module), which fan out network-bound RPC calls
concurrently and resolve them via a pending-sentinel. The keystore has no
network-bound work to overlap, so it stays single — the simplest model for the
component that must be the most careful with shared secret state.