1use alloy_ens::NameOrAddress;
2use alloy_network::EthereumWallet;
3use alloy_primitives::{Address, U256, hex, keccak256};
4use alloy_provider::{Provider, ProviderBuilder as AlloyProviderBuilder};
5use alloy_signer::Signer;
6use alloy_sol_types::{SolCall, sol};
7use chrono::DateTime;
8use clap::Parser;
9use eyre::Result;
10use foundry_cli::{
11 opts::{RpcOpts, TransactionOpts},
12 utils::LoadConfig,
13};
14use foundry_common::{
15 FoundryTransactionBuilder,
16 provider::ProviderBuilder,
17 shell,
18 tempo::{self, KeyType, KeysFile, WalletType, read_tempo_keys_file, tempo_keys_path},
19};
20use tempo_alloy::{TempoNetwork, provider::TempoProviderExt};
21use tempo_contracts::precompiles::{
22 ACCOUNT_KEYCHAIN_ADDRESS, IAccountKeychain,
23 IAccountKeychain::{KeyInfo, SignatureType, TokenLimit},
24};
25use yansi::Paint;
26
27use crate::tx::{CastTxBuilder, CastTxSender, SendTxOpts};
28
29sol! {
32 #[derive(Debug)]
33 struct SelectorRule {
34 bytes4 selector;
35 address[] recipients;
36 }
37
38 #[derive(Debug)]
39 struct CallScope {
40 address target;
41 SelectorRule[] selectorRules;
42 }
43
44 #[derive(Debug)]
45 struct ExtTokenLimit {
46 address token;
47 uint256 amount;
48 }
49
50 #[derive(Debug)]
51 struct KeyRestrictions {
52 uint64 expiry;
53 bool enforceLimits;
54 ExtTokenLimit[] limits;
55 bool allowAnyCalls;
56 CallScope[] allowedCalls;
57 }
58
59 function authorizeKeyWithRestrictions(
60 address keyId,
61 uint8 signatureType,
62 KeyRestrictions calldata config
63 ) external;
64
65 function setAllowedCalls(
66 address keyId,
67 CallScope[] calldata scopes
68 ) external;
69
70 function removeAllowedCalls(address keyId, address target) external;
71}
72
73#[derive(Debug, Parser)]
78pub enum KeychainSubcommand {
79 #[command(visible_alias = "ls")]
81 List,
82
83 Show {
85 wallet_address: Address,
87 },
88
89 #[command(visible_alias = "info")]
91 Check {
92 wallet_address: Address,
94
95 key_address: Address,
97
98 #[command(flatten)]
99 rpc: RpcOpts,
100 },
101
102 #[command(visible_alias = "auth")]
104 Authorize {
105 key_address: Address,
107
108 #[arg(default_value = "secp256k1", value_parser = parse_signature_type)]
110 key_type: SignatureType,
111
112 #[arg(default_value_t = u64::MAX)]
114 expiry: u64,
115
116 #[arg(long)]
118 enforce_limits: bool,
119
120 #[arg(long = "limit", value_parser = parse_limit)]
122 limits: Vec<TokenLimit>,
123
124 #[arg(long = "scope", value_parser = parse_scope)]
128 scope: Vec<CallScope>,
129
130 #[arg(long = "scopes", value_parser = parse_scopes_json, conflicts_with = "scope")]
134 scopes_json: Option<Vec<CallScope>>,
135
136 #[command(flatten)]
137 tx: TransactionOpts,
138
139 #[command(flatten)]
140 send_tx: SendTxOpts,
141 },
142
143 #[command(visible_alias = "rev")]
145 Revoke {
146 key_address: Address,
148
149 #[command(flatten)]
150 tx: TransactionOpts,
151
152 #[command(flatten)]
153 send_tx: SendTxOpts,
154 },
155
156 #[command(name = "rl", visible_alias = "remaining-limit")]
158 RemainingLimit {
159 wallet_address: Address,
161
162 key_address: Address,
164
165 token: Address,
167
168 #[command(flatten)]
169 rpc: RpcOpts,
170 },
171
172 #[command(name = "ul", visible_alias = "update-limit")]
174 UpdateLimit {
175 key_address: Address,
177
178 token: Address,
180
181 new_limit: U256,
183
184 #[command(flatten)]
185 tx: TransactionOpts,
186
187 #[command(flatten)]
188 send_tx: SendTxOpts,
189 },
190
191 #[command(name = "ss", visible_alias = "set-scope")]
193 SetScope {
194 key_address: Address,
196
197 #[arg(long = "scope", required = true, value_parser = parse_scope)]
199 scope: Vec<CallScope>,
200
201 #[command(flatten)]
202 tx: TransactionOpts,
203
204 #[command(flatten)]
205 send_tx: SendTxOpts,
206 },
207
208 #[command(name = "rs", visible_alias = "remove-scope")]
210 RemoveScope {
211 key_address: Address,
213
214 target: Address,
216
217 #[command(flatten)]
218 tx: TransactionOpts,
219
220 #[command(flatten)]
221 send_tx: SendTxOpts,
222 },
223}
224
225fn parse_signature_type(s: &str) -> Result<SignatureType, String> {
226 match s.to_lowercase().as_str() {
227 "secp256k1" => Ok(SignatureType::Secp256k1),
228 "p256" => Ok(SignatureType::P256),
229 "webauthn" => Ok(SignatureType::WebAuthn),
230 _ => Err(format!("unknown signature type: {s} (expected secp256k1, p256, or webauthn)")),
231 }
232}
233
234fn signature_type_name(t: &SignatureType) -> &'static str {
235 match t {
236 SignatureType::Secp256k1 => "secp256k1",
237 SignatureType::P256 => "p256",
238 SignatureType::WebAuthn => "webauthn",
239 _ => "unknown",
240 }
241}
242
243fn key_type_name(t: &KeyType) -> &'static str {
244 match t {
245 KeyType::Secp256k1 => "secp256k1",
246 KeyType::P256 => "p256",
247 KeyType::WebAuthn => "webauthn",
248 }
249}
250
251fn wallet_type_name(t: &WalletType) -> &'static str {
252 match t {
253 WalletType::Local => "local",
254 WalletType::Passkey => "passkey",
255 }
256}
257
258fn parse_limit(s: &str) -> Result<TokenLimit, String> {
260 let (token_str, amount_str) = s
261 .split_once(':')
262 .ok_or_else(|| format!("invalid limit format: {s} (expected TOKEN:AMOUNT)"))?;
263 let token: Address =
264 token_str.parse().map_err(|e| format!("invalid token address '{token_str}': {e}"))?;
265 let amount: U256 =
266 amount_str.parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?;
267 Ok(TokenLimit { token, amount })
268}
269
270fn parse_scope(s: &str) -> Result<CallScope, String> {
277 let (target_str, selectors_str) = match s.split_once(':') {
278 Some((t, sel)) => (t, Some(sel)),
279 None => (s, None),
280 };
281
282 let target: Address =
283 target_str.parse().map_err(|e| format!("invalid target address '{target_str}': {e}"))?;
284
285 let selector_rules = match selectors_str {
286 None => vec![],
287 Some(sel_str) => parse_selector_rules(sel_str)?,
288 };
289
290 Ok(CallScope { target, selectorRules: selector_rules })
291}
292
293fn parse_selector_rules(s: &str) -> Result<Vec<SelectorRule>, String> {
297 let mut rules = Vec::new();
298
299 for part in s.split(',') {
300 let part = part.trim();
301 if part.is_empty() {
302 continue;
303 }
304
305 let (selector_str, recipients_str) = match part.split_once('@') {
306 Some((sel, recip)) => (sel, Some(recip)),
307 None => (part, None),
308 };
309
310 let selector = parse_selector_bytes(selector_str)?;
311
312 let recipients = match recipients_str {
313 None => vec![],
314 Some(r) => r
315 .split(',')
316 .filter(|s| !s.trim().is_empty())
317 .map(|addr_str| {
318 let addr_str = addr_str.trim();
319 addr_str
320 .parse::<Address>()
321 .map_err(|e| format!("invalid recipient address '{addr_str}': {e}"))
322 })
323 .collect::<Result<Vec<_>, _>>()?,
324 };
325
326 rules.push(SelectorRule { selector: selector.into(), recipients });
327 }
328
329 Ok(rules)
330}
331
332fn parse_selector_bytes(s: &str) -> Result<[u8; 4], String> {
335 let s = s.trim();
336 if s.starts_with("0x") || s.starts_with("0X") {
337 let hex_str = &s[2..];
338 if hex_str.len() != 8 {
339 return Err(format!("hex selector must be 4 bytes (8 hex chars), got: {s}"));
340 }
341 let bytes = hex::decode(hex_str).map_err(|e| format!("invalid hex selector '{s}': {e}"))?;
342 let mut arr = [0u8; 4];
343 arr.copy_from_slice(&bytes);
344 Ok(arr)
345 } else {
346 let sig = if s.contains('(') { s.to_string() } else { format!("{s}()") };
347 let hash = keccak256(sig.as_bytes());
348 let mut arr = [0u8; 4];
349 arr.copy_from_slice(&hash[..4]);
350 Ok(arr)
351 }
352}
353
354#[derive(serde::Deserialize)]
356struct JsonCallScope {
357 target: Address,
358 #[serde(default)]
359 selectors: Option<Vec<JsonSelectorEntry>>,
360}
361
362#[derive(serde::Deserialize)]
364#[serde(untagged)]
365enum JsonSelectorEntry {
366 Name(String),
367 WithRecipients { selector: String, recipients: Vec<Address> },
368}
369
370fn parse_scopes_json(s: &str) -> Result<Vec<CallScope>, String> {
372 let entries: Vec<JsonCallScope> =
373 serde_json::from_str(s).map_err(|e| format!("invalid --scopes JSON: {e}"))?;
374
375 let mut scopes = Vec::new();
376 for entry in entries {
377 let selector_rules = match entry.selectors {
378 None => vec![],
379 Some(sels) => {
380 let mut rules = Vec::new();
381 for sel_entry in sels {
382 let (selector_str, recipients) = match sel_entry {
383 JsonSelectorEntry::Name(name) => (name, vec![]),
384 JsonSelectorEntry::WithRecipients { selector, recipients } => {
385 (selector, recipients)
386 }
387 };
388 let selector = parse_selector_bytes(&selector_str)
389 .map_err(|e| format!("in --scopes JSON: {e}"))?;
390 rules.push(SelectorRule { selector: selector.into(), recipients });
391 }
392 rules
393 }
394 };
395 scopes.push(CallScope { target: entry.target, selectorRules: selector_rules });
396 }
397
398 Ok(scopes)
399}
400
401impl KeychainSubcommand {
402 pub async fn run(self) -> Result<()> {
403 match self {
404 Self::List => run_list(),
405 Self::Show { wallet_address } => run_show(wallet_address),
406 Self::Check { wallet_address, key_address, rpc } => {
407 run_check(wallet_address, key_address, rpc).await
408 }
409 Self::Authorize {
410 key_address,
411 key_type,
412 expiry,
413 enforce_limits,
414 limits,
415 scope,
416 scopes_json,
417 tx,
418 send_tx,
419 } => {
420 let all_scopes =
421 if let Some(json_scopes) = scopes_json { json_scopes } else { scope };
422 run_authorize(
423 key_address,
424 key_type,
425 expiry,
426 enforce_limits,
427 limits,
428 all_scopes,
429 tx,
430 send_tx,
431 )
432 .await
433 }
434 Self::Revoke { key_address, tx, send_tx } => run_revoke(key_address, tx, send_tx).await,
435 Self::RemainingLimit { wallet_address, key_address, token, rpc } => {
436 run_remaining_limit(wallet_address, key_address, token, rpc).await
437 }
438 Self::UpdateLimit { key_address, token, new_limit, tx, send_tx } => {
439 run_update_limit(key_address, token, new_limit, tx, send_tx).await
440 }
441 Self::SetScope { key_address, scope, tx, send_tx } => {
442 run_set_scope(key_address, scope, tx, send_tx).await
443 }
444 Self::RemoveScope { key_address, target, tx, send_tx } => {
445 run_remove_scope(key_address, target, tx, send_tx).await
446 }
447 }
448 }
449}
450
451fn run_list() -> Result<()> {
453 let keys_file = load_keys_file()?;
454
455 if keys_file.keys.is_empty() {
456 sh_println!("No keys found in keys.toml.")?;
457 return Ok(());
458 }
459
460 if shell::is_json() {
461 let entries: Vec<_> = keys_file.keys.iter().map(key_entry_to_json).collect();
462 sh_println!("{}", serde_json::to_string_pretty(&entries)?)?;
463 return Ok(());
464 }
465
466 for (i, entry) in keys_file.keys.iter().enumerate() {
467 if i > 0 {
468 sh_println!()?;
469 }
470 print_key_entry(entry)?;
471 }
472
473 Ok(())
474}
475
476fn run_show(wallet_address: Address) -> Result<()> {
478 let keys_file = load_keys_file()?;
479
480 let entries: Vec<_> =
481 keys_file.keys.iter().filter(|e| e.wallet_address == wallet_address).collect();
482
483 if entries.is_empty() {
484 sh_println!("No keys found for wallet {wallet_address}.")?;
485 return Ok(());
486 }
487
488 if shell::is_json() {
489 let json: Vec<_> = entries.iter().map(|e| key_entry_to_json(e)).collect();
490 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
491 return Ok(());
492 }
493
494 for (i, entry) in entries.iter().enumerate() {
495 if i > 0 {
496 sh_println!()?;
497 }
498 print_key_entry(entry)?;
499 }
500
501 Ok(())
502}
503
504async fn run_check(wallet_address: Address, key_address: Address, rpc: RpcOpts) -> Result<()> {
506 let config = rpc.load_config()?;
507 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
508
509 let info: KeyInfo = provider.get_keychain_key(wallet_address, key_address).await?;
510
511 let provisioned = info.keyId != Address::ZERO;
512
513 if shell::is_json() {
514 let json = serde_json::json!({
515 "wallet_address": wallet_address.to_string(),
516 "key_address": key_address.to_string(),
517 "provisioned": provisioned,
518 "signatureType": signature_type_name(&info.signatureType),
519 "key_id": info.keyId.to_string(),
520 "expiry": info.expiry,
521 "expiry_human": format_expiry(info.expiry),
522 "enforce_limits": info.enforceLimits,
523 "is_revoked": info.isRevoked,
524 });
525 sh_println!("{}", serde_json::to_string_pretty(&json)?)?;
526 return Ok(());
527 }
528
529 sh_println!("Wallet: {wallet_address}")?;
530 sh_println!("Key: {key_address}")?;
531
532 if !provisioned {
533 sh_println!("Status: {} not provisioned", "✗".red())?;
534 return Ok(());
535 }
536
537 if info.isRevoked {
539 sh_println!("Status: {} revoked", "✗".red())?;
540 } else {
541 sh_println!("Status: {} active", "✓".green())?;
542 }
543
544 sh_println!("Signature Type: {}", signature_type_name(&info.signatureType))?;
545 sh_println!("Key ID: {}", info.keyId)?;
546
547 let expiry_str = format_expiry(info.expiry);
549 if info.expiry == u64::MAX {
550 sh_println!("Expiry: {}", expiry_str)?;
551 } else {
552 let now = std::time::SystemTime::now()
553 .duration_since(std::time::UNIX_EPOCH)
554 .unwrap_or_default()
555 .as_secs();
556 if info.expiry <= now {
557 sh_println!("Expiry: {} ({})", expiry_str, "expired".red())?;
558 } else {
559 sh_println!("Expiry: {}", expiry_str)?;
560 }
561 }
562
563 sh_println!("Spending Limits: {}", if info.enforceLimits { "enforced" } else { "none" })?;
564
565 Ok(())
566}
567
568#[allow(clippy::too_many_arguments)]
570async fn run_authorize(
571 key_address: Address,
572 key_type: SignatureType,
573 expiry: u64,
574 enforce_limits: bool,
575 limits: Vec<TokenLimit>,
576 allowed_calls: Vec<CallScope>,
577 tx_opts: TransactionOpts,
578 send_tx: SendTxOpts,
579) -> Result<()> {
580 let enforce = enforce_limits || !limits.is_empty();
581
582 let calldata = if allowed_calls.is_empty() {
583 IAccountKeychain::authorizeKeyCall {
585 keyId: key_address,
586 signatureType: key_type,
587 expiry,
588 enforceLimits: enforce,
589 limits,
590 }
591 .abi_encode()
592 } else {
593 let sig_type_u8 = match key_type {
595 SignatureType::Secp256k1 => 0u8,
596 SignatureType::P256 => 1u8,
597 SignatureType::WebAuthn => 2u8,
598 _ => eyre::bail!("unknown signature type"),
599 };
600 let restrictions = KeyRestrictions {
601 expiry,
602 enforceLimits: enforce,
603 limits: limits
604 .into_iter()
605 .map(|l| ExtTokenLimit { token: l.token, amount: l.amount })
606 .collect(),
607 allowAnyCalls: false,
608 allowedCalls: allowed_calls,
609 };
610 authorizeKeyWithRestrictionsCall {
611 keyId: key_address,
612 signatureType: sig_type_u8,
613 config: restrictions,
614 }
615 .abi_encode()
616 };
617
618 send_keychain_tx(calldata, tx_opts, &send_tx).await
619}
620
621async fn run_revoke(
623 key_address: Address,
624 tx_opts: TransactionOpts,
625 send_tx: SendTxOpts,
626) -> Result<()> {
627 let calldata = IAccountKeychain::revokeKeyCall { keyId: key_address }.abi_encode();
628 send_keychain_tx(calldata, tx_opts, &send_tx).await
629}
630
631async fn run_remaining_limit(
633 wallet_address: Address,
634 key_address: Address,
635 token: Address,
636 rpc: RpcOpts,
637) -> Result<()> {
638 let config = rpc.load_config()?;
639 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
640
641 let remaining: U256 =
642 provider.get_keychain_remaining_limit(wallet_address, key_address, token).await?;
643
644 if shell::is_json() {
645 sh_println!("{}", serde_json::to_string(&remaining.to_string())?)?;
646 } else {
647 sh_println!("{remaining}")?;
648 }
649
650 Ok(())
651}
652
653async fn run_update_limit(
655 key_address: Address,
656 token: Address,
657 new_limit: U256,
658 tx_opts: TransactionOpts,
659 send_tx: SendTxOpts,
660) -> Result<()> {
661 let calldata = IAccountKeychain::updateSpendingLimitCall {
662 keyId: key_address,
663 token,
664 newLimit: new_limit,
665 }
666 .abi_encode();
667 send_keychain_tx(calldata, tx_opts, &send_tx).await
668}
669
670async fn run_set_scope(
672 key_address: Address,
673 scopes: Vec<CallScope>,
674 tx_opts: TransactionOpts,
675 send_tx: SendTxOpts,
676) -> Result<()> {
677 let calldata = setAllowedCallsCall { keyId: key_address, scopes }.abi_encode();
678 send_keychain_tx(calldata, tx_opts, &send_tx).await
679}
680
681async fn run_remove_scope(
683 key_address: Address,
684 target: Address,
685 tx_opts: TransactionOpts,
686 send_tx: SendTxOpts,
687) -> Result<()> {
688 let calldata = removeAllowedCallsCall { keyId: key_address, target }.abi_encode();
689 send_keychain_tx(calldata, tx_opts, &send_tx).await
690}
691
692async fn send_keychain_tx(
694 calldata: Vec<u8>,
695 mut tx_opts: TransactionOpts,
696 send_tx: &SendTxOpts,
697) -> Result<()> {
698 let (signer, tempo_access_key) = send_tx.eth.wallet.maybe_signer().await?;
699
700 let config = send_tx.eth.load_config()?;
701 let timeout = send_tx.timeout.unwrap_or(config.transaction_timeout);
702 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
703
704 if let Some(ref ak) = tempo_access_key {
706 tx_opts.tempo.key_id = Some(ak.key_address);
707 }
708
709 let builder = CastTxBuilder::new(&provider, tx_opts, &config)
710 .await?
711 .with_to(Some(NameOrAddress::Address(ACCOUNT_KEYCHAIN_ADDRESS)))
712 .await?
713 .with_code_sig_and_args(None, Some(hex::encode_prefixed(&calldata)), vec![])
714 .await?;
715
716 if let Some(ref ak) = tempo_access_key {
717 let signer = signer.as_ref().expect("signer required for access key");
718 let from = ak.wallet_address;
719 let (tx, _) = builder.build(from).await?;
720
721 let raw_tx = tx
722 .sign_with_access_key(
723 &provider,
724 signer,
725 ak.wallet_address,
726 ak.key_address,
727 ak.key_authorization.as_ref(),
728 )
729 .await?;
730
731 let tx_hash = *provider.send_raw_transaction(&raw_tx).await?.tx_hash();
732 let cast = CastTxSender::new(&provider);
733 cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout).await?;
734 } else {
735 let signer = match signer {
736 Some(s) => s,
737 None => send_tx.eth.wallet.signer().await?,
738 };
739 let from = signer.address();
740 let (tx, _) = builder.build(from).await?;
741
742 let wallet = EthereumWallet::from(signer);
743 let provider = AlloyProviderBuilder::<_, _, TempoNetwork>::default()
744 .wallet(wallet)
745 .connect_provider(&provider);
746
747 let cast = CastTxSender::new(provider);
748 let pending_tx = cast.send(tx).await?;
749 let tx_hash = *pending_tx.inner().tx_hash();
750 cast.print_tx_result(tx_hash, send_tx.cast_async, send_tx.confirmations, timeout).await?;
751 }
752
753 Ok(())
754}
755
756fn format_expiry(expiry: u64) -> String {
757 if expiry == u64::MAX {
758 return "never".to_string();
759 }
760 DateTime::from_timestamp(expiry as i64, 0)
761 .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string())
762 .unwrap_or_else(|| expiry.to_string())
763}
764
765fn load_keys_file() -> Result<KeysFile> {
766 match read_tempo_keys_file() {
767 Some(f) => Ok(f),
768 None => {
769 let path = tempo_keys_path()
770 .map(|p| p.display().to_string())
771 .unwrap_or_else(|| "(unknown)".to_string());
772 eyre::bail!("could not read keys file at {path}")
773 }
774 }
775}
776
777fn print_key_entry(entry: &tempo::KeyEntry) -> Result<()> {
778 sh_println!("Wallet: {}", entry.wallet_address)?;
779 sh_println!("Wallet Type: {}", wallet_type_name(&entry.wallet_type))?;
780 sh_println!("Chain ID: {}", entry.chain_id)?;
781 sh_println!("Key Type: {}", key_type_name(&entry.key_type))?;
782
783 if let Some(key_address) = entry.key_address {
784 sh_println!("Key Address: {key_address}")?;
785
786 if key_address == entry.wallet_address {
787 sh_println!("Mode: direct (EOA)")?;
788 } else {
789 sh_println!("Mode: keychain (access key)")?;
790 }
791 } else {
792 sh_println!("Key Address: (not set)")?;
793 sh_println!("Mode: direct (EOA)")?;
794 }
795
796 if let Some(expiry) = entry.expiry {
797 sh_println!("Expiry: {}", format_expiry(expiry))?;
798 }
799
800 sh_println!("Has Key: {}", entry.has_inline_key())?;
801 sh_println!("Has Auth: {}", entry.key_authorization.is_some())?;
802
803 if !entry.limits.is_empty() {
804 sh_println!("Limits:")?;
805 for limit in &entry.limits {
806 sh_println!(" {} → {}", limit.currency, limit.limit)?;
807 }
808 }
809
810 Ok(())
811}
812
813fn key_entry_to_json(entry: &tempo::KeyEntry) -> serde_json::Value {
814 let is_direct = entry.key_address.is_none() || entry.key_address == Some(entry.wallet_address);
815
816 let limits: Vec<_> = entry
817 .limits
818 .iter()
819 .map(|l| {
820 serde_json::json!({
821 "currency": l.currency.to_string(),
822 "limit": l.limit,
823 })
824 })
825 .collect();
826
827 serde_json::json!({
828 "wallet_address": entry.wallet_address.to_string(),
829 "wallet_type": wallet_type_name(&entry.wallet_type),
830 "chain_id": entry.chain_id,
831 "key_type": key_type_name(&entry.key_type),
832 "key_address": entry.key_address.map(|a: Address| a.to_string()),
833 "mode": if is_direct { "direct" } else { "keychain" },
834 "expiry": entry.expiry,
835 "expiry_human": entry.expiry.map(format_expiry),
836 "has_key": entry.has_inline_key(),
837 "has_authorization": entry.key_authorization.is_some(),
838 "limits": limits,
839 })
840}