Skip to content
Open
Show file tree
Hide file tree
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
515 changes: 490 additions & 25 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ hex = "0.4"
minisign = "0.9"
hostname = "0.4"
mpp = { git = "https://github.com/tempoxyz/mpp-rs.git", branch = "main", features = ["tempo", "evm", "utils", "client", "server"] }
ows-core = "1.0.0"
ows-lib = "1.0.0"
posthog-rs = "0.4.7"
qrcode = { version = "0.14", default-features = false }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
Expand Down
2 changes: 2 additions & 0 deletions crates/tempo-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ getrandom.workspace = true
hex.workspace = true
hostname.workspace = true
mpp.workspace = true
ows-core.workspace = true
ows-lib.workspace = true
posthog-rs.workspace = true
reqwest.workspace = true
rusqlite.workspace = true
Expand Down
1 change: 1 addition & 0 deletions crates/tempo-common/src/keys/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

pub mod authorization;
mod io;
pub mod ows;
mod keystore;
mod model;
mod signer;
Expand Down
105 changes: 105 additions & 0 deletions crates/tempo-common/src/keys/ows.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! OWS (Open Wallet Standard) wallet operations.

use zeroize::Zeroizing;

use crate::error::{ConfigError, TempoError};

/// Create a new OWS wallet. Returns the wallet name.
pub fn create_wallet(name: &str) -> Result<String, TempoError> {
ows_lib::create_wallet(name, Some(12), None, None)
.map_err(|e| TempoError::Config(ConfigError::Missing(format!("OWS create_wallet: {e}"))))?;
Ok(name.to_string())
}

/// Find an existing OWS wallet whose name starts with `prefix`.
pub fn find_wallet(prefix: &str) -> Option<String> {
let wallets = ows_lib::list_wallets(None).ok()?;
wallets
.iter()
.find(|w| w.name.starts_with(prefix))
.map(|w| w.name.clone())
}

/// Decrypt the EVM signing key from an OWS wallet.
pub fn export_private_key(name_or_id: &str) -> Result<Zeroizing<String>, TempoError> {
let key_bytes = ows_lib::decrypt_signing_key(
name_or_id, ows_core::ChainType::Evm, "", None, None,
)
.map_err(|e| TempoError::Config(ConfigError::Missing(format!("OWS decrypt: {e}"))))?;
Ok(Zeroizing::new(format!("0x{}", hex::encode(key_bytes.expose()))))
}

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

// Uses a custom vault path so tests don't touch the real ~/.ows.
fn create_in(name: &str, vault: &std::path::Path) -> String {
ows_lib::create_wallet(name, Some(12), None, Some(vault)).unwrap();
name.to_string()
}

fn export_in(name: &str, vault: &std::path::Path) -> Zeroizing<String> {
let key_bytes = ows_lib::decrypt_signing_key(
name, ows_core::ChainType::Evm, "", None, Some(vault),
).unwrap();
Zeroizing::new(format!("0x{}", hex::encode(key_bytes.expose())))
}

fn find_in(prefix: &str, vault: &std::path::Path) -> Option<String> {
let wallets = ows_lib::list_wallets(Some(vault)).ok()?;
wallets.iter().find(|w| w.name.starts_with(prefix)).map(|w| w.name.clone())
}

#[test]
fn create_and_export_round_trip() {
let vault = tempfile::tempdir().unwrap();
let name = create_in("test-wallet", vault.path());
let key = export_in(&name, vault.path());
// Should be a valid 0x-prefixed 32-byte hex key.
assert!(key.starts_with("0x"));
assert_eq!(key.len(), 66); // 0x + 64 hex chars
}

#[test]
fn find_wallet_by_prefix() {
let vault = tempfile::tempdir().unwrap();
create_in("tempo-abc123", vault.path());
create_in("polymarket-def456", vault.path());

assert_eq!(find_in("tempo", vault.path()), Some("tempo-abc123".to_string()));
assert_eq!(find_in("polymarket", vault.path()), Some("polymarket-def456".to_string()));
assert_eq!(find_in("nonexistent", vault.path()), None);
}

#[test]
fn reuse_existing_wallet_same_address() {
let vault = tempfile::tempdir().unwrap();
create_in("tempo-first", vault.path());

let key1 = export_in("tempo-first", vault.path());
let key2 = export_in("tempo-first", vault.path());
assert_eq!(*key1, *key2, "same wallet should produce same key");
}

#[test]
fn different_wallets_different_keys() {
let vault = tempfile::tempdir().unwrap();
create_in("wallet-a", vault.path());
create_in("wallet-b", vault.path());

let key_a = export_in("wallet-a", vault.path());
let key_b = export_in("wallet-b", vault.path());
assert_ne!(*key_a, *key_b);
}

#[test]
fn exported_key_is_zeroizing() {
let vault = tempfile::tempdir().unwrap();
create_in("zero-test", vault.path());
let key = export_in("zero-test", vault.path());
// Key is Zeroizing<String> — verify it's valid before drop.
assert!(key.starts_with("0x"));
// After drop, memory is wiped (can't test directly, but type guarantees it).
}
}
4 changes: 2 additions & 2 deletions crates/tempo-wallet/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> {
|ctx| async move {
let cmd_name = command_name(&command);
let result = match command {
Commands::Login => login::run(&ctx).await,
Commands::Login { ows } => login::run(&ctx, ows).await,
Commands::Refresh => refresh::run(&ctx).await,
Commands::Logout { yes } => logout::run(&ctx, yes),
Commands::Completions { shell } => completions::run(&ctx, shell),
Expand Down Expand Up @@ -63,7 +63,7 @@ pub(crate) async fn run(mut cli: Cli) -> Result<(), TempoError> {
/// Derive a short analytics-friendly name from a parsed command.
const fn command_name(command: &Commands) -> &'static str {
match command {
Commands::Login => "login",
Commands::Login { .. } => "login",
Commands::Refresh => "refresh",
Commands::Logout { .. } => "logout",
Commands::Completions { .. } => "completions",
Expand Down
6 changes: 5 additions & 1 deletion crates/tempo-wallet/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ pub(crate) struct Cli {
pub(crate) enum Commands {
/// Sign up or log in to your Tempo wallet
#[command(display_order = 1)]
Login,
Login {
/// Use OWS encrypted vault instead of passkey (fully headless)
#[arg(long)]
ows: bool,
},
/// Refresh your access key without logging out
#[command(display_order = 2)]
Refresh,
Expand Down
89 changes: 88 additions & 1 deletion crates/tempo-wallet/src/commands/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ use tempo_common::{
const CALLBACK_TIMEOUT_SECS: u64 = 900; // 15 minutes
const POLL_INTERVAL_SECS: u64 = 2;

pub(crate) async fn run(ctx: &Context) -> Result<(), TempoError> {
pub(crate) async fn run(ctx: &Context, ows: bool) -> Result<(), TempoError> {
if ows {
return run_ows(ctx).await;
}
run_impl(ctx, false).await
}

Expand Down Expand Up @@ -81,6 +84,90 @@ async fn run_impl(ctx: &Context, force_reauth: bool) -> Result<(), TempoError> {
show_whoami(ctx, Some(&keys), None).await
}

/// OWS login — headless alternative to passkey. Same scoped access key,
/// same $100 limit, same 30-day expiry. Root key in OWS vault instead
/// of passkey secure enclave.
async fn run_ows(ctx: &Context) -> Result<(), TempoError> {
let already_logged_in = ctx.keys.has_key_for_network(ctx.network);

let needs_reauth = if already_logged_in {
is_key_revoked_or_expired(ctx).await
} else {
false
};

if needs_reauth {
invalidate_stale_key(ctx, false)?;
}

if already_logged_in && !needs_reauth {
if ctx.output_format == OutputFormat::Text {
eprintln!("Already logged in.\n");
}
let keys = ctx.keys.reload()?;
return show_whoami(ctx, Some(&keys), None).await;
}

// Reuse existing OWS wallet for this network, or create a new one.
let network_prefix = ctx.network.as_str();
let ows_wallet_name = if let Some(existing) = tempo_common::keys::ows::find_wallet(network_prefix) {
existing
} else {
let mut nonce = [0u8; 4];
getrandom::fill(&mut nonce).map_err(|source| KeyError::SigningOperationSource {
operation: "generate wallet nonce",
source: Box::new(source),
})?;
let name = format!("{}-{}", network_prefix, hex::encode(nonce));
tempo_common::keys::ows::create_wallet(&name)?
};

// Derive wallet address from root key.
let exported_key = tempo_common::keys::ows::export_private_key(&ows_wallet_name)?;
let root_signer = tempo_common::keys::parse_private_key_signer(&exported_key)?;
let wallet_address = root_signer.address();
drop(exported_key);

// Generate scoped access key and sign authorization (same defaults as passkey).
let access_signer = PrivateKeySigner::random();
let auth = tempo_common::keys::authorization::sign(
&root_signer,
&access_signer,
ctx.network.chain_id(),
)?;
drop(root_signer);

// Reuse save_keys() — same save path as passkey login.
save_keys(
ctx.network,
&ctx.keys,
AuthCallback {
account_address: format!("{wallet_address:#x}"),
key_authorization: Some(auth.hex),
duration_secs: 0,
},
access_signer,
)?;

ctx.track(
analytics::WALLET_CREATED,
WalletCreatedPayload {
wallet_type: "ows".to_string(),
},
);
ctx.track_event(analytics::KEY_CREATED);
if let Some(ref a) = ctx.analytics {
a.identify(&ctx.keys);
}

if ctx.output_format == OutputFormat::Text {
eprintln!("\nWallet connected!\n");
}

let keys = ctx.keys.reload()?;
show_whoami(ctx, Some(&keys), None).await
}

/// Check whether the stored access key has been revoked or has expired on-chain.
///
/// Returns `true` when the key is definitively invalid, `false` otherwise
Expand Down