Skip to content
Closed
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
2 changes: 1 addition & 1 deletion auth/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ hex = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "time"] }
toml = { workspace = true }
turnkey_api_key_stamper = { workspace = true }
turnkey_client = { workspace = true }

[dev-dependencies]
tempfile = { workspace = true }
wiremock = { workspace = true }
38 changes: 37 additions & 1 deletion auth/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow};
use serde::{Deserialize, Serialize};
use tempfile::NamedTempFile;

const DEFAULT_API_BASE_URL: &str = "https://api.turnkey.com";
const CONFIG_PATH_ENV: &str = "TURNKEY_TK_CONFIG_PATH";
Expand Down Expand Up @@ -297,10 +299,44 @@ async fn save_persisted_config(path: &Path, config: &PersistedConfigFile) -> Res
tokio::fs::create_dir_all(parent).await?;
}
let serialized = toml::to_string_pretty(config).context("failed to serialize config file")?;
tokio::fs::write(path, serialized).await?;
let parent = path
.parent()
.ok_or_else(|| anyhow!("config path has no parent directory"))?;
let temp = NamedTempFile::new_in(parent).context("failed to create temporary config file")?;
tokio::fs::write(temp.path(), &serialized)
.await
.context("failed to write temporary config file")?;
tokio::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))
.await
.context("failed to set config file permissions")?;
temp.persist(path)
.context("failed to persist config file")?;
Ok(())
}

/// Atomically writes all init config values to the global config file.
pub async fn save_init_config(
organization_id: &str,
api_public_key: &str,
api_private_key: &str,
signing_address: &str,
signing_public_key: &str,
api_base_url: Option<&str>,
) -> Result<()> {
let path = global_config_path()?;
let config = PersistedConfigFile {
turnkey: PersistedTurnkeyConfig {
organization_id: Some(organization_id.to_string()),
api_public_key: Some(api_public_key.to_string()),
api_private_key: Some(api_private_key.to_string()),
signing_address: Some(signing_address.to_string()),
signing_public_key: Some(signing_public_key.to_string()),
api_base_url: api_base_url.map(|s| s.to_string()),
},
};
save_persisted_config(&path, &config).await
}

fn read_value(env: &BTreeMap<String, String>, key: &str) -> Option<String> {
env.get(key).and_then(|value| normalize_value(value))
}
Expand Down
153 changes: 153 additions & 0 deletions auth/src/init.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
use anyhow::{Context, Result, anyhow};
use turnkey_api_key_stamper::TurnkeyP256ApiKey;
use turnkey_client::TurnkeyClient;
use turnkey_client::generated::immutable::common::v1::{AddressFormat, Curve, PathFormat};
use turnkey_client::generated::{
CreateWalletIntent, GetWalletAccountsRequest, GetWalletsRequest, WalletAccountParams,
};

use crate::config;

const DEFAULT_API_BASE_URL: &str = "https://api.turnkey.com";
const WALLET_NAME: &str = "tk-default";

/// Result of a successful `tk init` operation.
#[derive(Debug, Clone)]
pub struct InitResult {
/// The signing address for the resolved Ed25519 account.
pub signing_address: String,
/// The hex-encoded Ed25519 public key for the resolved account.
pub signing_public_key: String,
/// The organization ID used for the operation.
pub organization_id: String,
/// Whether a new wallet was created during init.
pub created: bool,
}

/// Initializes a Turnkey configuration by finding or creating an Ed25519 wallet account.
pub async fn initialize(
org_id: &str,
api_public_key: &str,
api_private_key: &str,
api_base_url: Option<&str>,
) -> Result<InitResult> {
let base_url = api_base_url.unwrap_or(DEFAULT_API_BASE_URL);

let api_key = TurnkeyP256ApiKey::from_strings(api_private_key, Some(api_public_key))
.context("failed to load Turnkey API key")?;

let client = TurnkeyClient::builder()
.api_key(api_key)
.base_url(base_url)
.build()
.context("failed to build Turnkey client")?;

// List existing wallets.
let wallets_response = client
.get_wallets(GetWalletsRequest {
organization_id: org_id.to_string(),
})
.await
.context("failed to list wallets")?;

// Search each wallet for an Ed25519 account.
for wallet in &wallets_response.wallets {
let accounts_response = client
.get_wallet_accounts(GetWalletAccountsRequest {
organization_id: org_id.to_string(),
wallet_id: Some(wallet.wallet_id.clone()),
include_wallet_details: None,
pagination_options: None,
})
.await
.context("failed to list wallet accounts")?;

if let Some(account) = accounts_response
.accounts
.iter()
.find(|a| a.curve == Curve::Ed25519)
{
let public_key = account
.public_key
.as_ref()
.ok_or_else(|| anyhow!("Ed25519 account missing public key"))?;

config::save_init_config(
org_id,
api_public_key,
api_private_key,
&account.address,
public_key,
api_base_url,
)
.await?;

return Ok(InitResult {
signing_address: account.address.clone(),
signing_public_key: public_key.clone(),
organization_id: org_id.to_string(),
created: false,
});
}
}

// No Ed25519 account found, create a new wallet.
let create_result = client
.create_wallet(
org_id.to_string(),
client.current_timestamp(),
CreateWalletIntent {
wallet_name: WALLET_NAME.to_string(),
accounts: vec![WalletAccountParams {
curve: Curve::Ed25519,
path_format: PathFormat::Bip32,
path: "m/44'/501'/0'/0'".to_string(),
address_format: AddressFormat::Compressed,
}],
mnemonic_length: None,
},
)
.await
.context("failed to create wallet")?;

let wallet_id = create_result.result.wallet_id;

// Fetch accounts for the newly created wallet.
let accounts_response = client
.get_wallet_accounts(GetWalletAccountsRequest {
organization_id: org_id.to_string(),
wallet_id: Some(wallet_id),
include_wallet_details: None,
pagination_options: None,
})
.await
.context("failed to list accounts for new wallet")?;

let account = accounts_response
.accounts
.iter()
.find(|a| a.curve == Curve::Ed25519)
.ok_or_else(|| anyhow!("newly created wallet has no Ed25519 account"))?;

let public_key = account
.public_key
.as_ref()
.ok_or_else(|| anyhow!("Ed25519 account missing public key"))?;

config::save_init_config(
org_id,
api_public_key,
api_private_key,
&account.address,
public_key,
api_base_url,
)
.await?;

Ok(InitResult {
signing_address: account.address.clone(),
signing_public_key: public_key.clone(),
organization_id: org_id.to_string(),
created: true,
})
}
4 changes: 4 additions & 0 deletions auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
pub mod config;
/// Git SSH signing helpers backed by Turnkey.
pub mod git_sign;
/// Organization initialization and wallet setup helpers.
pub mod init;
/// Public-key helpers backed by Turnkey.
pub mod public_key;
/// SSH wire-format helpers for public keys and signatures.
pub mod ssh;
/// Turnkey-backed signing client helpers.
pub mod turnkey;
/// Authenticated identity lookup helpers.
pub mod whoami;
46 changes: 46 additions & 0 deletions auth/src/whoami.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use anyhow::{Context, Result};
use turnkey_api_key_stamper::TurnkeyP256ApiKey;
use turnkey_client::TurnkeyClient;
use turnkey_client::generated::GetWhoamiRequest;

use crate::config::Config;

/// Authenticated Turnkey identity information.
#[derive(Debug, Clone)]
pub struct Identity {
/// The organization ID.
pub organization_id: String,
/// The organization name.
pub organization_name: String,
/// The authenticated user ID.
pub user_id: String,
/// The authenticated username.
pub username: String,
}

/// Fetches the authenticated identity from Turnkey.
pub async fn get_identity(config: &Config) -> Result<Identity> {
let api_key =
TurnkeyP256ApiKey::from_strings(&config.api_private_key, Some(&config.api_public_key))
.context("failed to load Turnkey API key")?;

let client = TurnkeyClient::builder()
.api_key(api_key)
.base_url(&config.api_base_url)
.build()
.context("failed to build Turnkey client")?;

let response = client
.get_whoami(GetWhoamiRequest {
organization_id: config.organization_id.clone(),
})
.await
.context("failed to fetch identity from Turnkey")?;

Ok(Identity {
organization_id: response.organization_id,
organization_name: response.organization_name,
user_id: response.user_id,
username: response.username,
})
}
12 changes: 12 additions & 0 deletions tk/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use turnkey_auth::config::DEFAULT_CONFIG_DIR_DISPLAY;
/// Top-level CLI arguments for the `tk` binary.
#[derive(Debug, Parser)]
#[command(
version,
about = "CLI for Turnkey backed auth workflows",
long_about = None,
after_help = after_help()
Expand All @@ -20,6 +21,8 @@ impl Cli {
let args = Self::parse();

match args.command {
Commands::Init(args) => commands::init::run(args).await,
Commands::Whoami(args) => commands::whoami::run(args).await,
Commands::Config(args) => commands::config::run(args).await,
Commands::SshAgent(args) => commands::agent::run(args).await,
Commands::GitSign(args) => commands::git_sign::run(args).await,
Expand All @@ -30,6 +33,10 @@ impl Cli {

#[derive(Debug, Subcommand)]
enum Commands {
/// Initialize Turnkey credentials and wallet configuration.
Init(commands::init::Args),
/// Display the authenticated Turnkey identity.
Whoami(commands::whoami::Args),
/// Inspect and update persistent auth configuration.
Config(commands::config::Args),
/// Manage a background SSH agent over a Unix socket.
Expand All @@ -43,6 +50,11 @@ enum Commands {
fn after_help() -> String {
format!(
"\
Quick start:
export TURNKEY_API_PRIVATE_KEY=\"<your-api-private-key>\"
tk init --org-id <org-id> --api-public-key <api-public-key>
tk whoami

Environment:
TURNKEY_ORGANIZATION_ID
TURNKEY_API_PUBLIC_KEY
Expand Down
7 changes: 7 additions & 0 deletions tk/src/commands/config.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use anyhow::bail;
use clap::{Args as ClapArgs, Subcommand};

use turnkey_auth::config::{self, ConfigKey};
Expand Down Expand Up @@ -40,6 +41,12 @@ pub async fn run(args: Args) -> anyhow::Result<()> {
}
Command::Set(args) => {
let key = ConfigKey::parse(&args.key)?;
if key == ConfigKey::ApiPrivateKey {
bail!(
"turnkey.apiPrivateKey cannot be set via the CLI to avoid shell history exposure.\n\
Use the TURNKEY_API_PRIVATE_KEY environment variable or run `tk init` instead."
);
}
config::set_config_value(key, &args.value).await?;
}
Command::List => {
Expand Down
Loading