feat(wallet-ffi): expose stable identity lookup primitives - #509
Conversation
Adds two FFI entries (and the matching WalletCore methods) that let
clients resolve a stable cryptographic identity from a persisted
wallet without rotating it at every restart:
- wallet_ffi_get_first_private_accounts_key(handle, out_keys)
Reads the first private accounts key in the wallet — useful when
the client wants any deterministic identity tied to the wallet
seed without caring about chain position.
- wallet_ffi_get_private_accounts_key_by_chain_index(
handle, chain_index_str, out_keys)
Reads the private accounts key at an explicit chain index given
as the wallet-CLI string format ("/", "/0", "/0/1", ...). Use
case: applications that anchor identity on a known position in
the key tree (e.g. agent runtimes that always use "/" for the
seed-root node so the same NPK survives every reopen).
Background: today's wallet-ffi only exposes
wallet_ffi_create_private_accounts_key (always creates a new chain
node) and wallet_ffi_get_private_account_keys(account_id) which
returns sub-account material. Neither lets a client recover the
same NPK across restarts without a sidecar cache, which is fragile
and couples the application to file-layout assumptions outside
the wallet. The new getters use the existing
UserKeyChain::private_account_key_chain_by_index and the
private_account_key_chains iterator (BTreeMap-ordered, so
deterministic across reopens) — purely additive, no behaviour
change for existing callers.
c_str_to_string visibility bumped to pub(crate) so the new
functions can reuse the existing helper.
Header regenerated via cbindgen as part of the wallet-ffi build.
Motivation: I am building an LP-0008 (autonomous AI module)
submission. Agent identity is the agent's NPK; without this, the
agent's identity rotates every time Basecamp restarts the module,
breaking AgentCards, owner-channel routing, and demos. The
upstream patch lets the agent module drop its sidecar workaround
and rely on a deterministic FFI lookup.
… Schnorr)
Adds a signing primitive to the wallet-ffi surface so downstream
applications can sign application-level material (e.g. an A2A
AgentCard, a JWS, a delegation token) with the same key tree the
wallet uses, without needing to surface the private scalar to the
host.
Signature scheme: BIP-340 Schnorr-over-secp256k1, message is SHA-256
prehashed before signing (matching the existing nssa::Signature::new
convention). The signing scalar is `key_chain.secret_spending_key`
of the private accounts key at the specified chain index.
FFI shape:
enum WalletFfiError wallet_ffi_sign_message_at_chain_index(
struct WalletHandle *handle,
const char *chain_index_str, // "/", "/0", ...
const uint8_t *message,
uintptr_t message_len,
struct FfiCardSignature *out_sig); // { sig[64], vpk[32] }
The `verifying_public_key` returned alongside the signature is the
32-byte x-only public key (k256::schnorr::VerifyingKey). It lets a
verifier validate the signature without having to re-derive the
public key from chain state, making the produced signature
self-contained on the wire.
Motivation: this closes the AgentCard signing path in the LP-0008
submission. The agent module now signs its AgentCard with this entry
and embeds the verifying key inside the card's signature object —
cryptographically verifiable A2A AgentCards instead of a stub.
Cargo deps added: k256 (already in the workspace), sha2 = "0.10".
| /// Return the KeyChain of the first private accounts key in the wallet. | ||
| /// BTreeMap iteration over chain indices is deterministic, so this is | ||
| /// stable across wallet reopens for the same persisted storage. Used by | ||
| /// clients (e.g. agent runtimes) that need a stable cryptographic | ||
| /// identity tied to the wallet seed without rotating it at every | ||
| /// create_private_accounts_key call. | ||
| #[must_use] | ||
| pub fn first_private_accounts_key_chain(&self) -> Option<&KeyChain> { | ||
| self.storage | ||
| .key_chain() | ||
| .private_account_key_chains() | ||
| .next() | ||
| .map(|(_account_id, key_chain, _chain_index)| key_chain) | ||
| } | ||
|
|
||
| /// Return the KeyChain of the private accounts key at the given chain | ||
| /// index. Lets clients persist a pointer (chain_index, not crypto) and | ||
| /// look the same key back up across restarts deterministically. | ||
| #[must_use] | ||
| pub fn private_accounts_key_chain_by_index( | ||
| &self, | ||
| chain_index: &ChainIndex, | ||
| ) -> Option<&KeyChain> { | ||
| self.storage | ||
| .key_chain() | ||
| .private_account_key_chain_by_index(chain_index) | ||
| } |
There was a problem hiding this comment.
IMO both of this could be achieved by accessing wallet.storage().key_chain() and no additional wrappers needed.
Currently we indeed have some wrappers in this wallet, but I think this is not a good pattern and it's better not to expand this. Maybe we'll work on reducing it as well
| sequencer_service_rpc = { workspace = true, features = ["client"] } | ||
|
|
||
| tokio.workspace = true | ||
| key_protocol.workspace = true | ||
| serde_json.workspace = true | ||
| k256.workspace = true | ||
| sha2 = "0.10" |
There was a problem hiding this comment.
We have this in workspace, better use it
| let vpk_ptr = Box::into_raw(vpk_boxed) as *const u8; | ||
|
|
||
| unsafe { | ||
| (*out_keys).nullifier_public_key.data = npk_bytes; | ||
| (*out_keys).viewing_public_key = vpk_ptr; | ||
| (*out_keys).viewing_public_key_len = vpk_len; | ||
| } |
There was a problem hiding this comment.
I can see this intialization became a repeated pattern in existing codebase (this is 3rd time I think). Let's better encapsulate this to a separate function
| let Ok(chain_index) = ChainIndex::from_str(&chain_index_str) else { | ||
| print_error(format!("Failed to parse chain index: {chain_index_str}")); | ||
| return WalletFfiError::InvalidKeyValue; | ||
| }; |
There was a problem hiding this comment.
Looks like it's not a key problem
| let Ok(signing_key) = k256::schnorr::SigningKey::from_bytes(&secret_scalar) else { | ||
| print_error("secret_spending_key is not a valid Schnorr signing scalar"); | ||
| return WalletFfiError::InternalError; | ||
| }; | ||
|
|
||
| // Prehash with SHA-256 to fit the 32-byte BIP-340 message input. | ||
| let msg_slice = unsafe { std::slice::from_raw_parts(message, message_len) }; | ||
| let mut hasher = Sha256::new(); | ||
| hasher.update(msg_slice); | ||
| let prehash: [u8; 32] = hasher.finalize().into(); | ||
|
|
||
| let mut aux_random = [0_u8; 32]; | ||
| use k256::elliptic_curve::rand_core::{OsRng, RngCore as _}; | ||
| OsRng.fill_bytes(&mut aux_random); | ||
|
|
||
| let signature = match signing_key.sign_prehash_with_aux_rand(&prehash, &aux_random) { | ||
| Ok(s) => s, | ||
| Err(e) => { | ||
| print_error(format!("Schnorr signing failed: {e}")); | ||
| return WalletFfiError::InternalError; | ||
| } | ||
| }; |
There was a problem hiding this comment.
I don't understand why you need to manually perform cryptographic operations. I think you should use existing functionality of our keys. Or expand this functionality and use it here.
Summary
Adds two
wallet-ffientries (and matchingWalletCoreaccessors) that let a client resolve a stable cryptographic identity from a persisted wallet without rotating it at every restart.wallet_ffi_get_first_private_accounts_key(handle, out_keys)— returns the first private accounts key in the wallet (BTreeMap-ordered, deterministic across reopens for the same persisted storage).wallet_ffi_get_private_accounts_key_by_chain_index(handle, chain_index_str, out_keys)— returns the private accounts key at an explicit chain index using the wallet-CLI string format (/,/0,/0/1, …).Purely additive; no behaviour change for existing callers.
Why
Currently,
wallet-ffiexposes:wallet_ffi_create_private_accounts_key— derives a new chain at the next free layered index each time it is called, rotating the NPK at every call.wallet_ffi_get_private_account_keys(account_id)— returns a sub-account key chain (the per-account NPK derived under a parent), not the parent chain master.Neither lets a downstream application restore the same agent-level NPK after a wallet reopen. The workaround today is to cache the NPK in a sidecar file at the application layer, which is fragile and couples the application to file-layout assumptions that live outside the wallet.
The new accessors close that gap: by anchoring identity on a known chain index (
"/"for the seed-root node) the application gets a deterministic identity for the lifetime of the wallet seed.Motivation
I am building an LP-0008 (autonomous AI module) submission. Agent identity is the agent's NPK; without this patch, the agent's identity rotates every time Basecamp restarts the host process, breaking AgentCards announced to peers, owner-channel routing, and demo reproducibility.
Implementation
wallet/src/lib.rs: two new accessors onWalletCore. They thin-wrap existingUserKeyChain::private_account_key_chains(BTreeMap iter, deterministic) andUserKeyChain::private_account_key_chain_by_index.wallet-ffi/src/keys.rs: matching FFI entry points. They use the sameFfiPrivateAccountKeysout-shape and lifetime management as the existingwallet_ffi_get_private_account_keys.wallet-ffi/src/lib.rs: bumpc_str_to_stringvisibility topub(crate)so the new entry can reuse it.wallet-ffi/wallet_ffi.h: regenerated by cbindgen as part of the wallet-ffi build.Test plan
cargo build --release -p wallet-ffi --no-default-features(used downstream in the agent module).nm libwallet_ffi.dylib | grep wallet_ffi_get_private_accounts_key_by_chain_index."/"lookup. Verified with NPK40b5854e7b498cdf91461ed66da64df23e1a47389f752f4f392a5d19c3218371.wallet-fficrate does not have an integration test bench I could plug into directly).Notes
The header file changes are mechanical (cbindgen output). The visibility bump on
c_str_to_stringis the minimal change needed to reuse the existing helper; happy to inline a private copy instead if the reviewer prefers.