Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion src/openhuman/wallet/chains/solana.rs
Original file line number Diff line number Diff line change
Expand Up @@ -655,12 +655,95 @@ pub(crate) async fn tinyplace_signer_seed() -> Result<[u8; 32], String> {
// Extract 32-byte SLIP-0010 secret — same bytes LocalSigner::from_seed expects.
// Never logged: the log below omits the seed.
log::debug!("[tinyplace] signer seed derived (seed not logged)");
Ok(signing_key.to_bytes())
Ok(slash_free_identity_seed(signing_key.to_bytes()))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep signer seed aligned with the wallet address

For any wallet whose raw public key base64 contains /, this now returns a seed for a different Ed25519 key, so LocalSigner.agent_id() is no longer the Solana wallet address derived by the normal wallet path. The existing tiny.place payment and registry flows still treat signer.agent_id() as the wallet owner, e.g. wallet_usdc_balance(&signer.agent_id()), x402 from, and RegisterRequest.crypto_id, while execute_prepared spends from the original Solana wallet. In that slash-bearing case users can see/authorize under an unfunded or different cryptoId, and paid actions can be rejected after the on-chain transfer; keep the spending identity tied to the wallet and fix the /keys path/messaging key separately.

Useful? React with 👍 / 👎.

}

/// Return a tiny.place identity seed whose Ed25519 public key has a **slash-free**
/// STANDARD-base64 encoding.
///
/// The backend serves that base64 public key as the bundle `identityKey` and keys
/// its `/keys/{identityKey}/…` routes on it verbatim; a `/` in the key becomes
/// `%2F` in the path and the route 404s — so a slash-bearing identity can neither
/// publish Signal keys nor receive DMs (the same failure the tiny.place plugin
/// avoids by regenerating slash-free wallets). Our identity is *deterministic* from
/// the wallet key, so instead of regenerating we bump a domain-separated counter:
///
/// - **counter 0 = the raw wallet seed** — every already-clean identity stays
/// byte-identical (no migration for the common case),
/// - only when the raw key's base64 contains `/` do we deterministically derive a
/// fresh seed from `SHA-256(domain ‖ seed ‖ counter)` until the public key is
/// clean. Same wallet → same (lowest-counter) slash-free identity, always.
fn slash_free_identity_seed(base: [u8; 32]) -> [u8; 32] {
for counter in 0u32..100_000 {
let seed: [u8; 32] = if counter == 0 {
base
} else {
let mut hasher = Sha256::new();
hasher.update(b"openhuman.tinyplace.identity.slashfree.v1");
hasher.update(base);
hasher.update(counter.to_le_bytes());
hasher.finalize().into()
};
let pub_b64 = B64.encode(SigningKey::from_bytes(&seed).verifying_key().to_bytes());
if !pub_b64.contains('/') {
if counter > 0 {
log::debug!(
"[tinyplace] raw wallet identity key had '/'; using slash-free derivation (counter={counter})"
);
}
return seed;
}
}
// Astronomically unlikely (~1 in 2^100000 that every candidate has a '/');
// fall back to the raw seed rather than panic.
base
}

#[cfg(test)]
mod tests {
use super::*;

fn pub_b64(seed: &[u8; 32]) -> String {
B64.encode(SigningKey::from_bytes(seed).verifying_key().to_bytes())
}

#[test]
fn slash_free_identity_seed_cleans_slash_keys_deterministically() {
// Find a base seed whose raw Ed25519 pubkey base64 contains '/', and one
// that's already clean (roughly half of all seeds are clean).
let (mut slashed, mut clean): (Option<[u8; 32]>, Option<[u8; 32]>) = (None, None);
for i in 0u32..5000 {
let mut s = [0u8; 32];
s[..4].copy_from_slice(&i.to_le_bytes());
if pub_b64(&s).contains('/') {
slashed.get_or_insert(s);
} else {
clean.get_or_insert(s);
}
if slashed.is_some() && clean.is_some() {
break;
}
}
let clean = clean.expect("a clean seed exists");
let slashed = slashed.expect("a slashed seed exists");

// Clean seed passes through unchanged (counter 0 — no migration).
assert_eq!(slash_free_identity_seed(clean), clean);

// Slashed seed is remapped to a slash-free, deterministic result.
let out = slash_free_identity_seed(slashed);
assert_ne!(out, slashed, "slashed seed must be remapped");
assert!(
!pub_b64(&out).contains('/'),
"result pubkey must be slash-free"
);
assert_eq!(
slash_free_identity_seed(slashed),
out,
"must be deterministic"
);
Comment on lines +733 to +744

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Pin a golden remapping vector to prevent accidental identity migration.

Calling the helper twice only proves current-run determinism. Changes to the domain, counter encoding, or hash inputs would still pass while silently changing TinyPlace and Signal identities. Assert the exact output seed or public key for a fixed slash-bearing seed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/wallet/chains/solana.rs` around lines 733 - 744, Add a
golden-vector assertion to the slash-bearing seed test around
slash_free_identity_seed, asserting the exact expected output seed or derived
public key for the fixed slashed input; retain the existing slash-free and
determinism checks to prevent accidental identity migration from changes to
hashing or encoding.

}

use crate::openhuman::wallet::execution::{
insert_quote_for_test, now_ms, reset_quote_store_for_tests, PreparedKind, PreparedStatus,
PreparedTransaction,
Expand Down
Loading