Skip to content
Draft
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
27 changes: 26 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,23 @@ jobs:
run: cargo build --release --target ${{ matrix.target }} -p ${{ env.PACKAGE }}
- run: strip target/${{ matrix.target }}/release/${{ env.PACKAGE }}
- run: mv target/${{ matrix.target }}/release/${{ env.PACKAGE }} ${{ env.PACKAGE }}-${{ matrix.suffix }}

- name: Build tempo-se (macOS only)
if: startsWith(matrix.os, 'macos') && env.PACKAGE == 'tempo-wallet'
run: |
ARCH=${{ matrix.target == 'aarch64-apple-darwin' && 'arm64' || 'x86_64' }}
swift build -c release --package-path tools/tempo-se --arch "$ARCH"
cp tools/tempo-se/.build/release/tempo-se tempo-se-${{ matrix.suffix }}

- uses: actions/upload-artifact@v7
with:
name: ${{ env.PACKAGE }}-${{ matrix.suffix }}
path: ${{ env.PACKAGE }}-${{ matrix.suffix }}
- uses: actions/upload-artifact@v7
if: startsWith(matrix.os, 'macos') && env.PACKAGE == 'tempo-wallet'
with:
name: tempo-se-${{ matrix.suffix }}
path: tempo-se-${{ matrix.suffix }}

publish:
name: Publish
Expand Down Expand Up @@ -106,7 +119,11 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${PACKAGE}@${VERSION}"
gh release upload "$TAG" artifacts/* --clobber
gh release upload "$TAG" artifacts/${PACKAGE}-* --clobber
# Upload tempo-se companion binaries if present
for f in artifacts/tempo-se-*; do
[ -f "$f" ] && gh release upload "$TAG" "$f" --clobber || true
done

- name: Configure git auth
run: git config --global url."https://x-access-token:${{ secrets.GH_PAT }}@github.com/".insteadOf "https://github.com/"
Expand Down Expand Up @@ -166,6 +183,14 @@ jobs:
aws s3 cp "$f" "s3://tempo-cli/${PREFIX}/${VTAG}/${BASENAME}" --endpoint-url "$AWS_ENDPOINT_URL"
done

# tempo-se companion binary (macOS only, tempo-wallet only)
for f in artifacts/tempo-se-darwin-*; do
[ -f "$f" ] || continue
BASENAME=$(basename "$f")
aws s3 cp "$f" "s3://tempo-cli/${PREFIX}/${BASENAME}" --endpoint-url "$AWS_ENDPOINT_URL"
aws s3 cp "$f" "s3://tempo-cli/${PREFIX}/${VTAG}/${BASENAME}" --endpoint-url "$AWS_ENDPOINT_URL"
done

# Signed manifest (latest + versioned)
aws s3 cp "artifacts/${PACKAGE}-manifest.json" "s3://tempo-cli/${PREFIX}/manifest.json" --endpoint-url "$AWS_ENDPOINT_URL"
aws s3 cp "artifacts/${PACKAGE}-manifest.json" "s3://tempo-cli/${PREFIX}/${VTAG}/manifest.json" --endpoint-url "$AWS_ENDPOINT_URL"
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ keys.toml
# Build artifacts
*.tar.gz

# Swift / Xcode
.build/
xcuserdata/
DerivedData/

# Eval (promptfoo output and old run artifacts)
eval/runs/
eval/output/
Expand Down
25 changes: 25 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
url = "2.5"
urlencoding = "2.1"
webbrowser = "1"
which = "7"
zeroize = { version = "1", features = ["serde"] }

# dev-dependencies
Expand Down
1 change: 1 addition & 0 deletions crates/tempo-common/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ toml.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
url.workspace = true
which.workspace = true
zeroize.workspace = true

[dev-dependencies]
Expand Down
2 changes: 2 additions & 0 deletions crates/tempo-common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ pub enum KeyError {
InvalidAddress(String),
#[error("Login expired. Use `tempo wallet login` to try again.")]
LoginExpired,
#[error("Secure Enclave error: {0}")]
SecureEnclave(String),
}

#[derive(Error, Debug)]
Expand Down
36 changes: 32 additions & 4 deletions crates/tempo-common/src/keys/authorization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ pub fn validate(
}))
}

/// Sign a key authorization for a key using the wallet EOA.
/// Sign a key authorization for a secp256k1 access key using the wallet EOA.
///
/// Returns the validated auth containing hex, expiry, and token limits.
/// Uses $100 USDC limit and 30-day expiry.
Expand All @@ -122,6 +122,29 @@ pub fn sign(
wallet_signer: &PrivateKeySigner,
access_signer: &PrivateKeySigner,
chain_id: u64,
) -> Result<ValidatedKeyAuth, TempoError> {
sign_for_key(
wallet_signer,
access_signer.address(),
SignatureType::Secp256k1,
chain_id,
)
}

/// Sign a key authorization for an access key of any type using the wallet EOA.
///
/// The wallet signer (secp256k1) signs an authorization granting `key_id` the
/// right to act on behalf of the wallet, with the given `key_type`.
///
/// # Errors
///
/// Returns an error when the chain ID is unsupported or the authorization
/// signature operation fails.
pub fn sign_for_key(
wallet_signer: &PrivateKeySigner,
key_id: Address,
key_type: SignatureType,
chain_id: u64,
) -> Result<ValidatedKeyAuth, TempoError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
Expand All @@ -144,10 +167,15 @@ pub fn sign(
limit: limit.to_string(),
})
.collect();
let stored_key_type = match key_type {
SignatureType::Secp256k1 => KeyType::Secp256k1,
SignatureType::P256 => KeyType::P256,
SignatureType::WebAuthn => KeyType::WebAuthn,
};
let auth = KeyAuthorization {
chain_id,
key_type: SignatureType::Secp256k1,
key_id: access_signer.address(),
key_type,
key_id,
expiry: Some(expiry_secs),
limits: Some(token_limits),
};
Expand All @@ -166,7 +194,7 @@ pub fn sign(
hex: format!("0x{}", hex::encode(&buf)),
expiry: expiry_secs,
chain_id,
key_type: KeyType::Secp256k1,
key_type: stored_key_type,
limits: stored_limits,
})
}
Expand Down
29 changes: 25 additions & 4 deletions crates/tempo-common/src/keys/keystore.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ impl Keystore {

/// Get the primary key entry.
///
/// Deterministic selection: passkey > first key with a signing key > first entry.
/// Deterministic selection: passkey > SE key > first key with a signing key > first entry.
#[must_use]
pub fn primary_key(&self) -> Option<&KeyEntry> {
if let Some(entry) = self
Expand All @@ -101,6 +101,9 @@ impl Keystore {
{
return Some(entry);
}
if let Some(entry) = self.keys.iter().find(|entry| entry.is_secure_enclave()) {
return Some(entry);
}
if let Some(entry) = self.keys.iter().find(|entry| entry.has_inline_key()) {
return Some(entry);
}
Expand All @@ -110,11 +113,12 @@ impl Keystore {
/// Check if a wallet is configured.
///
/// Returns `true` when the primary key has a wallet address AND
/// an inline `key`.
/// a usable signing key (inline or SE-backed).
#[must_use]
pub fn has_wallet(&self) -> bool {
self.primary_key()
.is_some_and(|entry| entry.wallet_address_parsed().is_some() && entry.has_inline_key())
self.primary_key().is_some_and(|entry| {
entry.wallet_address_parsed().is_some() && entry.has_signing_capability()
})
}

/// Check if a wallet is connected with a key for the given network.
Expand Down Expand Up @@ -223,6 +227,23 @@ impl Keystore {
Ok(())
}

/// Delete all entries with the given Secure Enclave label.
///
/// # Errors
///
/// Returns an error when no entries match.
pub fn delete_se_label(&mut self, label: &str) -> Result<(), TempoError> {
let before = self.keys.len();
self.keys.retain(|k| k.se_label.as_deref() != Some(label));
if self.keys.len() == before {
return Err(ConfigError::Missing(format!(
"No Secure Enclave key found with label '{label}'."
))
.into());
}
Ok(())
}

/// Find or create an entry by parsed wallet address and chain ID.
pub fn upsert_by_wallet_address_and_chain(
&mut self,
Expand Down
7 changes: 4 additions & 3 deletions crates/tempo-common/src/keys/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ pub mod authorization;
mod io;
mod keystore;
mod model;
pub mod secure_enclave;
mod signer;

pub use io::{take_keystore_load_summary, KeystoreLoadSummary};
pub use keystore::Keystore;
pub use model::{KeyEntry, WalletType};
use model::{KeyType, StoredTokenLimit};
pub use signer::{parse_private_key_signer, Signer};
use model::StoredTokenLimit;
pub use model::{KeyEntry, KeyType, WalletType};
pub use signer::{parse_private_key_signer, Signer, WalletSigner};
34 changes: 34 additions & 0 deletions crates/tempo-common/src/keys/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ pub enum KeyType {
Secp256k1,
P256,
WebAuthn,
/// P-256 key backed by Apple Secure Enclave (non-exportable).
///
/// On-chain this behaves identically to `P256` — the SE is a storage/signing
/// backend, not a distinct curve. A future refactor may split this into
/// `key_type = P256` + `key_backend = SecureEnclave`.
#[serde(rename = "secure_enclave")]
SecureEnclave,
}

/// Token spending limit stored in keys.toml.
Expand Down Expand Up @@ -70,6 +77,11 @@ pub struct KeyEntry {
/// Key expiry as unix timestamp.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expiry: Option<u64>,
/// Secure Enclave keychain label (macOS only).
/// When set, the private key is non-exportable and managed by the SE.
/// The `key` field is not used for SE keys.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub se_label: Option<String>,
/// Token spending limits for this key.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub limits: Vec<StoredTokenLimit>,
Expand All @@ -94,6 +106,11 @@ pub(super) struct StoredKeyEntry {
pub key_authorization: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expiry: Option<u64>,
/// Secure Enclave keychain label (macOS only).
/// When set, the private key is non-exportable and managed by the SE.
/// The `key` field is not used for SE keys.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub se_label: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub limits: Vec<StoredTokenLimit>,
}
Expand All @@ -115,6 +132,7 @@ impl From<KeyEntry> for StoredKeyEntry {
key: value.key,
key_authorization: value.key_authorization,
expiry: value.expiry,
se_label: value.se_label,
limits: value.limits,
}
}
Expand All @@ -131,6 +149,7 @@ impl From<StoredKeyEntry> for KeyEntry {
key: value.key,
key_authorization: value.key_authorization,
expiry: value.expiry,
se_label: value.se_label,
limits: value.limits,
}
}
Expand All @@ -147,6 +166,7 @@ impl std::fmt::Debug for KeyEntry {
.field("key", &self.key.as_ref().map(|_| "<redacted>"))
.field("key_authorization", &self.key_authorization)
.field("expiry", &self.expiry)
.field("se_label", &self.se_label)
.field("limits", &self.limits)
.finish()
}
Expand Down Expand Up @@ -219,6 +239,18 @@ impl KeyEntry {
self.key.as_ref().is_some_and(|key| !key.is_empty())
}

/// Whether this entry uses a Secure Enclave key.
#[must_use]
pub fn is_secure_enclave(&self) -> bool {
self.key_type == KeyType::SecureEnclave && self.se_label.is_some()
}

/// Whether this entry has a usable signing key (inline or SE-backed).
#[must_use]
pub fn has_signing_capability(&self) -> bool {
self.has_inline_key() || self.is_secure_enclave()
}

/// Whether this entry represents a direct EOA signer (wallet == signer key).
#[must_use]
pub fn is_direct_eoa_key(&self) -> bool {
Expand Down Expand Up @@ -286,6 +318,7 @@ mod tests {
.unwrap(),
limit: "1000".to_string(),
}],
se_label: None,
};

let toml_str = toml::to_string(&entry).unwrap();
Expand Down Expand Up @@ -314,6 +347,7 @@ mod tests {
key_authorization: None,
expiry: None,
limits: vec![],
se_label: None,
};

let toml_str = toml::to_string(&entry).unwrap();
Expand Down
Loading