Skip to main content

nexus_sdk/nexus/
network_auth.rs

1//! Tool focused helpers for the `nexus_registry::network_auth` package.
2//!
3//! This module supports tool operators and other clients that need to register
4//! or rotate a Tool message signing key on chain, or export a tool side
5//! allowlist for the signed HTTP runtime.
6//!
7//! # Registered state
8//!
9//! `nexus_registry::network_auth` binds an off chain identity, leader address,
10//! or stable Tool ID to an Ed25519 public key used for signed HTTP.
11//!
12//! Registration requires a proof of possession signature:
13//! `POP_DOMAIN || bcs(IdentityKey) || bcs(key_id) || public_key`
14//!
15//! Where `key_id` is the binding current `next_key_id`, which makes each signature single use.
16//!
17//! # Tool runtime
18//!
19//! Tools must not perform RPC calls at runtime. With the `signed_http` feature, a tool operator
20//! can export the typed allowlist data consumed by nexus toolkit.
21
22#[cfg(feature = "signed_http")]
23use crate::signed_http::v3::wire::{
24    AllowedLeaderFileV1,
25    AllowedLeaderKeyFileV1,
26    AllowedLeadersFileV1,
27};
28use {
29    crate::{
30        move_bindings::{
31            registry::{
32                era::V1 as RegistryWitnessV1,
33                network_auth::{
34                    IdentityKey,
35                    KeyBinding,
36                    KeyBindingInnerV1,
37                    KeyRecord,
38                    NetworkAuth,
39                    NetworkAuthInnerV1,
40                },
41            },
42            tool::{
43                era::V1 as ToolWitnessV1,
44                tool_registry::{Tool as ToolAnchor, ToolInnerV1},
45            },
46        },
47        nexus::{
48            client::NexusClient,
49            crawler::{Crawler, Response},
50            error::NexusError,
51            state::StateResolver,
52        },
53        sui,
54        transactions::{self, tool::OffChainToolRegistration},
55        types::{NexusContext, PackageRole, Tool, ToolMeta},
56        ToolFqn,
57    },
58    ed25519_dalek::{Signature, Signer as _, SigningKey},
59    std::sync::Arc,
60};
61
62const POP_DOMAIN_V1: &[u8] = b"nexus_registry.network_auth.pop_v1";
63const KEY_SCHEME_ED25519: u8 = 0;
64
65/// Result of registering a Tool message signing key.
66#[derive(Clone, Debug)]
67pub struct RegisteredToolKey {
68    /// Transaction digest that performed the registration.
69    pub tx_digest: sui::types::Digest,
70    /// Stable on chain [`ToolAnchor`] object ID.
71    pub tool_id: sui::types::Address,
72    /// Registered key ID carried in signed HTTP transport headers.
73    pub tool_kid: u64,
74    /// Registered Ed25519 public key bytes.
75    pub public_key: [u8; 32],
76    /// Deterministic [`KeyBinding`] object ID below [`NetworkAuth`].
77    pub binding_object_id: sui::types::Address,
78}
79
80/// An individual key entry returned by [`NetworkAuthActions::list_tool_keys`].
81#[derive(Clone, Debug)]
82pub struct ToolKeyEntry {
83    /// Key identifier used to select this key in signed HTTP transport headers.
84    pub kid: u64,
85    /// Hex encoded Ed25519 public key.
86    pub public_key_hex: String,
87    /// Millisecond timestamp when the key was added.
88    pub added_at_ms: u64,
89    /// Whether the key has been revoked.
90    pub revoked: bool,
91}
92
93/// All registered keys for a specific tool, returned by [`NetworkAuthActions::list_tool_keys`].
94#[derive(Clone, Debug)]
95pub struct ToolKeyList {
96    /// On chain object ID of the [`KeyBinding`] for this tool.
97    pub binding_object_id: sui::types::Address,
98    /// The currently active key ID, if any.
99    pub active_key_id: Option<u64>,
100    /// The next key ID that will be assigned on the next registration.
101    pub next_key_id: u64,
102    /// All key entries, sorted by kid ascending.
103    pub keys: Vec<ToolKeyEntry>,
104}
105
106/// Active Ed25519 key material resolved from a [`KeyBinding`].
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
108pub struct ActiveEd25519Key {
109    /// Key identifier used by signed HTTP messages.
110    pub kid: u64,
111    /// Exact Ed25519 public key bytes.
112    pub public_key: [u8; 32],
113}
114
115/// A [`KeyBinding`] plus its validated active Ed25519 key, when present.
116pub struct ResolvedKeyBinding {
117    /// Current supported binding state.
118    pub binding: Response<KeyBindingInnerV1>,
119    /// Active supported Ed25519 key, when configured.
120    pub active_key: Option<ActiveEd25519Key>,
121}
122
123/// Wallet backed network authorization operations.
124pub struct NetworkAuthActions {
125    pub(super) client: NexusClient,
126}
127
128impl NetworkAuthActions {
129    /// Derive the deterministic [`KeyBinding`] object ID for a network auth identity.
130    ///
131    /// # Errors
132    ///
133    /// Returns [`NexusError`] when the [`NetworkAuth`] package graph cannot be
134    /// resolved or the binding ID cannot be derived.
135    pub async fn binding_object_id(
136        &self,
137        identity: &IdentityKey,
138    ) -> Result<sui::types::Address, NexusError> {
139        let context = self.network_auth_context().await?;
140        network_auth_codec(&context)?.binding_object_id(identity)
141    }
142
143    /// Registers or rotates a Tool message signing key below [`NetworkAuth`].
144    ///
145    /// The binding witness is the authority source for rotation. A new binding
146    /// instead uses the canonical [`NetworkAuth`] root. In both cases the
147    /// [`ToolAnchor`] state pair must match the selected package graph before a
148    /// transaction is constructed.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`NexusError`] when state or package compatibility cannot be
153    /// established, proof creation fails, or transaction submission fails.
154    pub async fn register_tool_message_key(
155        &self,
156        tool_fqn: ToolFqn,
157        owner_cap_over_tool: sui::types::Address,
158        tool_signing_key: SigningKey,
159        description: Option<Vec<u8>>,
160    ) -> Result<RegisteredToolKey, NexusError> {
161        let address = self.client.owner()?;
162        let objects = self.client.get_nexus_objects();
163        let tool_id =
164            Tool::derive_id(objects.tool_registry.object_id(), &tool_fqn).map_err(|e| {
165                NexusError::Parsing(anyhow::anyhow!(
166                    "failed to derive ToolId for FQN '{tool_fqn}': {e}"
167                ))
168            })?;
169        let identity = IdentityKey::tool(tool_id);
170        let root_context = self.network_auth_context().await?;
171        let binding_object_id = network_auth_codec(&root_context)?.binding_object_id(&identity)?;
172
173        let existing = Self::try_get_key_binding(&self.client, binding_object_id).await?;
174        let (context, binding_ref, next_key_id) = match existing {
175            None => (root_context, None, 0),
176            Some((context, binding)) => {
177                validate_binding_identity(binding_object_id, &binding.data, &identity)?;
178                let next_key_id = binding.data.next_key_id;
179                (context, Some(binding.object_ref()), next_key_id)
180            }
181        };
182
183        let registry_storage = context
184            .require_package(PackageRole::Registry)
185            .map_err(|error| NexusError::Configuration(error.to_string()))?
186            .storage_id;
187        context
188            .require_package(PackageRole::Tool)
189            .map_err(|error| NexusError::IncompatiblePackage {
190                package: registry_storage,
191                reason: error.to_string(),
192            })?;
193        self.client
194            .state_resolver()
195            .validate_state_pair::<ToolAnchor, ToolWitnessV1, ToolInnerV1>(tool_id, &context)
196            .await?;
197
198        let (public_key, pop_sig) = tool_key_material(&identity, next_key_id, &tool_signing_key)?;
199        let (owner_cap_ref, tool) = tokio::try_join!(
200            self.client.object_reference(owner_cap_over_tool),
201            self.client.object_reference(tool_id),
202        )?;
203
204        let tx = match binding_ref {
205            None => transactions::network_auth::create_tool_binding_and_register_key_ptb(
206                &context,
207                &tool,
208                &owner_cap_ref,
209                public_key,
210                pop_sig,
211                description,
212            ),
213            Some(binding_ref) => {
214                transactions::network_auth::register_tool_key_on_existing_binding_ptb(
215                    &context,
216                    &binding_ref,
217                    &tool,
218                    &owner_cap_ref,
219                    public_key,
220                    pop_sig,
221                )
222            }
223        }
224        .map_err(NexusError::TransactionBuilding)?;
225
226        let response = self.client.submit_transaction(tx, address).await?;
227
228        Ok(RegisteredToolKey {
229            tx_digest: response.digest,
230            tool_id,
231            tool_kid: next_key_id,
232            public_key,
233            binding_object_id,
234        })
235    }
236
237    /// Queries every registered message signing key for a Tool FQN.
238    ///
239    /// Returns [`None`] when the Tool has no [`KeyBinding`].
240    ///
241    /// # Errors
242    ///
243    /// Returns [`NexusError`] when root authority, binding state, or key records
244    /// cannot be resolved.
245    pub async fn list_tool_keys(
246        &self,
247        tool_fqn: &ToolFqn,
248    ) -> Result<Option<ToolKeyList>, NexusError> {
249        let objects = self.client.get_nexus_objects();
250        let context = self.network_auth_context().await?;
251        let codec = network_auth_codec(&context)?;
252        let tool_id =
253            Tool::derive_id(objects.tool_registry.object_id(), tool_fqn).map_err(|e| {
254                NexusError::Parsing(anyhow::anyhow!(
255                    "failed to derive ToolId for FQN '{tool_fqn}': {e}"
256                ))
257            })?;
258        let identity = IdentityKey::tool(tool_id);
259        let binding_object_id = codec.binding_object_id(&identity)?;
260
261        let (_, binding) = match Self::try_get_key_binding(&self.client, binding_object_id).await? {
262            None => return Ok(None),
263            Some(binding) => binding,
264        };
265        validate_binding_identity(binding_object_id, &binding.data, &identity)?;
266
267        let key_records = self
268            .client
269            .crawler()
270            .get_dynamic_fields::<u64, KeyRecord>(
271                binding.data.key_table_id(),
272                binding.data.key_table_size(),
273            )
274            .await
275            .map_err(|e| {
276                NexusError::Rpc(anyhow::anyhow!(
277                    "failed to fetch tool key records ({binding_object_id}): {e}"
278                ))
279            })?;
280
281        let mut keys: Vec<ToolKeyEntry> = key_records
282            .into_iter()
283            .map(|(kid, record)| ToolKeyEntry {
284                kid,
285                public_key_hex: hex::encode(&record.public_key),
286                added_at_ms: record.added_at_ms,
287                revoked: record.revoked_at_ms().is_some(),
288            })
289            .collect();
290        keys.sort_by_key(|k| k.kid);
291
292        Ok(Some(ToolKeyList {
293            binding_object_id,
294            active_key_id: binding.data.active_key_id(),
295            next_key_id: binding.data.next_key_id,
296            keys,
297        }))
298    }
299
300    /// Exports the active key for each requested leader.
301    ///
302    /// The returned file model matches [`crate::signed_http::v3::wire::AllowedLeadersFileV1`].
303    ///
304    /// `leader_cap_ids` are leader capability ID values for
305    /// [`crate::move_bindings::registry::leader_cap::OverNetwork`] objects.
306    #[cfg(feature = "signed_http")]
307    pub async fn export_allowed_leaders_file_v1(
308        &self,
309        leader_cap_ids: &[sui::types::Address],
310    ) -> Result<AllowedLeadersFileV1, NexusError> {
311        let context = self.network_auth_context().await?;
312        let codec = network_auth_codec(&context)?;
313
314        let mut out = Vec::with_capacity(leader_cap_ids.len());
315        for leader_cap_id in leader_cap_ids {
316            let identity = IdentityKey::leader(*leader_cap_id);
317            let binding_object_id = codec.binding_object_id(&identity)?;
318            let (_, binding) = Self::try_get_key_binding(&self.client, binding_object_id)
319                .await?
320                .ok_or_else(|| {
321                    NexusError::Rpc(anyhow::anyhow!(
322                        "leader KeyBinding '{binding_object_id}' was not found"
323                    ))
324                })?;
325            validate_binding_identity(binding_object_id, &binding.data, &identity)?;
326
327            let active_kid = binding.data.active_key_id().ok_or_else(|| {
328                NexusError::Parsing(anyhow::anyhow!(
329                    "leader binding {binding_object_id} has no active key"
330                ))
331            })?;
332
333            let record = fetch_key_record(self.client.crawler(), &binding, active_kid).await?;
334            let public_key: [u8; 32] = record.public_key.as_slice().try_into().map_err(|_| {
335                NexusError::Parsing(anyhow::anyhow!(
336                    "leader binding {binding_object_id} active key is not 32 bytes"
337                ))
338            })?;
339
340            if record.scheme != KEY_SCHEME_ED25519 {
341                return Err(NexusError::Parsing(anyhow::anyhow!(
342                    "leader binding {binding_object_id} active key uses unsupported scheme {}",
343                    record.scheme
344                )));
345            }
346
347            out.push(AllowedLeaderFileV1 {
348                leader_id: leader_cap_id.to_string(),
349                keys: vec![AllowedLeaderKeyFileV1 {
350                    kid: active_kid,
351                    public_key: hex::encode(public_key),
352                }],
353            });
354        }
355
356        Ok(AllowedLeadersFileV1 {
357            version: 1,
358            leaders: out,
359        })
360    }
361
362    /// Lists leader capability IDs currently present in [`NetworkAuth`].
363    ///
364    /// # Errors
365    ///
366    /// Returns [`NexusError`] when the root package graph or stored state cannot
367    /// be resolved.
368    pub async fn list_leader_cap_ids_from_network_auth(
369        &self,
370    ) -> Result<Vec<sui::types::Address>, NexusError> {
371        let (_, state) = self.network_auth_state().await?;
372        Ok(sorted_leader_cap_ids(&state.data))
373    }
374
375    /// Exports the active key for every leader identity in [`NetworkAuth`].
376    ///
377    /// Leaders that do not have an active key are skipped.
378    #[cfg(feature = "signed_http")]
379    pub async fn export_allowed_leaders_file_v1_for_all_leaders(
380        &self,
381    ) -> Result<AllowedLeadersFileV1, NexusError> {
382        let (context, state) = self.network_auth_state().await?;
383        let leader_cap_ids = sorted_leader_cap_ids(&state.data);
384        if leader_cap_ids.is_empty() {
385            return Err(NexusError::Parsing(anyhow::anyhow!(
386                "network_auth contains no leader identities"
387            )));
388        }
389
390        let codec = network_auth_codec(&context)?;
391
392        let mut out = Vec::with_capacity(leader_cap_ids.len());
393        for leader_cap_id in leader_cap_ids {
394            if let Some(entry) =
395                Self::export_allowed_leader_entry_file_v1(&self.client, &codec, leader_cap_id)
396                    .await?
397            {
398                out.push(entry);
399            }
400        }
401
402        if out.is_empty() {
403            return Err(NexusError::Parsing(anyhow::anyhow!(
404                "no leaders with an active Ed25519 key were found in network_auth"
405            )));
406        }
407
408        Ok(AllowedLeadersFileV1 {
409            version: 1,
410            leaders: out,
411        })
412    }
413
414    async fn network_auth_context(&self) -> Result<Arc<NexusContext>, NexusError> {
415        let objects = self.client.get_nexus_objects();
416        self.client.context_for_root(&objects.network_auth).await
417    }
418
419    async fn network_auth_state(
420        &self,
421    ) -> Result<(Arc<NexusContext>, Response<NetworkAuthInnerV1>), NexusError> {
422        let context = self.network_auth_context().await?;
423        let object_id = context.network_auth.object_id();
424        let state = self
425            .client
426            .state_resolver()
427            .load_inner::<NetworkAuth, RegistryWitnessV1, NetworkAuthInnerV1>(object_id, &context)
428            .await?;
429        Ok((context, state))
430    }
431
432    async fn try_get_key_binding(
433        client: &NexusClient,
434        binding_object_id: sui::types::Address,
435    ) -> Result<Option<(Arc<NexusContext>, Response<KeyBindingInnerV1>)>, NexusError> {
436        let Some(_) = client
437            .crawler()
438            .get_optional_object::<KeyBinding>(binding_object_id)
439            .await
440            .map_err(NexusError::Rpc)?
441        else {
442            return Ok(None);
443        };
444        let context = client.context_for_object(binding_object_id).await?;
445        let binding = client
446            .state_resolver()
447            .load_inner::<KeyBinding, RegistryWitnessV1, KeyBindingInnerV1>(
448                binding_object_id,
449                &context,
450            )
451            .await?;
452        Ok(Some((context, binding)))
453    }
454
455    #[cfg(feature = "signed_http")]
456    async fn export_allowed_leader_entry_file_v1(
457        client: &NexusClient,
458        codec: &NetworkAuthCodec,
459        leader_cap_id: sui::types::Address,
460    ) -> Result<Option<AllowedLeaderFileV1>, NexusError> {
461        let identity = IdentityKey::leader(leader_cap_id);
462        let binding_object_id = codec.binding_object_id(&identity)?;
463        let (_, binding) = Self::try_get_key_binding(client, binding_object_id)
464            .await?
465            .ok_or_else(|| {
466                NexusError::Rpc(anyhow::anyhow!(
467                    "leader KeyBinding '{binding_object_id}' was not found"
468                ))
469            })?;
470        validate_binding_identity(binding_object_id, &binding.data, &identity)?;
471
472        let Some(active_kid) = binding.data.active_key_id() else {
473            return Ok(None);
474        };
475
476        let record = fetch_key_record(client.crawler(), &binding, active_kid).await?;
477        let public_key: [u8; 32] = record.public_key.as_slice().try_into().map_err(|_| {
478            NexusError::Parsing(anyhow::anyhow!(
479                "leader binding {binding_object_id} active key is not 32 bytes"
480            ))
481        })?;
482
483        if record.scheme != KEY_SCHEME_ED25519 {
484            return Err(NexusError::Parsing(anyhow::anyhow!(
485                "leader binding {binding_object_id} active key uses unsupported scheme {}",
486                record.scheme
487            )));
488        }
489
490        Ok(Some(AllowedLeaderFileV1 {
491            leader_id: leader_cap_id.to_string(),
492            keys: vec![AllowedLeaderKeyFileV1 {
493                kid: active_kid,
494                public_key: hex::encode(public_key),
495            }],
496        }))
497    }
498}
499
500/// Read only access to the on chain [`NetworkAuth`] registry.
501///
502/// Unlike [`NetworkAuthActions`], this type does not require a wallet private
503/// key or gas coins. It supports tool operators that export and periodically
504/// refresh signed HTTP configuration from chain.
505#[derive(Clone)]
506pub struct NetworkAuthReader {
507    crawler: Crawler,
508    state_resolver: StateResolver,
509    registry_type_origin_pkg_id: sui::types::Address,
510    network_auth_object_id: sui::types::Address,
511}
512
513impl NetworkAuthReader {
514    /// Creates a reader for one [`NetworkAuth`] root.
515    ///
516    /// `registry_type_origin_pkg_id` is the package that first defined
517    /// [`IdentityKey`]. It is used only for the stable derived binding ID. Each
518    /// object witness independently selects the package graph used to decode
519    /// its state.
520    pub fn new(
521        crawler: Crawler,
522        registry_type_origin_pkg_id: sui::types::Address,
523        network_auth_object_id: sui::types::Address,
524    ) -> Self {
525        let state_resolver = StateResolver::new(Arc::new(crawler.clone()));
526        Self {
527            crawler,
528            state_resolver,
529            registry_type_origin_pkg_id,
530            network_auth_object_id,
531        }
532    }
533
534    /// Creates a reader that preserves the state catalog configured on
535    /// `client`.
536    ///
537    /// Current objects are read through the client's primary Sui service. An
538    /// indexed catalog configured by
539    /// [`NexusClientBuilder::with_state_catalog_rpc_url`](crate::nexus::client::NexusClientBuilder::with_state_catalog_rpc_url)
540    /// remains available when a state key lineage must be discovered.
541    pub fn from_client(
542        client: &NexusClient,
543        registry_type_origin_pkg_id: sui::types::Address,
544        network_auth_object_id: sui::types::Address,
545    ) -> Self {
546        Self {
547            crawler: client.crawler().clone(),
548            state_resolver: client.state_resolver().clone(),
549            registry_type_origin_pkg_id,
550            network_auth_object_id,
551        }
552    }
553
554    /// Creates a reader backed by the Sui gRPC endpoint at `rpc_url`.
555    ///
556    /// # Errors
557    ///
558    /// Returns [`NexusError::Rpc`] when `rpc_url` is invalid.
559    pub fn from_rpc_url(
560        rpc_url: &str,
561        registry_type_origin_pkg_id: sui::types::Address,
562        network_auth_object_id: sui::types::Address,
563    ) -> Result<Self, NexusError> {
564        let client = sui::grpc::client(rpc_url).map_err(NexusError::Rpc)?;
565        let crawler = Crawler::new(Arc::new(client));
566        Ok(Self::new(
567            crawler,
568            registry_type_origin_pkg_id,
569            network_auth_object_id,
570        ))
571    }
572
573    /// Derives the deterministic [`KeyBinding`] object ID for `identity`.
574    ///
575    /// # Errors
576    ///
577    /// Returns [`NexusError::Parsing`] when the identity cannot be encoded.
578    pub fn binding_object_id(
579        &self,
580        identity: &IdentityKey,
581    ) -> Result<sui::types::Address, NexusError> {
582        NetworkAuthCodec::new(
583            self.registry_type_origin_pkg_id,
584            self.network_auth_object_id,
585        )
586        .binding_object_id(identity)
587    }
588
589    /// Fetches the [`KeyBinding`] for `identity`, when it exists.
590    ///
591    /// # Errors
592    ///
593    /// Returns [`NexusError`] when object authority or stored state cannot be
594    /// resolved.
595    pub async fn try_get_key_binding(
596        &self,
597        identity: &IdentityKey,
598    ) -> Result<Option<Response<KeyBindingInnerV1>>, NexusError> {
599        let binding_object_id = self.binding_object_id(identity)?;
600        try_get_key_binding_by_object_id(&self.crawler, &self.state_resolver, binding_object_id)
601            .await
602    }
603
604    /// Fetches a [`KeyBinding`] and resolves its active Ed25519 key.
605    ///
606    /// # Errors
607    ///
608    /// Returns [`NexusError`] when binding state or its active key record is
609    /// invalid or unavailable.
610    pub async fn try_get_active_key_binding(
611        &self,
612        identity: &IdentityKey,
613    ) -> Result<Option<ResolvedKeyBinding>, NexusError> {
614        let Some(binding) = self.try_get_key_binding(identity).await? else {
615            return Ok(None);
616        };
617        let active_key = try_get_active_ed25519_key(&self.crawler, &binding).await?;
618
619        Ok(Some(ResolvedKeyBinding {
620            binding,
621            active_key,
622        }))
623    }
624
625    /// Lists leader capability IDs currently present in [`NetworkAuth`].
626    ///
627    /// # Errors
628    ///
629    /// Returns [`NexusError`] when root authority or stored state cannot be
630    /// resolved.
631    pub async fn list_leader_cap_ids_from_network_auth(
632        &self,
633    ) -> Result<Vec<sui::types::Address>, NexusError> {
634        let state = self
635            .state_resolver
636            .resolve_and_load_inner::<NetworkAuth, RegistryWitnessV1, NetworkAuthInnerV1>(
637                self.network_auth_object_id,
638            )
639            .await?;
640        Ok(sorted_leader_cap_ids(&state.data))
641    }
642
643    /// Exports the active key for every leader identity in [`NetworkAuth`].
644    ///
645    /// Leaders that do not have an active key are skipped.
646    #[cfg(feature = "signed_http")]
647    pub async fn export_allowed_leaders_file_v1_for_all_leaders(
648        &self,
649    ) -> Result<AllowedLeadersFileV1, NexusError> {
650        let leader_cap_ids = self.list_leader_cap_ids_from_network_auth().await?;
651        if leader_cap_ids.is_empty() {
652            return Err(NexusError::Parsing(anyhow::anyhow!(
653                "network_auth contains no leader identities"
654            )));
655        }
656
657        let codec = NetworkAuthCodec::new(
658            self.registry_type_origin_pkg_id,
659            self.network_auth_object_id,
660        );
661
662        let mut out = Vec::with_capacity(leader_cap_ids.len());
663        for leader_cap_id in leader_cap_ids {
664            if let Some(entry) = self
665                .export_allowed_leader_entry_file_v1(&codec, leader_cap_id)
666                .await?
667            {
668                out.push(entry);
669            }
670        }
671
672        if out.is_empty() {
673            return Err(NexusError::Parsing(anyhow::anyhow!(
674                "no leaders with an active Ed25519 key were found in network_auth"
675            )));
676        }
677
678        Ok(AllowedLeadersFileV1 {
679            version: 1,
680            leaders: out,
681        })
682    }
683
684    #[cfg(feature = "signed_http")]
685    async fn export_allowed_leader_entry_file_v1(
686        &self,
687        codec: &NetworkAuthCodec,
688        leader_cap_id: sui::types::Address,
689    ) -> Result<Option<AllowedLeaderFileV1>, NexusError> {
690        let identity = IdentityKey::leader(leader_cap_id);
691        let Some(binding) = self.try_get_active_key_binding(&identity).await? else {
692            let binding_object_id = codec.binding_object_id(&identity)?;
693            return Err(NexusError::Rpc(anyhow::anyhow!(
694                "failed to fetch leader KeyBinding ({binding_object_id}): not found"
695            )));
696        };
697
698        let Some(active_key) = binding.active_key else {
699            return Ok(None);
700        };
701
702        Ok(Some(AllowedLeaderFileV1 {
703            leader_id: leader_cap_id.to_string(),
704            keys: vec![AllowedLeaderKeyFileV1 {
705                kid: active_key.kid,
706                public_key: hex::encode(active_key.public_key),
707            }],
708        }))
709    }
710}
711
712async fn try_get_key_binding_by_object_id(
713    crawler: &Crawler,
714    state_resolver: &StateResolver,
715    binding_object_id: sui::types::Address,
716) -> Result<Option<Response<KeyBindingInnerV1>>, NexusError> {
717    let Some(_) = crawler
718        .get_optional_object::<KeyBinding>(binding_object_id)
719        .await
720        .map_err(NexusError::Rpc)?
721    else {
722        return Ok(None);
723    };
724
725    state_resolver
726        .resolve_and_load_inner::<KeyBinding, RegistryWitnessV1, KeyBindingInnerV1>(
727            binding_object_id,
728        )
729        .await
730        .map(Some)
731}
732
733fn sorted_leader_cap_ids(state: &NetworkAuthInnerV1) -> Vec<sui::types::Address> {
734    let mut leaders = state.leader_cap_ids().collect::<Vec<_>>();
735    leaders.sort_unstable();
736    leaders.dedup();
737    leaders
738}
739
740async fn try_get_active_ed25519_key(
741    crawler: &Crawler,
742    binding: &Response<KeyBindingInnerV1>,
743) -> Result<Option<ActiveEd25519Key>, NexusError> {
744    let Some(active_kid) = binding.data.active_key_id() else {
745        return Ok(None);
746    };
747
748    let record = fetch_key_record(crawler, binding, active_kid).await?;
749    let public_key: [u8; 32] = record.public_key.as_slice().try_into().map_err(|_| {
750        NexusError::Parsing(anyhow::anyhow!(
751            "key binding {} active key kid={active_kid} is not 32 bytes",
752            binding.object_id
753        ))
754    })?;
755
756    if record.scheme != KEY_SCHEME_ED25519 {
757        return Err(NexusError::Parsing(anyhow::anyhow!(
758            "key binding {} active key kid={active_kid} uses unsupported scheme {}",
759            binding.object_id,
760            record.scheme
761        )));
762    }
763
764    if record.revoked_at_ms().is_some() {
765        return Err(NexusError::Parsing(anyhow::anyhow!(
766            "key binding {} active key kid={active_kid} is revoked",
767            binding.object_id
768        )));
769    }
770
771    Ok(Some(ActiveEd25519Key {
772        kid: active_kid,
773        public_key,
774    }))
775}
776
777async fn fetch_key_record(
778    crawler: &Crawler,
779    binding: &Response<KeyBindingInnerV1>,
780    key_id: u64,
781) -> Result<KeyRecord, NexusError> {
782    crawler
783        .get_dynamic_field_by_key::<u64, KeyRecord>(
784            binding.data.key_table_id(),
785            key_id,
786            &sui::types::TypeTag::U64,
787        )
788        .await
789        .map_err(|error| {
790            NexusError::Rpc(anyhow::anyhow!(
791                "failed to fetch key record ({}, kid={key_id}): {error}",
792                binding.object_id
793            ))
794        })?
795        .ok_or_else(|| {
796            NexusError::Parsing(anyhow::anyhow!(
797                "key binding {} is missing key record kid={key_id}",
798                binding.object_id
799            ))
800        })
801}
802
803fn network_auth_codec(context: &NexusContext) -> Result<NetworkAuthCodec, NexusError> {
804    let registry = context
805        .require_package(PackageRole::Registry)
806        .map_err(|error| NexusError::Configuration(error.to_string()))?;
807    let identity_origin = registry
808        .type_origin("network_auth", "IdentityKey")
809        .map_err(|error| NexusError::IncompatiblePackage {
810            package: registry.storage_id,
811            reason: error.to_string(),
812        })?;
813
814    Ok(NetworkAuthCodec::new(
815        identity_origin,
816        context.network_auth.object_id(),
817    ))
818}
819
820fn validate_binding_identity(
821    binding_object_id: sui::types::Address,
822    binding: &KeyBindingInnerV1,
823    expected: &IdentityKey,
824) -> Result<(), NexusError> {
825    if &binding.identity == expected {
826        return Ok(());
827    }
828
829    Err(NexusError::InvalidObjectState {
830        object: binding_object_id,
831        reason: "stored binding identity does not match its derived object ID".to_owned(),
832    })
833}
834
835/// Internal helper that knows how to compute binding IDs.
836struct NetworkAuthCodec {
837    registry_type_origin_pkg_id: sui::types::Address,
838    network_auth_object_id: sui::types::Address,
839}
840
841impl NetworkAuthCodec {
842    fn new(
843        registry_type_origin_pkg_id: sui::types::Address,
844        network_auth_object_id: sui::types::Address,
845    ) -> Self {
846        Self {
847            registry_type_origin_pkg_id,
848            network_auth_object_id,
849        }
850    }
851
852    fn binding_object_id(&self, identity: &IdentityKey) -> Result<sui::types::Address, NexusError> {
853        crate::move_bindings::derive_network_auth_binding_id(
854            self.registry_type_origin_pkg_id,
855            self.network_auth_object_id,
856            identity,
857        )
858        .map_err(NexusError::Parsing)
859    }
860}
861
862/// Creates an [`OffChainToolRegistration`] whose initial key uses key id zero.
863///
864/// # Errors
865///
866/// Returns [`NexusError::Parsing`] if the stable Tool ID cannot be derived or
867/// the proof cannot be encoded.
868pub fn initial_tool_registration(
869    tool_registry_id: sui::types::Address,
870    meta: ToolMeta,
871    signing_key: &SigningKey,
872    invocation_cost_mist: u64,
873) -> Result<OffChainToolRegistration, NexusError> {
874    let tool_id = Tool::derive_id(tool_registry_id, &meta.fqn).map_err(|error| {
875        NexusError::Parsing(anyhow::anyhow!(
876            "failed to derive Tool ID for FQN '{}': {error}",
877            meta.fqn
878        ))
879    })?;
880    let identity = IdentityKey::tool(tool_id);
881    let (public_key, pop_signature) = tool_key_material(&identity, 0, signing_key)?;
882
883    Ok(OffChainToolRegistration {
884        meta,
885        public_key,
886        pop_signature,
887        invocation_cost_mist,
888    })
889}
890
891fn tool_key_material(
892    identity: &IdentityKey,
893    key_id: u64,
894    signing_key: &SigningKey,
895) -> Result<([u8; 32], [u8; 64]), NexusError> {
896    let public_key = signing_key.verifying_key().to_bytes();
897    let message = pop_message_v1(identity, key_id, public_key)?;
898    Ok((public_key, sign_bytes(signing_key, &message)))
899}
900
901fn pop_message_v1(
902    identity: &IdentityKey,
903    key_id: u64,
904    public_key: [u8; 32],
905) -> Result<Vec<u8>, NexusError> {
906    let mut out = Vec::new();
907    out.extend_from_slice(POP_DOMAIN_V1);
908    out.extend_from_slice(&identity_bcs(identity)?);
909    out.extend_from_slice(&bcs::to_bytes(&key_id).map_err(|error| {
910        NexusError::Parsing(anyhow::anyhow!("failed to BCS encode key_id: {error}"))
911    })?);
912    out.extend_from_slice(&public_key);
913    Ok(out)
914}
915
916fn identity_bcs(identity: &IdentityKey) -> Result<Vec<u8>, NexusError> {
917    bcs::to_bytes(identity)
918        .map_err(|e| NexusError::Parsing(anyhow::anyhow!("failed to BCS-encode IdentityKey: {e}")))
919}
920
921fn sign_bytes(signing_key: &SigningKey, msg: &[u8]) -> [u8; 64] {
922    let sig: Signature = signing_key.sign(msg);
923    sig.to_bytes()
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    #[test]
931    fn binding_object_id_is_deterministic_and_distinct() {
932        let mut rng = rand::thread_rng();
933        let registry_pkg_id = sui::types::Address::generate(&mut rng);
934        let network_auth_object_id = sui::types::Address::generate(&mut rng);
935        let codec = NetworkAuthCodec::new(registry_pkg_id, network_auth_object_id);
936
937        let leader = IdentityKey::leader(sui::types::Address::generate(&mut rng));
938        let tool = IdentityKey::tool(sui::types::Address::generate(&mut rng));
939
940        let leader_id_first = codec.binding_object_id(&leader).unwrap();
941        let leader_id_second = codec.binding_object_id(&leader).unwrap();
942        let tool_id = codec.binding_object_id(&tool).unwrap();
943
944        assert_eq!(leader_id_first, leader_id_second);
945        assert_ne!(leader_id_first, tool_id);
946    }
947
948    #[test]
949    fn binding_object_id_matches_move_derived_object_snapshot() {
950        let registry_pkg_id = "0x1b7beaf7c749f48e8746b2ee2803eaad6303bd353ad967c3e23db50317919beb"
951            .parse()
952            .unwrap();
953        let network_auth_object_id =
954            "0x47fc1741e0f9d0c3a8f573f82fc5c632bc3f3068c325bff24ecb76e4d685b696"
955                .parse()
956                .unwrap();
957        let leader_cap_id = "0x1b7b4eeb8a11033f52b9394b6e284abd6dc33a2a22ff18f678b65d7a909b6eb7"
958            .parse()
959            .unwrap();
960        let expected_binding_id =
961            "0xcd2e634ec159ea299824d23a437992dba70c2a2239cfb7cd16a8ee767b17c040"
962                .parse()
963                .unwrap();
964
965        let codec = NetworkAuthCodec::new(registry_pkg_id, network_auth_object_id);
966        let actual = codec
967            .binding_object_id(&IdentityKey::leader(leader_cap_id))
968            .unwrap();
969
970        assert_eq!(actual, expected_binding_id);
971    }
972
973    #[test]
974    fn pop_message_v1_matches_expected_layout() {
975        let mut rng = rand::thread_rng();
976        let identity = IdentityKey::tool(sui::types::Address::generate(&mut rng));
977        let key_id = 7u64;
978        let public_key = [9u8; 32];
979
980        let msg = pop_message_v1(&identity, key_id, public_key).unwrap();
981
982        let mut expected = Vec::new();
983        expected.extend_from_slice(POP_DOMAIN_V1);
984        expected.extend_from_slice(&identity_bcs(&identity).unwrap());
985        expected.extend_from_slice(&bcs::to_bytes(&key_id).unwrap());
986        expected.extend_from_slice(&public_key);
987
988        assert_eq!(msg, expected);
989    }
990
991    #[test]
992    fn initial_tool_registration_signs_key_zero_for_tool_identity() {
993        let signing_key = SigningKey::from_bytes(&[7u8; 32]);
994        let tool_registry_id = sui::types::Address::from_static("0x42");
995        let meta = ToolMeta {
996            fqn: "xyz.taluslabs.atomic@1".parse().unwrap(),
997            url: "https://example.com/atomic".to_string(),
998            description: "atomic".to_string(),
999            timeout: std::time::Duration::from_secs(1),
1000            input_schema: b"{}".to_vec(),
1001            output_schema: b"{}".to_vec(),
1002        };
1003
1004        let registration =
1005            initial_tool_registration(tool_registry_id, meta.clone(), &signing_key, 9).unwrap();
1006
1007        assert_eq!(registration.meta, meta);
1008        assert_eq!(registration.invocation_cost_mist, 9);
1009        assert_eq!(
1010            registration.public_key,
1011            signing_key.verifying_key().to_bytes()
1012        );
1013
1014        let tool_id = Tool::derive_id(tool_registry_id, &registration.meta.fqn).unwrap();
1015        let identity = IdentityKey::tool(tool_id);
1016        let message = pop_message_v1(&identity, 0, registration.public_key).unwrap();
1017        let signature = Signature::from_bytes(&registration.pop_signature);
1018        signing_key
1019            .verifying_key()
1020            .verify_strict(&message, &signature)
1021            .unwrap();
1022    }
1023
1024    #[test]
1025    fn sign_bytes_produces_valid_signature() {
1026        let key = SigningKey::from_bytes(&[7u8; 32]);
1027        let msg = b"nexus";
1028        let sig = sign_bytes(&key, msg);
1029        let verify_key = key.verifying_key();
1030        let signature = Signature::from_bytes(&sig);
1031        verify_key.verify_strict(msg, &signature).unwrap();
1032    }
1033
1034    #[cfg(feature = "test_utils")]
1035    mod grpc_tests {
1036        use {
1037            super::*,
1038            crate::{
1039                move_bindings::{
1040                    registry::network_auth::KeyRecord,
1041                    sui_framework::{object::UID, table::Table as MoveTable, vec_set::VecSet},
1042                },
1043                test_utils::{nexus_mocks, sui_mocks},
1044            },
1045            serde::Serialize,
1046            tonic::Response,
1047        };
1048
1049        #[derive(Clone, Debug, Serialize)]
1050        struct DynamicFieldValueBcs<K, V> {
1051            id: sui::types::Address,
1052            name: K,
1053            value: V,
1054        }
1055
1056        fn raw_network_auth_for_test(
1057            id: sui::types::Address,
1058            identities: Vec<IdentityKey>,
1059        ) -> (NetworkAuth, NetworkAuthInnerV1) {
1060            (
1061                NetworkAuth::new(UID::new(id)),
1062                NetworkAuthInnerV1::new(
1063                    UID::new(sui::types::Address::from_static("0x123")),
1064                    VecSet {
1065                        contents: identities,
1066                    },
1067                ),
1068            )
1069        }
1070
1071        fn raw_key_binding_for_test(
1072            id: sui::types::Address,
1073            identity: IdentityKey,
1074            next_key_id: u64,
1075            active_key_id: Option<u64>,
1076            keys: MoveTable<u64, KeyRecord>,
1077        ) -> (KeyBinding, KeyBindingInnerV1) {
1078            (
1079                KeyBinding::new(UID::new(id)),
1080                KeyBindingInnerV1::new_for_test(identity, None, next_key_id, active_key_id, keys),
1081            )
1082        }
1083
1084        fn standalone_context(
1085            registry_package: sui::types::Address,
1086            network_auth: sui::types::Address,
1087        ) -> NexusContext {
1088            let mut objects = sui_mocks::mock_nexus_objects();
1089            objects.network_auth = crate::types::SharedRoot::new(network_auth, 1);
1090            let mut packages = sui_mocks::mock_nexus_packages();
1091            let registry = packages.registry.as_mut().unwrap();
1092            let previous_origin = registry.initial_id;
1093            registry.initial_id = registry_package;
1094            registry.storage_id = registry_package;
1095            for datatypes in registry.type_origins.values_mut() {
1096                for origin in datatypes.values_mut() {
1097                    if *origin == previous_origin {
1098                        *origin = registry_package;
1099                    }
1100                }
1101            }
1102            NexusContext::new(Arc::new(objects), packages)
1103        }
1104
1105        fn owner_immutable() -> sui::grpc::Owner {
1106            let mut owner = sui::grpc::Owner::default();
1107            owner.kind = Some(sui::grpc::owner::OwnerKind::Immutable as i32);
1108            owner
1109        }
1110
1111        fn object_with_contents(
1112            object_id: Option<sui::types::Address>,
1113            contents: Vec<u8>,
1114        ) -> sui::grpc::Object {
1115            let mut rng = rand::thread_rng();
1116            let digest = sui::types::Digest::generate(&mut rng);
1117            let mut object = sui::grpc::Object::default();
1118            object.object_id = object_id.map(|id| id.to_string());
1119            object.owner = Some(owner_immutable());
1120            object.digest = Some(digest.to_string());
1121            object.version = Some(1);
1122            let mut bcs = sui::grpc::Bcs::default();
1123            bcs.value = Some(contents.into());
1124            object.contents = Some(bcs);
1125            object
1126        }
1127
1128        fn mock_key_record_field(
1129            ledger_service: &mut sui_mocks::grpc::MockLedgerService,
1130            key_table_id: sui::types::Address,
1131            key_id: u64,
1132            record: KeyRecord,
1133            times: usize,
1134        ) {
1135            let field_id = key_table_id.derive_dynamic_child_id(
1136                &sui::types::TypeTag::U64,
1137                &bcs::to_bytes(&key_id).expect("key ID serializes"),
1138            );
1139            let field = DynamicFieldValueBcs {
1140                id: field_id,
1141                name: key_id,
1142                value: record,
1143            };
1144            let field_bytes = bcs::to_bytes(&field).expect("key record field serializes");
1145
1146            for _ in 0..times {
1147                sui_mocks::grpc::mock_get_object_bcs(
1148                    ledger_service,
1149                    sui_mocks::object_ref_for_id(field_id),
1150                    sui::types::Owner::Object(key_table_id),
1151                    field_bytes.clone(),
1152                );
1153            }
1154        }
1155
1156        async fn build_reader(
1157            registry_pkg_id: sui::types::Address,
1158            network_auth_object_id: sui::types::Address,
1159            leader_cap_id: sui::types::Address,
1160            active_kid: u64,
1161            record: KeyRecord,
1162        ) -> NetworkAuthReader {
1163            let codec = NetworkAuthCodec::new(registry_pkg_id, network_auth_object_id);
1164            let identity = IdentityKey::leader(leader_cap_id);
1165            let binding_object_id = codec.binding_object_id(&identity).unwrap();
1166
1167            let key_table_id = sui::types::Address::from_static("0x111");
1168            let (binding, binding_state) = raw_key_binding_for_test(
1169                binding_object_id,
1170                identity,
1171                active_kid + 1,
1172                Some(active_kid),
1173                MoveTable::new(key_table_id, 1),
1174            );
1175
1176            let context = standalone_context(registry_pkg_id, network_auth_object_id);
1177            let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
1178            let mut package_service = sui_mocks::grpc::MockMovePackageService::new();
1179            let mut state_service = sui_mocks::grpc::MockStateService::new();
1180            sui_mocks::grpc::mock_object_state::<KeyBinding, RegistryWitnessV1, KeyBindingInnerV1>(
1181                &mut ledger_service,
1182                &mut state_service,
1183                &context,
1184                sui_mocks::object_ref_for_id(binding_object_id),
1185                sui::types::Owner::Shared(1),
1186                binding,
1187                binding_state,
1188            );
1189            sui_mocks::grpc::mock_nexus_package_graph(
1190                &mut ledger_service,
1191                &mut package_service,
1192                context.packages(),
1193            );
1194
1195            mock_key_record_field(&mut ledger_service, key_table_id, active_kid, record, 1);
1196
1197            let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
1198                ledger_service_mock: Some(ledger_service),
1199                package_service_mock: Some(package_service),
1200                state_service_mock: Some(state_service),
1201                ..Default::default()
1202            });
1203
1204            NetworkAuthReader::from_rpc_url(&rpc_url, registry_pkg_id, network_auth_object_id)
1205                .unwrap()
1206        }
1207
1208        #[cfg(feature = "signed_http")]
1209        async fn build_reader_with_network_auth(
1210            registry_pkg_id: sui::types::Address,
1211            network_auth_object_id: sui::types::Address,
1212            leader_cap_id: sui::types::Address,
1213            active_kid: u64,
1214            record: KeyRecord,
1215        ) -> NetworkAuthReader {
1216            let codec = NetworkAuthCodec::new(registry_pkg_id, network_auth_object_id);
1217            let identity = IdentityKey::leader(leader_cap_id);
1218            let binding_object_id = codec.binding_object_id(&identity).unwrap();
1219
1220            let key_table_id = sui::types::Address::from_static("0x111");
1221            let (binding, binding_state) = raw_key_binding_for_test(
1222                binding_object_id,
1223                identity.clone(),
1224                active_kid + 1,
1225                Some(active_kid),
1226                MoveTable::new(key_table_id, 1),
1227            );
1228
1229            let (network_auth, network_auth_state) = raw_network_auth_for_test(
1230                network_auth_object_id,
1231                vec![
1232                    identity.clone(),
1233                    IdentityKey::tool(sui::types::Address::from_static("0x42")),
1234                ],
1235            );
1236
1237            let context = standalone_context(registry_pkg_id, network_auth_object_id);
1238            let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
1239            let mut package_service = sui_mocks::grpc::MockMovePackageService::new();
1240            let mut state_service = sui_mocks::grpc::MockStateService::new();
1241            sui_mocks::grpc::mock_object_state::<NetworkAuth, RegistryWitnessV1, NetworkAuthInnerV1>(
1242                &mut ledger_service,
1243                &mut state_service,
1244                &context,
1245                sui_mocks::object_ref_for_id(network_auth_object_id),
1246                sui::types::Owner::Shared(1),
1247                network_auth,
1248                network_auth_state,
1249            );
1250            sui_mocks::grpc::mock_object_state::<KeyBinding, RegistryWitnessV1, KeyBindingInnerV1>(
1251                &mut ledger_service,
1252                &mut state_service,
1253                &context,
1254                sui_mocks::object_ref_for_id(binding_object_id),
1255                sui::types::Owner::Shared(1),
1256                binding,
1257                binding_state,
1258            );
1259            sui_mocks::grpc::mock_nexus_package_graph(
1260                &mut ledger_service,
1261                &mut package_service,
1262                context.packages(),
1263            );
1264
1265            mock_key_record_field(&mut ledger_service, key_table_id, active_kid, record, 1);
1266
1267            let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
1268                ledger_service_mock: Some(ledger_service),
1269                package_service_mock: Some(package_service),
1270                state_service_mock: Some(state_service),
1271                ..Default::default()
1272            });
1273
1274            NetworkAuthReader::from_rpc_url(&rpc_url, registry_pkg_id, network_auth_object_id)
1275                .unwrap()
1276        }
1277
1278        #[tokio::test]
1279        async fn reader_try_get_active_key_binding_returns_validated_active_key() {
1280            let mut rng = rand::thread_rng();
1281            let registry_pkg_id = sui::types::Address::generate(&mut rng);
1282            let network_auth_object_id = sui::types::Address::generate(&mut rng);
1283            let leader_cap_id = sui::types::Address::generate(&mut rng);
1284            let active_kid = 5u64;
1285            let public_key = [7u8; 32];
1286            let reader = build_reader(
1287                registry_pkg_id,
1288                network_auth_object_id,
1289                leader_cap_id,
1290                active_kid,
1291                KeyRecord::new_for_test(0, public_key.to_vec(), 0, None),
1292            )
1293            .await;
1294
1295            let identity = IdentityKey::leader(leader_cap_id);
1296            let resolved = reader
1297                .try_get_active_key_binding(&identity)
1298                .await
1299                .unwrap()
1300                .expect("binding should exist");
1301
1302            assert_eq!(
1303                reader.binding_object_id(&identity).unwrap(),
1304                resolved.binding.object_id
1305            );
1306            assert_eq!(
1307                resolved.active_key,
1308                Some(ActiveEd25519Key {
1309                    kid: active_kid,
1310                    public_key,
1311                })
1312            );
1313        }
1314
1315        #[cfg(feature = "signed_http")]
1316        #[tokio::test]
1317        async fn actions_export_allowlists() {
1318            let mut rng = rand::thread_rng();
1319            let network_auth_object_id = sui::types::Address::generate(&mut rng);
1320            let leader_cap_id = sui::types::Address::generate(&mut rng);
1321            let mut nexus_objects = sui_mocks::mock_nexus_objects();
1322            nexus_objects.network_auth = crate::types::SharedRoot::new(network_auth_object_id, 1);
1323            let context = sui_mocks::mock_nexus_context_for(&nexus_objects);
1324            let registry_pkg_id = context
1325                .require_package(PackageRole::Registry)
1326                .unwrap()
1327                .initial_id;
1328
1329            let codec = NetworkAuthCodec::new(registry_pkg_id, network_auth_object_id);
1330            let identity = IdentityKey::leader(leader_cap_id);
1331            let binding_object_id = codec.binding_object_id(&identity).unwrap();
1332
1333            let active_kid = 3u64;
1334            let public_key = [7u8; 32];
1335            let record = KeyRecord::new_for_test(0, public_key.to_vec(), 0, None);
1336
1337            let key_table_id = sui::types::Address::from_static("0x111");
1338            let (binding, binding_state) = raw_key_binding_for_test(
1339                binding_object_id,
1340                identity.clone(),
1341                active_kid + 1,
1342                Some(active_kid),
1343                MoveTable::new(key_table_id, 1),
1344            );
1345
1346            let (network_auth, network_auth_state) = raw_network_auth_for_test(
1347                network_auth_object_id,
1348                vec![
1349                    identity.clone(),
1350                    IdentityKey::tool(sui::types::Address::from_static("0x42")),
1351                ],
1352            );
1353
1354            let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
1355            let mut package_service = sui_mocks::grpc::MockMovePackageService::new();
1356            let mut state_service = sui_mocks::grpc::MockStateService::new();
1357            sui_mocks::grpc::mock_object_state::<NetworkAuth, RegistryWitnessV1, NetworkAuthInnerV1>(
1358                &mut ledger_service,
1359                &mut state_service,
1360                &context,
1361                sui_mocks::object_ref_for_id(network_auth_object_id),
1362                sui::types::Owner::Shared(1),
1363                network_auth,
1364                network_auth_state,
1365            );
1366            sui_mocks::grpc::mock_object_state::<KeyBinding, RegistryWitnessV1, KeyBindingInnerV1>(
1367                &mut ledger_service,
1368                &mut state_service,
1369                &context,
1370                sui_mocks::object_ref_for_id(binding_object_id),
1371                sui::types::Owner::Shared(1),
1372                binding,
1373                binding_state,
1374            );
1375            sui_mocks::grpc::mock_nexus_package_graph(
1376                &mut ledger_service,
1377                &mut package_service,
1378                context.packages(),
1379            );
1380
1381            mock_key_record_field(&mut ledger_service, key_table_id, active_kid, record, 2);
1382
1383            let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
1384                ledger_service_mock: Some(ledger_service),
1385                package_service_mock: Some(package_service),
1386                state_service_mock: Some(state_service),
1387                ..Default::default()
1388            });
1389
1390            let client =
1391                nexus_mocks::mock_nexus_client_without_coins(&nexus_objects, &rpc_url).await;
1392
1393            let expected_entry = AllowedLeaderFileV1 {
1394                leader_id: leader_cap_id.to_string(),
1395                keys: vec![AllowedLeaderKeyFileV1 {
1396                    kid: active_kid,
1397                    public_key: hex::encode(public_key),
1398                }],
1399            };
1400
1401            let file = client
1402                .network_auth()
1403                .export_allowed_leaders_file_v1(&[leader_cap_id])
1404                .await
1405                .unwrap();
1406            assert_eq!(file.leaders.len(), 1);
1407            assert_eq!(file.leaders[0].leader_id, expected_entry.leader_id);
1408            assert_eq!(file.leaders[0].keys.len(), 1);
1409            assert_eq!(file.leaders[0].keys[0].kid, active_kid);
1410            assert_eq!(file.leaders[0].keys[0].public_key, hex::encode(public_key));
1411
1412            let leaders = client
1413                .network_auth()
1414                .list_leader_cap_ids_from_network_auth()
1415                .await
1416                .unwrap();
1417            assert_eq!(leaders, vec![leader_cap_id]);
1418
1419            let file = client
1420                .network_auth()
1421                .export_allowed_leaders_file_v1_for_all_leaders()
1422                .await
1423                .unwrap();
1424            assert_eq!(file.leaders.len(), 1);
1425            assert_eq!(file.leaders[0].leader_id, expected_entry.leader_id);
1426            assert_eq!(file.leaders[0].keys.len(), 1);
1427            assert_eq!(file.leaders[0].keys[0].kid, active_kid);
1428            assert_eq!(file.leaders[0].keys[0].public_key, hex::encode(public_key));
1429        }
1430
1431        #[cfg(feature = "signed_http")]
1432        #[tokio::test]
1433        async fn reader_export_allowed_leaders_file_v1_for_all_leaders() {
1434            let mut rng = rand::thread_rng();
1435            let registry_pkg_id = sui::types::Address::generate(&mut rng);
1436            let network_auth_object_id = sui::types::Address::generate(&mut rng);
1437            let leader_cap_id = sui::types::Address::generate(&mut rng);
1438
1439            let active_kid = 7u64;
1440            let public_key = [9u8; 32];
1441
1442            let reader = build_reader_with_network_auth(
1443                registry_pkg_id,
1444                network_auth_object_id,
1445                leader_cap_id,
1446                active_kid,
1447                KeyRecord::new_for_test(0, public_key.to_vec(), 0, None),
1448            )
1449            .await;
1450
1451            let allowlist = reader
1452                .export_allowed_leaders_file_v1_for_all_leaders()
1453                .await
1454                .unwrap();
1455            assert_eq!(allowlist.version, 1);
1456            assert_eq!(allowlist.leaders.len(), 1);
1457            assert_eq!(allowlist.leaders[0].leader_id, leader_cap_id.to_string());
1458            assert_eq!(allowlist.leaders[0].keys.len(), 1);
1459            assert_eq!(allowlist.leaders[0].keys[0].kid, active_kid);
1460            assert_eq!(
1461                allowlist.leaders[0].keys[0].public_key,
1462                hex::encode(public_key)
1463            );
1464        }
1465
1466        /// Verifies that `list_tool_keys` returns the correct key list for a tool
1467        /// with an active key and a revoked key, sorted by kid ascending.
1468        /// Guards against regressions in the binding lookup, dynamic field
1469        /// deserialization, key sorting, and revocation flag mapping.
1470        #[tokio::test]
1471        async fn list_tool_keys_returns_sorted_entries() {
1472            let mut rng = rand::thread_rng();
1473            let network_auth_object_id = sui::types::Address::generate(&mut rng);
1474
1475            let tool_fqn_str = "xyz.demo.tool@1";
1476            let tool_fqn: crate::ToolFqn = tool_fqn_str.parse().unwrap();
1477            let tool_registry_id = sui::types::Address::generate(&mut rng);
1478            let mut nexus_objects = sui_mocks::mock_nexus_objects();
1479            nexus_objects.network_auth = crate::types::SharedRoot::new(network_auth_object_id, 1);
1480            nexus_objects.tool_registry = crate::types::SharedRoot::new(tool_registry_id, 1);
1481            let context = sui_mocks::mock_nexus_context_for(&nexus_objects);
1482            let registry_pkg_id = context
1483                .require_package(PackageRole::Registry)
1484                .unwrap()
1485                .initial_id;
1486
1487            let codec = NetworkAuthCodec::new(registry_pkg_id, network_auth_object_id);
1488            let identity = IdentityKey::tool(
1489                Tool::derive_id(tool_registry_id, &tool_fqn).expect("Tool ID derives"),
1490            );
1491            let binding_object_id = codec.binding_object_id(&identity).unwrap();
1492
1493            let key_table_id = sui::types::Address::from_static("0x111");
1494
1495            // Two keys: kid=0 (revoked), kid=1 (active).
1496            let record_0 = KeyRecord::new_for_test(0, vec![0xaau8; 32], 1000, Some(2000));
1497            let record_1 = KeyRecord::new_for_test(0, vec![0xbbu8; 32], 3000, None);
1498
1499            let (binding, binding_state) = raw_key_binding_for_test(
1500                binding_object_id,
1501                identity.clone(),
1502                2,
1503                Some(1),
1504                MoveTable::new(key_table_id, 2),
1505            );
1506
1507            let field_0_id = sui::types::Address::from_static("0x333");
1508            let field_1_id = sui::types::Address::from_static("0x444");
1509
1510            let field_0_value = DynamicFieldValueBcs {
1511                id: sui::types::Address::from_static("0x555"),
1512                name: 0u64,
1513                value: record_0,
1514            };
1515            let field_1_value = DynamicFieldValueBcs {
1516                id: sui::types::Address::from_static("0x666"),
1517                name: 1u64,
1518                value: record_1,
1519            };
1520            let field_0_bytes = bcs::to_bytes(&field_0_value).unwrap();
1521            let field_1_bytes = bcs::to_bytes(&field_1_value).unwrap();
1522
1523            let mut ledger_service = sui_mocks::grpc::MockLedgerService::new();
1524            let mut package_service = sui_mocks::grpc::MockMovePackageService::new();
1525            let mut state_service = sui_mocks::grpc::MockStateService::new();
1526            let (network_auth, network_auth_state) =
1527                raw_network_auth_for_test(network_auth_object_id, vec![]);
1528            sui_mocks::grpc::mock_object_state::<NetworkAuth, RegistryWitnessV1, NetworkAuthInnerV1>(
1529                &mut ledger_service,
1530                &mut state_service,
1531                &context,
1532                sui_mocks::object_ref_for_id(network_auth_object_id),
1533                sui::types::Owner::Shared(1),
1534                network_auth,
1535                network_auth_state,
1536            );
1537
1538            sui_mocks::grpc::mock_object_state::<KeyBinding, RegistryWitnessV1, KeyBindingInnerV1>(
1539                &mut ledger_service,
1540                &mut state_service,
1541                &context,
1542                sui_mocks::object_ref_for_id(binding_object_id),
1543                sui::types::Owner::Shared(1),
1544                binding,
1545                binding_state,
1546            );
1547            sui_mocks::grpc::mock_nexus_package_graph(
1548                &mut ledger_service,
1549                &mut package_service,
1550                context.packages(),
1551            );
1552
1553            // list_dynamic_fields: returns two field entries (kid=0 and kid=1).
1554            // Return kid=1 first to verify the sort.
1555            let expected_key_table = key_table_id.to_string();
1556            state_service
1557                .expect_list_dynamic_fields()
1558                .withf(move |request| {
1559                    request.get_ref().parent.as_deref() == Some(expected_key_table.as_str())
1560                })
1561                .times(1)
1562                .returning(move |_request| {
1563                    let mut df1 = sui::grpc::DynamicField::default();
1564                    df1.set_child_id(field_1_id);
1565                    df1.set_field_id(field_1_id);
1566                    let mut name1 = sui::grpc::Bcs::default();
1567                    name1.value = Some(bcs::to_bytes(&1u64).unwrap().into());
1568                    df1.set_name(name1);
1569
1570                    let mut df0 = sui::grpc::DynamicField::default();
1571                    df0.set_child_id(field_0_id);
1572                    df0.set_field_id(field_0_id);
1573                    let mut name0 = sui::grpc::Bcs::default();
1574                    name0.value = Some(bcs::to_bytes(&0u64).unwrap().into());
1575                    df0.set_name(name0);
1576
1577                    let mut response = sui::grpc::ListDynamicFieldsResponse::default();
1578                    response.dynamic_fields = vec![df1, df0];
1579                    Ok(Response::new(response))
1580                });
1581
1582            // batch_get_objects: returns both field values.
1583            ledger_service
1584                .expect_batch_get_objects()
1585                .times(1)
1586                .returning(move |_request| {
1587                    let obj1 = object_with_contents(Some(field_1_id), field_1_bytes.clone());
1588                    let mut r1 = sui::grpc::GetObjectResult::default();
1589                    r1.result = Some(sui::grpc::get_object_result::Result::Object(obj1));
1590
1591                    let obj0 = object_with_contents(Some(field_0_id), field_0_bytes.clone());
1592                    let mut r0 = sui::grpc::GetObjectResult::default();
1593                    r0.result = Some(sui::grpc::get_object_result::Result::Object(obj0));
1594
1595                    let mut response = sui::grpc::BatchGetObjectsResponse::default();
1596                    response.objects = vec![r1, r0];
1597                    Ok(Response::new(response))
1598                });
1599
1600            let rpc_url = sui_mocks::grpc::mock_server(sui_mocks::grpc::ServerMocks {
1601                ledger_service_mock: Some(ledger_service),
1602                package_service_mock: Some(package_service),
1603                state_service_mock: Some(state_service),
1604                ..Default::default()
1605            });
1606
1607            let client =
1608                nexus_mocks::mock_nexus_client_without_coins(&nexus_objects, &rpc_url).await;
1609
1610            let list = client
1611                .network_auth()
1612                .list_tool_keys(&tool_fqn)
1613                .await
1614                .unwrap()
1615                .expect("binding exists, should return Some");
1616
1617            assert_eq!(list.binding_object_id, binding_object_id);
1618            assert_eq!(list.active_key_id, Some(1));
1619            assert_eq!(list.next_key_id, 2);
1620            assert_eq!(list.keys.len(), 2);
1621
1622            // Sorted by kid ascending.
1623            assert_eq!(list.keys[0].kid, 0);
1624            assert_eq!(list.keys[0].public_key_hex, hex::encode([0xaau8; 32]));
1625            assert_eq!(list.keys[0].added_at_ms, 1000);
1626            assert!(list.keys[0].revoked);
1627
1628            assert_eq!(list.keys[1].kid, 1);
1629            assert_eq!(list.keys[1].public_key_hex, hex::encode([0xbbu8; 32]));
1630            assert_eq!(list.keys[1].added_at_ms, 3000);
1631            assert!(!list.keys[1].revoked);
1632        }
1633    }
1634}