diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index fcf508d..821575e 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -42,6 +42,12 @@ message UnsealEncryptedKey { bytes associated_data = 3; } +message BootstrapEncryptedKey { + bytes nonce = 1; + bytes ciphertext = 2; + bytes associated_data = 3; +} + enum UnsealResult { UNSEAL_RESULT_UNSPECIFIED = 0; UNSEAL_RESULT_SUCCESS = 1; @@ -49,6 +55,13 @@ enum UnsealResult { UNSEAL_RESULT_UNBOOTSTRAPPED = 3; } +enum BootstrapResult { + BOOTSTRAP_RESULT_UNSPECIFIED = 0; + BOOTSTRAP_RESULT_SUCCESS = 1; + BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = 2; + BOOTSTRAP_RESULT_INVALID_KEY = 3; +} + enum VaultState { VAULT_STATE_UNSPECIFIED = 0; VAULT_STATE_UNBOOTSTRAPPED = 1; @@ -80,6 +93,7 @@ message UserAgentRequest { arbiter.evm.EvmGrantDeleteRequest evm_grant_delete = 9; arbiter.evm.EvmGrantListRequest evm_grant_list = 10; ClientConnectionResponse client_connection_response = 11; + BootstrapEncryptedKey bootstrap_encrypted_key = 12; } } message UserAgentResponse { @@ -96,5 +110,6 @@ message UserAgentResponse { arbiter.evm.EvmGrantListResponse evm_grant_list = 10; ClientConnectionRequest client_connection_request = 11; ClientConnectionCancel client_connection_cancel = 12; + BootstrapResult bootstrap_result = 13; } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/session.rs b/server/crates/arbiter-server/src/actors/user_agent/session.rs index 5ef3b20..376273d 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/session.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/session.rs @@ -3,9 +3,9 @@ use std::{ops::DerefMut, sync::Mutex}; use arbiter_proto::proto::{ evm as evm_proto, user_agent::{ - ClientConnectionCancel, ClientConnectionRequest, UnsealEncryptedKey, UnsealResult, - UnsealStart, UnsealStartResponse, UserAgentRequest, UserAgentResponse, - user_agent_request::Payload as UserAgentRequestPayload, + BootstrapEncryptedKey, BootstrapResult, ClientConnectionCancel, ClientConnectionRequest, + UnsealEncryptedKey, UnsealResult, UnsealStart, UnsealStartResponse, UserAgentRequest, + UserAgentResponse, user_agent_request::Payload as UserAgentRequestPayload, user_agent_response::Payload as UserAgentResponsePayload, }, }; @@ -19,7 +19,7 @@ use x25519_dalek::{EphemeralSecret, PublicKey}; use crate::actors::{ evm::{Generate, ListWallets}, - keyholder::{self, TryUnseal}, + keyholder::{self, Bootstrap, TryUnseal}, router::RegisterUserAgent, user_agent::{TransportResponseError, UserAgentConnection}, }; @@ -168,9 +168,11 @@ impl UserAgentSession { UserAgentRequestPayload::UnsealEncryptedKey(unseal_encrypted_key) => { self.handle_unseal_encrypted_key(unseal_encrypted_key).await } - UserAgentRequestPayload::QueryVaultState(_) => { - self.handle_query_vault_state().await + UserAgentRequestPayload::BootstrapEncryptedKey(bootstrap_encrypted_key) => { + self.handle_bootstrap_encrypted_key(bootstrap_encrypted_key) + .await } + UserAgentRequestPayload::QueryVaultState(_) => self.handle_query_vault_state().await, UserAgentRequestPayload::EvmWalletCreate(_) => self.handle_evm_wallet_create().await, UserAgentRequestPayload::EvmWalletList(_) => self.handle_evm_wallet_list().await, _ => Err(TransportResponseError::UnexpectedRequestPayload), @@ -187,6 +189,59 @@ fn response(payload: UserAgentResponsePayload) -> UserAgentResponse { } impl UserAgentSession { + fn take_unseal_secret( + &mut self, + ) -> Result<(EphemeralSecret, PublicKey), TransportResponseError> { + let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else { + error!("Received encrypted key in invalid state"); + return Err(TransportResponseError::InvalidStateForUnsealEncryptedKey); + }; + + let ephemeral_secret = { + let mut secret_lock = unseal_context.secret.lock().unwrap(); + let secret = secret_lock.take(); + match secret { + Some(secret) => secret, + None => { + drop(secret_lock); + error!("Ephemeral secret already taken"); + return Err(TransportResponseError::StateTransitionFailed); + } + } + }; + + Ok((ephemeral_secret, unseal_context.client_public_key)) + } + + fn decrypt_client_key_material( + ephemeral_secret: EphemeralSecret, + client_public_key: PublicKey, + nonce: &[u8], + ciphertext: &[u8], + associated_data: &[u8], + ) -> Result>, ()> { + let nonce = XNonce::from_slice(nonce); + + let shared_secret = ephemeral_secret.diffie_hellman(&client_public_key); + let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into()); + + let mut key_buffer = MemSafe::new(ciphertext.to_vec()).unwrap(); + + let decryption_result = { + let mut write_handle = key_buffer.write().unwrap(); + let write_handle = write_handle.deref_mut(); + cipher.decrypt_in_place(nonce, associated_data, write_handle) + }; + + match decryption_result { + Ok(_) => Ok(key_buffer), + Err(err) => { + error!(?err, "Failed to decrypt encrypted key material"); + Err(()) + } + } + } + async fn handle_unseal_request(&mut self, req: UnsealStart) -> Output { let secret = EphemeralSecret::random(); let public_key = PublicKey::from(&secret); @@ -211,92 +266,140 @@ impl UserAgentSession { } async fn handle_unseal_encrypted_key(&mut self, req: UnsealEncryptedKey) -> Output { - let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else { - error!("Received unseal encrypted key in invalid state"); - return Err(TransportResponseError::InvalidStateForUnsealEncryptedKey); + let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() { + Ok(values) => values, + Err(TransportResponseError::StateTransitionFailed) => { + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + return Ok(response(UserAgentResponsePayload::UnsealResult( + UnsealResult::InvalidKey.into(), + ))); + } + Err(err) => return Err(err), }; - let ephemeral_secret = { - let mut secret_lock = unseal_context.secret.lock().unwrap(); - let secret = secret_lock.take(); - match secret { - Some(secret) => secret, - None => { - drop(secret_lock); - error!("Ephemeral secret already taken"); - self.transition(UserAgentEvents::ReceivedInvalidKey)?; - return Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::InvalidKey.into(), - ))); - } + + let seal_key_buffer = match Self::decrypt_client_key_material( + ephemeral_secret, + client_public_key, + &req.nonce, + &req.ciphertext, + &req.associated_data, + ) { + Ok(buffer) => buffer, + Err(()) => { + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + return Ok(response(UserAgentResponsePayload::UnsealResult( + UnsealResult::InvalidKey.into(), + ))); } }; - let nonce = XNonce::from_slice(&req.nonce); - - let shared_secret = ephemeral_secret.diffie_hellman(&unseal_context.client_public_key); - let cipher = XChaCha20Poly1305::new(shared_secret.as_bytes().into()); - - let mut seal_key_buffer = MemSafe::new(req.ciphertext.clone()).unwrap(); - - let decryption_result = { - let mut write_handle = seal_key_buffer.write().unwrap(); - let write_handle = write_handle.deref_mut(); - cipher.decrypt_in_place(nonce, &req.associated_data, write_handle) - }; - - match decryption_result { + match self + .props + .actors + .key_holder + .ask(TryUnseal { + seal_key_raw: seal_key_buffer, + }) + .await + { Ok(_) => { - match self - .props - .actors - .key_holder - .ask(TryUnseal { - seal_key_raw: seal_key_buffer, - }) - .await - { - Ok(_) => { - info!("Successfully unsealed key with client-provided key"); - self.transition(UserAgentEvents::ReceivedValidKey)?; - Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::Success.into(), - ))) - } - Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => { - self.transition(UserAgentEvents::ReceivedInvalidKey)?; - Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::InvalidKey.into(), - ))) - } - Err(SendError::HandlerError(err)) => { - error!(?err, "Keyholder failed to unseal key"); - self.transition(UserAgentEvents::ReceivedInvalidKey)?; - Ok(response(UserAgentResponsePayload::UnsealResult( - UnsealResult::InvalidKey.into(), - ))) - } - Err(err) => { - error!(?err, "Failed to send unseal request to keyholder"); - self.transition(UserAgentEvents::ReceivedInvalidKey)?; - Err(TransportResponseError::KeyHolderActorUnreachable) - } - } + info!("Successfully unsealed key with client-provided key"); + self.transition(UserAgentEvents::ReceivedValidKey)?; + Ok(response(UserAgentResponsePayload::UnsealResult( + UnsealResult::Success.into(), + ))) } - Err(err) => { - error!(?err, "Failed to decrypt unseal key"); + Err(SendError::HandlerError(keyholder::Error::InvalidKey)) => { self.transition(UserAgentEvents::ReceivedInvalidKey)?; Ok(response(UserAgentResponsePayload::UnsealResult( UnsealResult::InvalidKey.into(), ))) } + Err(SendError::HandlerError(err)) => { + error!(?err, "Keyholder failed to unseal key"); + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + Ok(response(UserAgentResponsePayload::UnsealResult( + UnsealResult::InvalidKey.into(), + ))) + } + Err(err) => { + error!(?err, "Failed to send unseal request to keyholder"); + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + Err(TransportResponseError::KeyHolderActorUnreachable) + } + } + } + + async fn handle_bootstrap_encrypted_key(&mut self, req: BootstrapEncryptedKey) -> Output { + let (ephemeral_secret, client_public_key) = match self.take_unseal_secret() { + Ok(values) => values, + Err(TransportResponseError::StateTransitionFailed) => { + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + return Ok(response(UserAgentResponsePayload::BootstrapResult( + BootstrapResult::InvalidKey.into(), + ))); + } + Err(err) => return Err(err), + }; + + let seal_key_buffer = match Self::decrypt_client_key_material( + ephemeral_secret, + client_public_key, + &req.nonce, + &req.ciphertext, + &req.associated_data, + ) { + Ok(buffer) => buffer, + Err(()) => { + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + return Ok(response(UserAgentResponsePayload::BootstrapResult( + BootstrapResult::InvalidKey.into(), + ))); + } + }; + + match self + .props + .actors + .key_holder + .ask(Bootstrap { + seal_key_raw: seal_key_buffer, + }) + .await + { + Ok(_) => { + info!("Successfully bootstrapped vault with client-provided key"); + self.transition(UserAgentEvents::ReceivedValidKey)?; + Ok(response(UserAgentResponsePayload::BootstrapResult( + BootstrapResult::Success.into(), + ))) + } + Err(SendError::HandlerError(keyholder::Error::AlreadyBootstrapped)) => { + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + Ok(response(UserAgentResponsePayload::BootstrapResult( + BootstrapResult::AlreadyBootstrapped.into(), + ))) + } + Err(SendError::HandlerError(err)) => { + error!(?err, "Keyholder failed to bootstrap vault"); + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + Ok(response(UserAgentResponsePayload::BootstrapResult( + BootstrapResult::InvalidKey.into(), + ))) + } + Err(err) => { + error!(?err, "Failed to send bootstrap request to keyholder"); + self.transition(UserAgentEvents::ReceivedInvalidKey)?; + Err(TransportResponseError::KeyHolderActorUnreachable) + } } } } impl UserAgentSession { async fn handle_query_vault_state(&mut self) -> Output { - use arbiter_proto::proto::user_agent::VaultState; use crate::actors::keyholder::{GetState, StateDiscriminants}; + use arbiter_proto::proto::user_agent::VaultState; let vault_state = match self.props.actors.key_holder.ask(GetState {}).await { Ok(StateDiscriminants::Unbootstrapped) => VaultState::Unbootstrapped, diff --git a/server/crates/arbiter-useragent/src/lib.rs b/server/crates/arbiter-useragent/src/lib.rs index c21b4a3..a9e86bb 100644 --- a/server/crates/arbiter-useragent/src/lib.rs +++ b/server/crates/arbiter-useragent/src/lib.rs @@ -254,12 +254,11 @@ where } mod grpc; -pub use grpc::{connect_grpc, ConnectError, UserAgentGrpc}; +pub use grpc::{ConnectError, UserAgentGrpc, connect_grpc}; use arbiter_proto::proto::user_agent::{ - UnsealEncryptedKey, UnsealStart, - user_agent_request::Payload as RequestPayload, - user_agent_response::Payload as ResponsePayload, + BootstrapEncryptedKey, UnsealEncryptedKey, UnsealStart, + user_agent_request::Payload as RequestPayload, user_agent_response::Payload as ResponsePayload, }; /// Send an `UnsealStart` request and await the server's `UnsealStartResponse`. @@ -274,6 +273,13 @@ pub struct SendUnsealEncryptedKey { pub associated_data: Vec, } +/// Send a `BootstrapEncryptedKey` request and await the server's `BootstrapResult`. +pub struct SendBootstrapEncryptedKey { + pub nonce: Vec, + pub ciphertext: Vec, + pub associated_data: Vec, +} + /// Query the server for the current `VaultState`. pub struct QueryVaultState; @@ -350,6 +356,40 @@ where } } +impl kameo::message::Message for UserAgentActor +where + Transport: Bi, +{ + type Reply = Result; + + async fn handle( + &mut self, + msg: SendBootstrapEncryptedKey, + _ctx: &mut kameo::message::Context, + ) -> Self::Reply { + self.transport + .send(UserAgentRequest { + payload: Some(RequestPayload::BootstrapEncryptedKey( + BootstrapEncryptedKey { + nonce: msg.nonce, + ciphertext: msg.ciphertext, + associated_data: msg.associated_data, + }, + )), + }) + .await + .map_err(|_| SessionError::TransportSendFailed)?; + + match self.transport.recv().await { + Some(resp) => match resp.payload { + Some(ResponsePayload::BootstrapResult(r)) => Ok(r), + _ => Err(SessionError::UnexpectedResponse), + }, + None => Err(SessionError::TransportClosed), + } + } +} + impl kameo::message::Message for UserAgentActor where Transport: Bi, diff --git a/useragent/lib/features/connection/connection.dart b/useragent/lib/features/connection/connection.dart index 4afc46e..4e6436f 100644 --- a/useragent/lib/features/connection/connection.dart +++ b/useragent/lib/features/connection/connection.dart @@ -5,6 +5,7 @@ import 'package:arbiter/features/connection/server_info_storage.dart'; import 'package:arbiter/features/identity/pk_manager.dart'; import 'package:arbiter/proto/arbiter.pbgrpc.dart'; import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:cryptography/cryptography.dart'; import 'package:grpc/grpc.dart'; import 'package:mtcore/markettakers.dart'; @@ -25,9 +26,11 @@ class Connection { } Future receive() async { - await _rx.moveNext(); + final hasValue = await _rx.moveNext(); + if (!hasValue) { + throw Exception('Connection closed while waiting for server response.'); + } return _rx.current; - } Future close() async { @@ -64,6 +67,112 @@ List formatChallenge(AuthChallenge challenge, List pubkey) { return utf8.encode(payload); } +const _vaultKeyAssociatedData = 'arbiter.vault.password'; + +Future bootstrapVault( + Connection connection, + String password, +) async { + final encryptedKey = await _encryptVaultKeyMaterial(connection, password); + + await connection.send( + UserAgentRequest( + bootstrapEncryptedKey: BootstrapEncryptedKey( + nonce: encryptedKey.nonce, + ciphertext: encryptedKey.ciphertext, + associatedData: encryptedKey.associatedData, + ), + ), + ); + + final response = await connection.receive(); + if (!response.hasBootstrapResult()) { + throw Exception( + 'Expected bootstrap result, got ${response.whichPayload()}', + ); + } + + return response.bootstrapResult; +} + +Future unsealVault(Connection connection, String password) async { + final encryptedKey = await _encryptVaultKeyMaterial(connection, password); + + await connection.send( + UserAgentRequest( + unsealEncryptedKey: UnsealEncryptedKey( + nonce: encryptedKey.nonce, + ciphertext: encryptedKey.ciphertext, + associatedData: encryptedKey.associatedData, + ), + ), + ); + + final response = await connection.receive(); + if (!response.hasUnsealResult()) { + throw Exception('Expected unseal result, got ${response.whichPayload()}'); + } + + return response.unsealResult; +} + +Future<_EncryptedVaultKey> _encryptVaultKeyMaterial( + Connection connection, + String password, +) async { + final keyExchange = X25519(); + final cipher = Xchacha20.poly1305Aead(); + final clientKeyPair = await keyExchange.newKeyPair(); + final clientPublicKey = await clientKeyPair.extractPublicKey(); + + await connection.send( + UserAgentRequest( + unsealStart: UnsealStart(clientPubkey: clientPublicKey.bytes), + ), + ); + + final handshakeResponse = await connection.receive(); + if (!handshakeResponse.hasUnsealStartResponse()) { + throw Exception( + 'Expected unseal handshake response, got ${handshakeResponse.whichPayload()}', + ); + } + + final serverPublicKey = SimplePublicKey( + handshakeResponse.unsealStartResponse.serverPubkey, + type: KeyPairType.x25519, + ); + final sharedSecret = await keyExchange.sharedSecretKey( + keyPair: clientKeyPair, + remotePublicKey: serverPublicKey, + ); + + final secretBox = await cipher.encrypt( + utf8.encode(password), + secretKey: sharedSecret, + nonce: cipher.newNonce(), + aad: utf8.encode(_vaultKeyAssociatedData), + ); + + return _EncryptedVaultKey( + nonce: secretBox.nonce, + ciphertext: [...secretBox.cipherText, ...secretBox.mac.bytes], + associatedData: utf8.encode(_vaultKeyAssociatedData), + ); +} + +class _EncryptedVaultKey { + const _EncryptedVaultKey({ + required this.nonce, + required this.ciphertext, + required this.associatedData, + }); + + final List nonce; + final List ciphertext; + final List associatedData; +} + Future connectAndAuthorize( StoredServerInfo serverInfo, KeyHandle key, { @@ -90,12 +199,14 @@ Future connectAndAuthorize( "Sent auth challenge request with pubkey ${base64Encode(pubkey)}", ); - final response = await connection.receive(); - talker.info( - 'Received response from server, checking for auth challenge...', - ); + talker.info('Received response from server, checking auth flow...'); + + if (response.hasAuthOk()) { + talker.info('Authentication successful, connection established'); + return connection; + } if (!response.hasAuthChallenge()) { throw Exception( diff --git a/useragent/lib/proto/user_agent.pb.dart b/useragent/lib/proto/user_agent.pb.dart index f73a61e..e7e96a1 100644 --- a/useragent/lib/proto/user_agent.pb.dart +++ b/useragent/lib/proto/user_agent.pb.dart @@ -460,6 +460,89 @@ class UnsealEncryptedKey extends $pb.GeneratedMessage { void clearAssociatedData() => $_clearField(3); } +class BootstrapEncryptedKey extends $pb.GeneratedMessage { + factory BootstrapEncryptedKey({ + $core.List<$core.int>? nonce, + $core.List<$core.int>? ciphertext, + $core.List<$core.int>? associatedData, + }) { + final result = create(); + if (nonce != null) result.nonce = nonce; + if (ciphertext != null) result.ciphertext = ciphertext; + if (associatedData != null) result.associatedData = associatedData; + return result; + } + + BootstrapEncryptedKey._(); + + factory BootstrapEncryptedKey.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory BootstrapEncryptedKey.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'BootstrapEncryptedKey', + package: + const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), + createEmptyInstance: create) + ..a<$core.List<$core.int>>( + 1, _omitFieldNames ? '' : 'nonce', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>( + 2, _omitFieldNames ? '' : 'ciphertext', $pb.PbFieldType.OY) + ..a<$core.List<$core.int>>( + 3, _omitFieldNames ? '' : 'associatedData', $pb.PbFieldType.OY) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BootstrapEncryptedKey clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + BootstrapEncryptedKey copyWith( + void Function(BootstrapEncryptedKey) updates) => + super.copyWith((message) => updates(message as BootstrapEncryptedKey)) + as BootstrapEncryptedKey; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static BootstrapEncryptedKey create() => BootstrapEncryptedKey._(); + @$core.override + BootstrapEncryptedKey createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static BootstrapEncryptedKey getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static BootstrapEncryptedKey? _defaultInstance; + + @$pb.TagNumber(1) + $core.List<$core.int> get nonce => $_getN(0); + @$pb.TagNumber(1) + set nonce($core.List<$core.int> value) => $_setBytes(0, value); + @$pb.TagNumber(1) + $core.bool hasNonce() => $_has(0); + @$pb.TagNumber(1) + void clearNonce() => $_clearField(1); + + @$pb.TagNumber(2) + $core.List<$core.int> get ciphertext => $_getN(1); + @$pb.TagNumber(2) + set ciphertext($core.List<$core.int> value) => $_setBytes(1, value); + @$pb.TagNumber(2) + $core.bool hasCiphertext() => $_has(1); + @$pb.TagNumber(2) + void clearCiphertext() => $_clearField(2); + + @$pb.TagNumber(3) + $core.List<$core.int> get associatedData => $_getN(2); + @$pb.TagNumber(3) + set associatedData($core.List<$core.int> value) => $_setBytes(2, value); + @$pb.TagNumber(3) + $core.bool hasAssociatedData() => $_has(2); + @$pb.TagNumber(3) + void clearAssociatedData() => $_clearField(3); +} + class ClientConnectionRequest extends $pb.GeneratedMessage { factory ClientConnectionRequest({ $core.List<$core.int>? pubkey, @@ -625,6 +708,7 @@ enum UserAgentRequest_Payload { evmGrantDelete, evmGrantList, clientConnectionResponse, + bootstrapEncryptedKey, notSet } @@ -641,6 +725,7 @@ class UserAgentRequest extends $pb.GeneratedMessage { $1.EvmGrantDeleteRequest? evmGrantDelete, $1.EvmGrantListRequest? evmGrantList, ClientConnectionResponse? clientConnectionResponse, + BootstrapEncryptedKey? bootstrapEncryptedKey, }) { final result = create(); if (authChallengeRequest != null) @@ -658,6 +743,8 @@ class UserAgentRequest extends $pb.GeneratedMessage { if (evmGrantList != null) result.evmGrantList = evmGrantList; if (clientConnectionResponse != null) result.clientConnectionResponse = clientConnectionResponse; + if (bootstrapEncryptedKey != null) + result.bootstrapEncryptedKey = bootstrapEncryptedKey; return result; } @@ -683,6 +770,7 @@ class UserAgentRequest extends $pb.GeneratedMessage { 9: UserAgentRequest_Payload.evmGrantDelete, 10: UserAgentRequest_Payload.evmGrantList, 11: UserAgentRequest_Payload.clientConnectionResponse, + 12: UserAgentRequest_Payload.bootstrapEncryptedKey, 0: UserAgentRequest_Payload.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -690,7 +778,7 @@ class UserAgentRequest extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) ..aOM( 1, _omitFieldNames ? '' : 'authChallengeRequest', subBuilder: AuthChallengeRequest.create) @@ -716,6 +804,9 @@ class UserAgentRequest extends $pb.GeneratedMessage { ..aOM( 11, _omitFieldNames ? '' : 'clientConnectionResponse', subBuilder: ClientConnectionResponse.create) + ..aOM( + 12, _omitFieldNames ? '' : 'bootstrapEncryptedKey', + subBuilder: BootstrapEncryptedKey.create) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -748,6 +839,7 @@ class UserAgentRequest extends $pb.GeneratedMessage { @$pb.TagNumber(9) @$pb.TagNumber(10) @$pb.TagNumber(11) + @$pb.TagNumber(12) UserAgentRequest_Payload whichPayload() => _UserAgentRequest_PayloadByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @@ -761,6 +853,7 @@ class UserAgentRequest extends $pb.GeneratedMessage { @$pb.TagNumber(9) @$pb.TagNumber(10) @$pb.TagNumber(11) + @$pb.TagNumber(12) void clearPayload() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -885,6 +978,18 @@ class UserAgentRequest extends $pb.GeneratedMessage { void clearClientConnectionResponse() => $_clearField(11); @$pb.TagNumber(11) ClientConnectionResponse ensureClientConnectionResponse() => $_ensure(10); + + @$pb.TagNumber(12) + BootstrapEncryptedKey get bootstrapEncryptedKey => $_getN(11); + @$pb.TagNumber(12) + set bootstrapEncryptedKey(BootstrapEncryptedKey value) => + $_setField(12, value); + @$pb.TagNumber(12) + $core.bool hasBootstrapEncryptedKey() => $_has(11); + @$pb.TagNumber(12) + void clearBootstrapEncryptedKey() => $_clearField(12); + @$pb.TagNumber(12) + BootstrapEncryptedKey ensureBootstrapEncryptedKey() => $_ensure(11); } enum UserAgentResponse_Payload { @@ -900,6 +1005,7 @@ enum UserAgentResponse_Payload { evmGrantList, clientConnectionRequest, clientConnectionCancel, + bootstrapResult, notSet } @@ -917,6 +1023,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { $1.EvmGrantListResponse? evmGrantList, ClientConnectionRequest? clientConnectionRequest, ClientConnectionCancel? clientConnectionCancel, + BootstrapResult? bootstrapResult, }) { final result = create(); if (authChallenge != null) result.authChallenge = authChallenge; @@ -934,6 +1041,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { result.clientConnectionRequest = clientConnectionRequest; if (clientConnectionCancel != null) result.clientConnectionCancel = clientConnectionCancel; + if (bootstrapResult != null) result.bootstrapResult = bootstrapResult; return result; } @@ -960,6 +1068,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { 10: UserAgentResponse_Payload.evmGrantList, 11: UserAgentResponse_Payload.clientConnectionRequest, 12: UserAgentResponse_Payload.clientConnectionCancel, + 13: UserAgentResponse_Payload.bootstrapResult, 0: UserAgentResponse_Payload.notSet }; static final $pb.BuilderInfo _i = $pb.BuilderInfo( @@ -967,7 +1076,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { package: const $pb.PackageName(_omitMessageNames ? '' : 'arbiter.user_agent'), createEmptyInstance: create) - ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) + ..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]) ..aOM(1, _omitFieldNames ? '' : 'authChallenge', subBuilder: AuthChallenge.create) ..aOM(2, _omitFieldNames ? '' : 'authOk', subBuilder: AuthOk.create) @@ -993,6 +1102,8 @@ class UserAgentResponse extends $pb.GeneratedMessage { ..aOM( 12, _omitFieldNames ? '' : 'clientConnectionCancel', subBuilder: ClientConnectionCancel.create) + ..aE(13, _omitFieldNames ? '' : 'bootstrapResult', + enumValues: BootstrapResult.values) ..hasRequiredFields = false; @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') @@ -1026,6 +1137,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { @$pb.TagNumber(10) @$pb.TagNumber(11) @$pb.TagNumber(12) + @$pb.TagNumber(13) UserAgentResponse_Payload whichPayload() => _UserAgentResponse_PayloadByTag[$_whichOneof(0)]!; @$pb.TagNumber(1) @@ -1040,6 +1152,7 @@ class UserAgentResponse extends $pb.GeneratedMessage { @$pb.TagNumber(10) @$pb.TagNumber(11) @$pb.TagNumber(12) + @$pb.TagNumber(13) void clearPayload() => $_clearField($_whichOneof(0)); @$pb.TagNumber(1) @@ -1171,6 +1284,15 @@ class UserAgentResponse extends $pb.GeneratedMessage { void clearClientConnectionCancel() => $_clearField(12); @$pb.TagNumber(12) ClientConnectionCancel ensureClientConnectionCancel() => $_ensure(11); + + @$pb.TagNumber(13) + BootstrapResult get bootstrapResult => $_getN(12); + @$pb.TagNumber(13) + set bootstrapResult(BootstrapResult value) => $_setField(13, value); + @$pb.TagNumber(13) + $core.bool hasBootstrapResult() => $_has(12); + @$pb.TagNumber(13) + void clearBootstrapResult() => $_clearField(13); } const $core.bool _omitFieldNames = diff --git a/useragent/lib/proto/user_agent.pbenum.dart b/useragent/lib/proto/user_agent.pbenum.dart index 9ceaae4..41b6c7e 100644 --- a/useragent/lib/proto/user_agent.pbenum.dart +++ b/useragent/lib/proto/user_agent.pbenum.dart @@ -64,6 +64,31 @@ class UnsealResult extends $pb.ProtobufEnum { const UnsealResult._(super.value, super.name); } +class BootstrapResult extends $pb.ProtobufEnum { + static const BootstrapResult BOOTSTRAP_RESULT_UNSPECIFIED = + BootstrapResult._(0, _omitEnumNames ? '' : 'BOOTSTRAP_RESULT_UNSPECIFIED'); + static const BootstrapResult BOOTSTRAP_RESULT_SUCCESS = + BootstrapResult._(1, _omitEnumNames ? '' : 'BOOTSTRAP_RESULT_SUCCESS'); + static const BootstrapResult BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED = + BootstrapResult._(2, _omitEnumNames ? '' : 'BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED'); + static const BootstrapResult BOOTSTRAP_RESULT_INVALID_KEY = + BootstrapResult._(3, _omitEnumNames ? '' : 'BOOTSTRAP_RESULT_INVALID_KEY'); + + static const $core.List values = [ + BOOTSTRAP_RESULT_UNSPECIFIED, + BOOTSTRAP_RESULT_SUCCESS, + BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED, + BOOTSTRAP_RESULT_INVALID_KEY, + ]; + + static final $core.List _byValue = + $pb.ProtobufEnum.$_initByValueList(values, 3); + static BootstrapResult? valueOf($core.int value) => + value < 0 || value >= _byValue.length ? null : _byValue[value]; + + const BootstrapResult._(super.value, super.name); +} + class VaultState extends $pb.ProtobufEnum { static const VaultState VAULT_STATE_UNSPECIFIED = VaultState._(0, _omitEnumNames ? '' : 'VAULT_STATE_UNSPECIFIED'); diff --git a/useragent/lib/providers/connection/bootstrap_token.dart b/useragent/lib/providers/connection/bootstrap_token.dart index 1062f31..48b8291 100644 --- a/useragent/lib/providers/connection/bootstrap_token.dart +++ b/useragent/lib/providers/connection/bootstrap_token.dart @@ -6,6 +6,7 @@ part 'bootstrap_token.g.dart'; @Riverpod(keepAlive: true) class BootstrapToken extends _$BootstrapToken { + @override String? build() { return null; } @@ -14,9 +15,13 @@ class BootstrapToken extends _$BootstrapToken { state = token; } + void clear() { + state = null; + } + String? take() { final token = state; state = null; return token; } -} \ No newline at end of file +} diff --git a/useragent/lib/providers/connection/bootstrap_token.g.dart b/useragent/lib/providers/connection/bootstrap_token.g.dart index 4477998..0c46387 100644 --- a/useragent/lib/providers/connection/bootstrap_token.g.dart +++ b/useragent/lib/providers/connection/bootstrap_token.g.dart @@ -41,7 +41,7 @@ final class BootstrapTokenProvider } } -String _$bootstrapTokenHash() => r'a59e679ab0561ed2ab4148660499891571d439db'; +String _$bootstrapTokenHash() => r'5c09ea4480fc3a7fd0d0a0bced712912542cca5d'; abstract class _$BootstrapToken extends $Notifier { String? build(); diff --git a/useragent/lib/providers/connection/connection_manager.dart b/useragent/lib/providers/connection/connection_manager.dart index c1e5266..08f8d0f 100644 --- a/useragent/lib/providers/connection/connection_manager.dart +++ b/useragent/lib/providers/connection/connection_manager.dart @@ -20,12 +20,18 @@ class ConnectionManager extends _$ConnectionManager { } final Connection connection; try { - connection = await connectAndAuthorize(serverInfo, key, bootstrapToken: token); + connection = await connectAndAuthorize( + serverInfo, + key, + bootstrapToken: token, + ); + if (token != null) { + ref.read(bootstrapTokenProvider.notifier).clear(); + } } catch (e) { talker.handle(e); rethrow; } - ref.onDispose(() { final connection = state.asData?.value; diff --git a/useragent/lib/providers/connection/connection_manager.g.dart b/useragent/lib/providers/connection/connection_manager.g.dart index 3646316..4869eff 100644 --- a/useragent/lib/providers/connection/connection_manager.g.dart +++ b/useragent/lib/providers/connection/connection_manager.g.dart @@ -33,7 +33,7 @@ final class ConnectionManagerProvider ConnectionManager create() => ConnectionManager(); } -String _$connectionManagerHash() => r'8923346dff75a9a06127c71a0a39ca65d9733d8c'; +String _$connectionManagerHash() => r'd01084e550f315bc6cadfe74413a7f959426a80e'; abstract class _$ConnectionManager extends $AsyncNotifier { FutureOr build(); diff --git a/useragent/lib/providers/evm.dart b/useragent/lib/providers/evm.dart new file mode 100644 index 0000000..17734f4 --- /dev/null +++ b/useragent/lib/providers/evm.dart @@ -0,0 +1,23 @@ +import 'package:arbiter/proto/evm.pb.dart'; +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'evm.g.dart'; + +@riverpod +class Evm extends _$Evm { + Future?> build() async { + final connection = await ref.watch(connectionManagerProvider.future); + if (connection == null) { + return null; + } + + await connection.send(UserAgentRequest( + evmWalletList: Empty() + )); + + final response = await connection.receive(); + } +} diff --git a/useragent/lib/providers/evm.g.dart b/useragent/lib/providers/evm.g.dart new file mode 100644 index 0000000..60eca52 --- /dev/null +++ b/useragent/lib/providers/evm.g.dart @@ -0,0 +1,55 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'evm.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(Evm) +final evmProvider = EvmProvider._(); + +final class EvmProvider + extends $AsyncNotifierProvider?> { + EvmProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'evmProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$evmHash(); + + @$internal + @override + Evm create() => Evm(); +} + +String _$evmHash() => r'6d2e0baf7b78a0850d7b99b0be7abde206e088c7'; + +abstract class _$Evm extends $AsyncNotifier?> { + FutureOr?> build(); + @$mustCallSuper + @override + void runBuild() { + final ref = + this.ref as $Ref?>, List?>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier?>, List?>, + AsyncValue?>, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/useragent/lib/providers/vault_state.dart b/useragent/lib/providers/vault_state.dart new file mode 100644 index 0000000..de02c5e --- /dev/null +++ b/useragent/lib/providers/vault_state.dart @@ -0,0 +1,27 @@ +import 'package:arbiter/proto/user_agent.pb.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:mtcore/markettakers.dart'; +import 'package:protobuf/well_known_types/google/protobuf/empty.pb.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'vault_state.g.dart'; + +@riverpod +Future vaultState(Ref ref) async { + final conn = await ref.watch(connectionManagerProvider.future); + if (conn == null) { + return null; + } + + await conn.send(UserAgentRequest(queryVaultState: Empty())); + + final resp = await conn.receive(); + if (resp.whichPayload() != UserAgentResponse_Payload.vaultState) { + talker.warning('Expected vault state response, got ${resp.whichPayload()}'); + return null; + } + + final vaultState = resp.vaultState; + + return vaultState; +} diff --git a/useragent/lib/providers/vault_state.g.dart b/useragent/lib/providers/vault_state.g.dart new file mode 100644 index 0000000..aa4f953 --- /dev/null +++ b/useragent/lib/providers/vault_state.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'vault_state.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(vaultState) +final vaultStateProvider = VaultStateProvider._(); + +final class VaultStateProvider + extends + $FunctionalProvider< + AsyncValue, + VaultState?, + FutureOr + > + with $FutureModifier, $FutureProvider { + VaultStateProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'vaultStateProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$vaultStateHash(); + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + return vaultState(ref); + } +} + +String _$vaultStateHash() => r'1fd975a9661de1f62beef9eb1c7c439f377a8b88'; diff --git a/useragent/lib/router.dart b/useragent/lib/router.dart index b809a4b..78f14a2 100644 --- a/useragent/lib/router.dart +++ b/useragent/lib/router.dart @@ -9,6 +9,7 @@ class Router extends RootStackRouter { AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true), AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'), AutoRoute(page: ServerConnectionRoute.page, path: '/server-connection'), + AutoRoute(page: VaultSetupRoute.page, path: '/vault'), AutoRoute( page: DashboardRouter.page, diff --git a/useragent/lib/router.gr.dart b/useragent/lib/router.gr.dart index e1b93c6..aa123e8 100644 --- a/useragent/lib/router.gr.dart +++ b/useragent/lib/router.gr.dart @@ -15,18 +15,19 @@ import 'package:arbiter/screens/dashboard/about.dart' as _i1; import 'package:arbiter/screens/dashboard/calc.dart' as _i3; import 'package:arbiter/screens/server_connection.dart' as _i5; import 'package:arbiter/screens/server_info_setup.dart' as _i6; -import 'package:auto_route/auto_route.dart' as _i7; -import 'package:flutter/material.dart' as _i8; +import 'package:arbiter/screens/vault_setup.dart' as _i7; +import 'package:auto_route/auto_route.dart' as _i8; +import 'package:flutter/material.dart' as _i9; /// generated route for /// [_i1.AboutScreen] -class AboutRoute extends _i7.PageRouteInfo { - const AboutRoute({List<_i7.PageRouteInfo>? children}) +class AboutRoute extends _i8.PageRouteInfo { + const AboutRoute({List<_i8.PageRouteInfo>? children}) : super(AboutRoute.name, initialChildren: children); static const String name = 'AboutRoute'; - static _i7.PageInfo page = _i7.PageInfo( + static _i8.PageInfo page = _i8.PageInfo( name, builder: (data) { return const _i1.AboutScreen(); @@ -36,13 +37,13 @@ class AboutRoute extends _i7.PageRouteInfo { /// generated route for /// [_i2.Bootstrap] -class Bootstrap extends _i7.PageRouteInfo { - const Bootstrap({List<_i7.PageRouteInfo>? children}) +class Bootstrap extends _i8.PageRouteInfo { + const Bootstrap({List<_i8.PageRouteInfo>? children}) : super(Bootstrap.name, initialChildren: children); static const String name = 'Bootstrap'; - static _i7.PageInfo page = _i7.PageInfo( + static _i8.PageInfo page = _i8.PageInfo( name, builder: (data) { return const _i2.Bootstrap(); @@ -52,13 +53,13 @@ class Bootstrap extends _i7.PageRouteInfo { /// generated route for /// [_i3.CalcScreen] -class CalcRoute extends _i7.PageRouteInfo { - const CalcRoute({List<_i7.PageRouteInfo>? children}) +class CalcRoute extends _i8.PageRouteInfo { + const CalcRoute({List<_i8.PageRouteInfo>? children}) : super(CalcRoute.name, initialChildren: children); static const String name = 'CalcRoute'; - static _i7.PageInfo page = _i7.PageInfo( + static _i8.PageInfo page = _i8.PageInfo( name, builder: (data) { return const _i3.CalcScreen(); @@ -68,13 +69,13 @@ class CalcRoute extends _i7.PageRouteInfo { /// generated route for /// [_i4.DashboardRouter] -class DashboardRouter extends _i7.PageRouteInfo { - const DashboardRouter({List<_i7.PageRouteInfo>? children}) +class DashboardRouter extends _i8.PageRouteInfo { + const DashboardRouter({List<_i8.PageRouteInfo>? children}) : super(DashboardRouter.name, initialChildren: children); static const String name = 'DashboardRouter'; - static _i7.PageInfo page = _i7.PageInfo( + static _i8.PageInfo page = _i8.PageInfo( name, builder: (data) { return const _i4.DashboardRouter(); @@ -85,11 +86,11 @@ class DashboardRouter extends _i7.PageRouteInfo { /// generated route for /// [_i5.ServerConnectionScreen] class ServerConnectionRoute - extends _i7.PageRouteInfo { + extends _i8.PageRouteInfo { ServerConnectionRoute({ - _i8.Key? key, + _i9.Key? key, String? arbiterUrl, - List<_i7.PageRouteInfo>? children, + List<_i8.PageRouteInfo>? children, }) : super( ServerConnectionRoute.name, args: ServerConnectionRouteArgs(key: key, arbiterUrl: arbiterUrl), @@ -98,7 +99,7 @@ class ServerConnectionRoute static const String name = 'ServerConnectionRoute'; - static _i7.PageInfo page = _i7.PageInfo( + static _i8.PageInfo page = _i8.PageInfo( name, builder: (data) { final args = data.argsAs( @@ -115,7 +116,7 @@ class ServerConnectionRoute class ServerConnectionRouteArgs { const ServerConnectionRouteArgs({this.key, this.arbiterUrl}); - final _i8.Key? key; + final _i9.Key? key; final String? arbiterUrl; @@ -137,16 +138,32 @@ class ServerConnectionRouteArgs { /// generated route for /// [_i6.ServerInfoSetupScreen] -class ServerInfoSetupRoute extends _i7.PageRouteInfo { - const ServerInfoSetupRoute({List<_i7.PageRouteInfo>? children}) +class ServerInfoSetupRoute extends _i8.PageRouteInfo { + const ServerInfoSetupRoute({List<_i8.PageRouteInfo>? children}) : super(ServerInfoSetupRoute.name, initialChildren: children); static const String name = 'ServerInfoSetupRoute'; - static _i7.PageInfo page = _i7.PageInfo( + static _i8.PageInfo page = _i8.PageInfo( name, builder: (data) { return const _i6.ServerInfoSetupScreen(); }, ); } + +/// generated route for +/// [_i7.VaultSetupScreen] +class VaultSetupRoute extends _i8.PageRouteInfo { + const VaultSetupRoute({List<_i8.PageRouteInfo>? children}) + : super(VaultSetupRoute.name, initialChildren: children); + + static const String name = 'VaultSetupRoute'; + + static _i8.PageInfo page = _i8.PageInfo( + name, + builder: (data) { + return const _i7.VaultSetupScreen(); + }, + ); +} diff --git a/useragent/lib/screens/server_connection.dart b/useragent/lib/screens/server_connection.dart index 8c1727f..9634287 100644 --- a/useragent/lib/screens/server_connection.dart +++ b/useragent/lib/screens/server_connection.dart @@ -18,7 +18,7 @@ class ServerConnectionScreen extends HookConsumerWidget { if (connectionState.value != null) { WidgetsBinding.instance.addPostFrameCallback((_) { - context.router.replace(const DashboardRouter()); + context.router.replace(const VaultSetupRoute()); }); } diff --git a/useragent/lib/screens/vault_setup.dart b/useragent/lib/screens/vault_setup.dart new file mode 100644 index 0000000..5c1ce6e --- /dev/null +++ b/useragent/lib/screens/vault_setup.dart @@ -0,0 +1,408 @@ +import 'package:arbiter/features/connection/connection.dart'; +import 'package:arbiter/proto/user_agent.pbenum.dart'; +import 'package:arbiter/providers/connection/connection_manager.dart'; +import 'package:arbiter/providers/vault_state.dart'; +import 'package:arbiter/router.gr.dart'; +import 'package:arbiter/widgets/bottom_popup.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sizer/sizer.dart'; + +@RoutePage() +class VaultSetupScreen extends HookConsumerWidget { + const VaultSetupScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final vaultState = ref.watch(vaultStateProvider); + final bootstrapPasswordController = useTextEditingController(); + final bootstrapConfirmController = useTextEditingController(); + final unsealPasswordController = useTextEditingController(); + final errorText = useState(null); + final isSubmitting = useState(false); + + useEffect(() { + if (vaultState.asData?.value == VaultState.VAULT_STATE_UNSEALED) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) { + context.router.replace(const DashboardRouter()); + } + }); + } + + return null; + }, [context, vaultState.asData?.value]); + + Future refreshVaultState() async { + ref.invalidate(vaultStateProvider); + await ref.read(vaultStateProvider.future); + } + + Future submitBootstrap() async { + final password = bootstrapPasswordController.text; + final confirmation = bootstrapConfirmController.text; + + if (password.isEmpty || confirmation.isEmpty) { + errorText.value = 'Enter the password twice.'; + return; + } + + if (password != confirmation) { + errorText.value = 'Passwords do not match.'; + return; + } + + final confirmed = await showBottomPopup( + context: context, + builder: (popupContext) { + return _WarningPopup( + title: 'Bootstrap vault?', + body: + 'This password cannot be recovered. If you lose it, the vault cannot be unsealed.', + confirmLabel: 'Bootstrap', + onCancel: () => Navigator.of(popupContext).pop(false), + onConfirm: () => Navigator.of(popupContext).pop(true), + ); + }, + ); + + if (confirmed != true) { + return; + } + + errorText.value = null; + isSubmitting.value = true; + + try { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + + final result = await bootstrapVault(connection, password); + switch (result) { + case BootstrapResult.BOOTSTRAP_RESULT_SUCCESS: + bootstrapPasswordController.clear(); + bootstrapConfirmController.clear(); + await refreshVaultState(); + break; + case BootstrapResult.BOOTSTRAP_RESULT_ALREADY_BOOTSTRAPPED: + errorText.value = + 'The vault was already bootstrapped. Refreshing vault state.'; + await refreshVaultState(); + break; + case BootstrapResult.BOOTSTRAP_RESULT_INVALID_KEY: + case BootstrapResult.BOOTSTRAP_RESULT_UNSPECIFIED: + errorText.value = 'Failed to bootstrap the vault.'; + break; + } + } catch (error) { + errorText.value = _formatVaultError(error); + } finally { + isSubmitting.value = false; + } + } + + Future submitUnseal() async { + final password = unsealPasswordController.text; + if (password.isEmpty) { + errorText.value = 'Enter the vault password.'; + return; + } + + errorText.value = null; + isSubmitting.value = true; + + try { + final connection = await ref.read(connectionManagerProvider.future); + if (connection == null) { + throw Exception('Not connected to the server.'); + } + + final result = await unsealVault(connection, password); + switch (result) { + case UnsealResult.UNSEAL_RESULT_SUCCESS: + unsealPasswordController.clear(); + await refreshVaultState(); + break; + case UnsealResult.UNSEAL_RESULT_INVALID_KEY: + errorText.value = 'Incorrect password.'; + break; + case UnsealResult.UNSEAL_RESULT_UNBOOTSTRAPPED: + errorText.value = + 'The vault is not bootstrapped yet. Refreshing vault state.'; + await refreshVaultState(); + break; + case UnsealResult.UNSEAL_RESULT_UNSPECIFIED: + errorText.value = 'Failed to unseal the vault.'; + break; + } + } catch (error) { + errorText.value = _formatVaultError(error); + } finally { + isSubmitting.value = false; + } + } + + final body = switch (vaultState) { + AsyncLoading() => const Center(child: CircularProgressIndicator()), + AsyncError(:final error) => _VaultCard( + title: 'Vault unavailable', + subtitle: _formatVaultError(error), + child: FilledButton( + onPressed: () => ref.invalidate(vaultStateProvider), + child: const Text('Retry'), + ), + ), + AsyncData(:final value) => switch (value) { + VaultState.VAULT_STATE_UNBOOTSTRAPPED => _VaultCard( + title: 'Create vault password', + subtitle: + 'Choose the password that will be required to unseal this vault.', + child: _PasswordForm( + errorText: errorText.value, + isSubmitting: isSubmitting.value, + submitLabel: 'Bootstrap vault', + onSubmit: submitBootstrap, + fields: [ + _PasswordFieldConfig( + controller: bootstrapPasswordController, + label: 'Password', + textInputAction: TextInputAction.next, + ), + _PasswordFieldConfig( + controller: bootstrapConfirmController, + label: 'Confirm password', + textInputAction: TextInputAction.done, + onSubmitted: (_) => submitBootstrap(), + ), + ], + ), + ), + VaultState.VAULT_STATE_SEALED => _VaultCard( + title: 'Unseal vault', + subtitle: 'Enter the vault password to continue.', + child: _PasswordForm( + errorText: errorText.value, + isSubmitting: isSubmitting.value, + submitLabel: 'Unseal vault', + onSubmit: submitUnseal, + fields: [ + _PasswordFieldConfig( + controller: unsealPasswordController, + label: 'Password', + textInputAction: TextInputAction.done, + onSubmitted: (_) => submitUnseal(), + ), + ], + ), + ), + VaultState.VAULT_STATE_UNSEALED => const Center( + child: CircularProgressIndicator(), + ), + VaultState.VAULT_STATE_ERROR => _VaultCard( + title: 'Vault state unavailable', + subtitle: 'Unable to determine the current vault state.', + child: FilledButton( + onPressed: () => ref.invalidate(vaultStateProvider), + child: const Text('Retry'), + ), + ), + VaultState.VAULT_STATE_UNSPECIFIED => _VaultCard( + title: 'Vault state unavailable', + subtitle: 'Unable to determine the current vault state.', + child: FilledButton( + onPressed: () => ref.invalidate(vaultStateProvider), + child: const Text('Retry'), + ), + ), + null => _VaultCard( + title: 'Vault state unavailable', + subtitle: 'Unable to determine the current vault state.', + child: FilledButton( + onPressed: () => ref.invalidate(vaultStateProvider), + child: const Text('Retry'), + ), + ), + _ => _VaultCard( + title: 'Vault state unavailable', + subtitle: 'Unable to determine the current vault state.', + child: FilledButton( + onPressed: () => ref.invalidate(vaultStateProvider), + child: const Text('Retry'), + ), + ), + }, + }; + + return Scaffold( + appBar: AppBar(title: const Text('Vault Setup')), + body: Center( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 3.h), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 520), + child: body, + ), + ), + ), + ); + } +} + +class _VaultCard extends StatelessWidget { + const _VaultCard({ + required this.title, + required this.subtitle, + required this.child, + }); + + final String title; + final String subtitle; + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 12), + Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 24), + child, + ], + ), + ), + ); + } +} + +class _PasswordForm extends StatelessWidget { + const _PasswordForm({ + required this.fields, + required this.errorText, + required this.isSubmitting, + required this.submitLabel, + required this.onSubmit, + }); + + final List<_PasswordFieldConfig> fields; + final String? errorText; + final bool isSubmitting; + final String submitLabel; + final Future Function() onSubmit; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final field in fields) ...[ + TextField( + controller: field.controller, + obscureText: true, + enableSuggestions: false, + autocorrect: false, + textInputAction: field.textInputAction, + onSubmitted: field.onSubmitted, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: field.label, + ), + ), + const SizedBox(height: 16), + ], + if (errorText != null) ...[ + Text( + errorText!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + const SizedBox(height: 16), + ], + Align( + alignment: Alignment.centerLeft, + child: FilledButton( + onPressed: isSubmitting ? null : () => onSubmit(), + child: Text(isSubmitting ? 'Working...' : submitLabel), + ), + ), + ], + ); + } +} + +class _PasswordFieldConfig { + const _PasswordFieldConfig({ + required this.controller, + required this.label, + required this.textInputAction, + this.onSubmitted, + }); + + final TextEditingController controller; + final String label; + final TextInputAction textInputAction; + final ValueChanged? onSubmitted; +} + +class _WarningPopup extends StatelessWidget { + const _WarningPopup({ + required this.title, + required this.body, + required this.confirmLabel, + required this.onCancel, + required this.onConfirm, + }); + + final String title; + final String body; + final String confirmLabel; + final VoidCallback onCancel; + final VoidCallback onConfirm; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(24), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 12), + Text(body, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton(onPressed: onCancel, child: const Text('Cancel')), + const SizedBox(width: 12), + FilledButton(onPressed: onConfirm, child: Text(confirmLabel)), + ], + ), + ], + ), + ), + ); + } +} + +String _formatVaultError(Object error) { + final message = error.toString(); + + if (message.contains('GrpcError')) { + return 'The server rejected the vault request. Check the password and try again.'; + } + + return message.replaceFirst('Exception: ', ''); +} diff --git a/useragent/lib/widgets/bottom_popup.dart b/useragent/lib/widgets/bottom_popup.dart new file mode 100644 index 0000000..875ef08 --- /dev/null +++ b/useragent/lib/widgets/bottom_popup.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +Future showBottomPopup({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, +}) { + return showGeneralDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 320), + pageBuilder: (dialogContext, animation, secondaryAnimation) { + return _BottomPopupRoute( + animation: animation, + builder: builder, + barrierDismissible: barrierDismissible, + ); + }, + ); +} + +class _BottomPopupRoute extends StatelessWidget { + const _BottomPopupRoute({ + required this.animation, + required this.builder, + required this.barrierDismissible, + }); + + final Animation animation; + final WidgetBuilder builder; + final bool barrierDismissible; + + @override + Widget build(BuildContext context) { + final barrierAnimation = CurvedAnimation( + parent: animation, + curve: const Interval(0, 0.3125, curve: Curves.easeOut), + ); + final popupAnimation = CurvedAnimation( + parent: animation, + curve: const Interval(0.3125, 1, curve: Curves.easeOutCubic), + ); + + return Material( + type: MaterialType.transparency, + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: barrierDismissible ? () => Navigator.of(context).pop() : null, + child: AnimatedBuilder( + animation: barrierAnimation, + builder: (context, child) { + return ColoredBox( + color: Colors.black.withValues( + alpha: 0.35 * barrierAnimation.value, + ), + ); + }, + ), + ), + ), + SafeArea( + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(16), + child: FadeTransition( + opacity: popupAnimation, + child: SlideTransition( + position: + Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(popupAnimation), + child: GestureDetector( + onTap: () {}, + child: Builder(builder: builder), + ), + ), + ), + ), + ), + ), + ], + ), + ); + } +}