Compare commits
3 Commits
208bbbd540
...
8fb7a04102
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fb7a04102 | ||
|
|
056cd4af40 | ||
|
|
832d884457 |
156
ARCHITECTURE.md
Normal file
156
ARCHITECTURE.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# Arbiter
|
||||||
|
|
||||||
|
Arbiter is a permissioned signing service for cryptocurrency wallets. It runs as a background service on the user's machine with an optional client application for vault management.
|
||||||
|
|
||||||
|
**Core principle:** The vault NEVER exposes key material. It only produces signatures when a request satisfies the configured policies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Peer Types
|
||||||
|
|
||||||
|
Arbiter distinguishes two kinds of peers:
|
||||||
|
|
||||||
|
- **User Agent** — A client application used by the owner to manage the vault (create wallets, approve SDK clients, configure policies).
|
||||||
|
- **SDK Client** — A consumer of signing capabilities, typically an automation tool. In the future, this could include a browser-based wallet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Authentication
|
||||||
|
|
||||||
|
### 2.1 Challenge-Response
|
||||||
|
|
||||||
|
All peers authenticate via public-key cryptography using a challenge-response protocol:
|
||||||
|
|
||||||
|
1. The peer sends its public key and requests a challenge.
|
||||||
|
2. The server looks up the key in its database. If found, it increments the nonce and returns a challenge (replay-attack protection).
|
||||||
|
3. The peer signs the challenge with its private key and sends the signature back.
|
||||||
|
4. The server verifies the signature:
|
||||||
|
- **Pass:** The connection is considered authenticated.
|
||||||
|
- **Fail:** The server closes the connection.
|
||||||
|
|
||||||
|
### 2.2 User Agent Bootstrap
|
||||||
|
|
||||||
|
On first run — when no User Agents are registered — the server generates a one-time bootstrap token. It is made available in two ways:
|
||||||
|
|
||||||
|
- **Local setup:** Written to `~/.arbiter/bootstrap_token` for automatic discovery by a co-located User Agent.
|
||||||
|
- **Remote setup:** Printed to the server's console output.
|
||||||
|
|
||||||
|
The first User Agent must present this token alongside the standard challenge-response to complete registration.
|
||||||
|
|
||||||
|
### 2.3 SDK Client Registration
|
||||||
|
|
||||||
|
There is no bootstrap mechanism for SDK clients. They must be explicitly approved by an already-registered User Agent.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Server Identity
|
||||||
|
|
||||||
|
The server proves its identity using TLS with a self-signed certificate. The TLS private key is generated on first run and is long-term; no rotation mechanism exists yet due to the complexity of multi-peer coordination.
|
||||||
|
|
||||||
|
Peers verify the server by its **public key fingerprint**:
|
||||||
|
|
||||||
|
- **User Agent (local):** Receives the fingerprint automatically through the bootstrap token.
|
||||||
|
- **User Agent (remote) / SDK Client:** Must receive the fingerprint out-of-band.
|
||||||
|
|
||||||
|
> A streamlined setup mechanism using a single connection string is planned but not yet implemented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Key Management
|
||||||
|
|
||||||
|
### 4.1 Key Hierarchy
|
||||||
|
|
||||||
|
There are three layers of keys:
|
||||||
|
|
||||||
|
| Key | Encrypts | Encrypted by |
|
||||||
|
|---|---|---|
|
||||||
|
| **User key** (password) | Root key | — (derived from user input) |
|
||||||
|
| **Root key** | Wallet keys | User key |
|
||||||
|
| **Wallet keys** | — (used for signing) | Root key |
|
||||||
|
|
||||||
|
This layered design enables:
|
||||||
|
|
||||||
|
- **Password rotation** without re-encrypting every wallet key (only the root key is re-encrypted).
|
||||||
|
- **Root key rotation** without requiring the user to change their password.
|
||||||
|
|
||||||
|
### 4.2 Encryption at Rest
|
||||||
|
|
||||||
|
The database stores everything in encrypted form using symmetric AEAD. The encryption scheme is versioned to support transparent migration — when the vault unseals, Arbiter automatically re-encrypts any entries that are behind the current scheme version. See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the specific scheme and versioning mechanism.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vault Lifecycle
|
||||||
|
|
||||||
|
### 5.1 Sealed State
|
||||||
|
|
||||||
|
On boot, the root key is encrypted and the server cannot perform any signing operations. This state is called **Sealed**.
|
||||||
|
|
||||||
|
### 5.2 Unseal Flow
|
||||||
|
|
||||||
|
To transition to the **Unsealed** state, a User Agent must provide the password:
|
||||||
|
|
||||||
|
1. The User Agent initiates an unseal request.
|
||||||
|
2. The server generates a one-time key pair and returns the public key.
|
||||||
|
3. The User Agent encrypts the user's password with this one-time public key and sends the ciphertext to the server.
|
||||||
|
4. The server decrypts and verifies the password:
|
||||||
|
- **Success:** The root key is decrypted and placed into a hardened memory cell. The server transitions to `Unsealed`. Any entries pending encryption scheme migration are re-encrypted.
|
||||||
|
- **Failure:** The server returns an error indicating the password is incorrect.
|
||||||
|
|
||||||
|
### 5.3 Memory Protection
|
||||||
|
|
||||||
|
Once unsealed, the root key must be protected in memory against:
|
||||||
|
|
||||||
|
- Memory dumps
|
||||||
|
- Page swaps to disk
|
||||||
|
- Hibernation files
|
||||||
|
|
||||||
|
See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the current and planned memory protection approaches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Permission Engine
|
||||||
|
|
||||||
|
### 6.1 Fundamental Rules
|
||||||
|
|
||||||
|
- SDK clients have **no access by default**.
|
||||||
|
- Access is granted **explicitly** by a User Agent.
|
||||||
|
- Grants are scoped to **specific wallets** and governed by **policies**.
|
||||||
|
|
||||||
|
Each blockchain requires its own policy system due to differences in static transaction analysis. Currently, only EVM is supported; Solana support is planned.
|
||||||
|
|
||||||
|
Arbiter is also responsible for ensuring that **transaction nonces are never reused**.
|
||||||
|
|
||||||
|
### 6.2 EVM Policies
|
||||||
|
|
||||||
|
Every EVM grant is scoped to a specific **wallet** and **chain ID**.
|
||||||
|
|
||||||
|
#### 6.2.1 Transaction Sub-Grants
|
||||||
|
|
||||||
|
Arbiter maintains an ever-expanding database of known contracts and their ABIs. Based on contract knowledge, transaction requests fall into three categories:
|
||||||
|
|
||||||
|
**1. Known contract (ABI available)**
|
||||||
|
|
||||||
|
The transaction can be decoded and presented with semantic meaning. For example: *"Client X wants to transfer Y USDT to address Z."*
|
||||||
|
|
||||||
|
Available restrictions:
|
||||||
|
- Volume limits (e.g., "no more than 10,000 tokens ever")
|
||||||
|
- Rate limits (e.g., "no more than 100 tokens per hour")
|
||||||
|
|
||||||
|
**2. Unknown contract (no ABI)**
|
||||||
|
|
||||||
|
The transaction cannot be decoded, so its effects are opaque — it could do anything, including draining all tokens. The user is warned, and if approved, access is granted to all interactions with the contract (matched by the `to` field).
|
||||||
|
|
||||||
|
Available restrictions:
|
||||||
|
- Transaction count limits (e.g., "no more than 100 transactions ever")
|
||||||
|
- Rate limits (e.g., "no more than 5 transactions per hour")
|
||||||
|
|
||||||
|
**3. Plain ether transfer (no calldata)**
|
||||||
|
|
||||||
|
These transactions have no `calldata` and therefore cannot interact with contracts. They can be subject to the same volume and rate restrictions as above.
|
||||||
|
|
||||||
|
#### 6.2.2 Global Limits
|
||||||
|
|
||||||
|
In addition to sub-grant-specific restrictions, the following limits can be applied across all grant types:
|
||||||
|
|
||||||
|
- **Gas limit** — Maximum gas per transaction.
|
||||||
|
- **Time-window restrictions** — e.g., signing allowed only 08:00–20:00 on Mondays and Thursdays.
|
||||||
35
IMPLEMENTATION.md
Normal file
35
IMPLEMENTATION.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Implementation Details
|
||||||
|
|
||||||
|
This document covers concrete technology choices and dependencies. For the architectural design, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cryptography
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- **Signature scheme:** ed25519
|
||||||
|
|
||||||
|
### Encryption at Rest
|
||||||
|
- **Scheme:** Symmetric AEAD — currently **XChaCha20-Poly1305**
|
||||||
|
- **Version tracking:** Each `aead_encrypted` database entry carries a `scheme` field denoting the version, enabling transparent migration on unseal
|
||||||
|
|
||||||
|
### Server Identity
|
||||||
|
- **Transport:** TLS with a self-signed certificate
|
||||||
|
- **Key type:** Generated on first run; long-term (no rotation mechanism yet)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Communication
|
||||||
|
|
||||||
|
- **Protocol:** gRPC with Protocol Buffers
|
||||||
|
- **Server identity distribution:** `ServerInfo` protobuf struct containing the TLS public key fingerprint
|
||||||
|
- **Future consideration:** grpc-web lacks bidirectional stream support, so a browser-based wallet may require protojson over WebSocket
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Memory Protection
|
||||||
|
|
||||||
|
The unsealed root key must be held in a hardened memory cell resistant to dumps, page swaps, and hibernation.
|
||||||
|
|
||||||
|
- **Current:** Using the `memsafe` crate as an interim solution
|
||||||
|
- **Planned:** Custom implementation based on `mlock` (Unix) and `VirtualProtect` (Windows)
|
||||||
@@ -5,10 +5,8 @@ package arbiter.auth;
|
|||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
|
|
||||||
message AuthChallengeRequest {
|
message AuthChallengeRequest {
|
||||||
oneof payload {
|
bytes pubkey = 1;
|
||||||
bytes pubkey = 1;
|
optional string bootstrap_token = 2;
|
||||||
string bootstrap_token = 2;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message AuthChallenge {
|
message AuthChallenge {
|
||||||
|
|||||||
@@ -34,3 +34,4 @@ memsafe = "0.4.0"
|
|||||||
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
chacha20poly1305 = { version = "0.10.1", features = ["std"] }
|
||||||
zeroize = { version = "1.8.2", features = ["std", "simd"] }
|
zeroize = { version = "1.8.2", features = ["std", "simd"] }
|
||||||
kameo.workspace = true
|
kameo.workspace = true
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use arbiter_proto::{
|
use arbiter_proto::{
|
||||||
proto::{
|
proto::{
|
||||||
UserAgentRequest, UserAgentResponse,
|
UserAgentRequest, UserAgentResponse,
|
||||||
@@ -8,10 +10,12 @@ use arbiter_proto::{
|
|||||||
},
|
},
|
||||||
transport::Bi,
|
transport::Bi,
|
||||||
};
|
};
|
||||||
|
use ed25519_dalek::VerifyingKey;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use kameo::{Actor, message::StreamMessage, messages, prelude::Context};
|
use kameo::{Actor, message::StreamMessage, messages, prelude::Context};
|
||||||
use secrecy::{ExposeSecret, SecretBox};
|
use secrecy::{ExposeSecret, SecretBox};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
use tokio::sync::mpsc::Sender;
|
||||||
use tonic::{Status, transport::Server};
|
use tonic::{Status, transport::Server};
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
@@ -59,78 +63,64 @@ impl UserAgentStateMachineContext for ServerContext {
|
|||||||
pub struct UserAgentActor {
|
pub struct UserAgentActor {
|
||||||
context: ServerContext,
|
context: ServerContext,
|
||||||
state: UserAgentStateMachine<ServerContext>,
|
state: UserAgentStateMachine<ServerContext>,
|
||||||
tx: mpsc::Sender<Result<UserAgentResponse, tonic::Status>>,
|
rx: Sender<Result<UserAgentResponse, Status>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UserAgentActor {
|
impl UserAgentActor {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
context: ServerContext,
|
context: ServerContext,
|
||||||
tx: mpsc::Sender<Result<UserAgentResponse, tonic::Status>>,
|
rx: Sender<Result<UserAgentResponse, Status>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
context: context.clone(),
|
context: context.clone(),
|
||||||
state: UserAgentStateMachine::new(context),
|
state: UserAgentStateMachine::new(context),
|
||||||
tx,
|
rx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_grpc(
|
async fn auth_with_bootstrap_token(
|
||||||
&mut self,
|
&mut self,
|
||||||
msg: UserAgentRequest,
|
pubkey: ed25519_dalek::VerifyingKey,
|
||||||
ctx: &mut Context<Self, ()>,
|
token: String,
|
||||||
) -> Result<UserAgentResponse, tonic::Status> {
|
) -> Result<UserAgentResponse, Status> {
|
||||||
let Some(msg) = msg.payload else {
|
todo!()
|
||||||
error!(actor = "useragent", "Received message with no payload");
|
|
||||||
ctx.stop();
|
|
||||||
return Err(tonic::Status::invalid_argument(
|
|
||||||
"Message payload is required",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
let UserAgentRequestPayload::AuthMessage(ClientMessage {
|
|
||||||
payload: Some(client_message),
|
|
||||||
}) = msg
|
|
||||||
else {
|
|
||||||
error!(
|
|
||||||
actor = "useragent",
|
|
||||||
"Received unexpected message type during authentication"
|
|
||||||
);
|
|
||||||
ctx.stop();
|
|
||||||
return Err(tonic::Status::invalid_argument(
|
|
||||||
"Unexpected message type during authentication",
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
match client_message {
|
|
||||||
ClientAuthPayload::AuthChallengeRequest(AuthChallengeRequest {
|
|
||||||
payload: Some(payload),
|
|
||||||
}) => match payload {
|
|
||||||
auth::auth_challenge_request::Payload::Pubkey(items) => todo!(),
|
|
||||||
auth::auth_challenge_request::Payload::BootstrapToken(_) => todo!(),
|
|
||||||
},
|
|
||||||
ClientAuthPayload::AuthChallengeSolution(_auth_challenge_solution) => todo!(),
|
|
||||||
_ => {
|
|
||||||
error!(
|
|
||||||
actor = "useragent",
|
|
||||||
"Received unexpected message type during authentication"
|
|
||||||
);
|
|
||||||
ctx.stop();
|
|
||||||
return Err(tonic::Status::invalid_argument(
|
|
||||||
"Unexpected message type during authentication",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Output = Result<UserAgentResponse, Status>;
|
||||||
|
|
||||||
#[messages]
|
#[messages]
|
||||||
impl UserAgentActor {
|
impl UserAgentActor {
|
||||||
#[message(ctx)]
|
#[message(ctx)]
|
||||||
pub async fn grpc(&mut self, msg: UserAgentRequest, ctx: &mut Context<Self, ()>) {
|
async fn handle_auth_challenge_request(
|
||||||
let result = self.handle_grpc(msg, ctx).await;
|
&mut self,
|
||||||
self.tx.send(result).await.unwrap_or_else(|e| {
|
req: AuthChallengeRequest,
|
||||||
error!(handler = "useragent", "Failed to send response: {}", e);
|
ctx: &mut Context<Self, Output>,
|
||||||
ctx.stop();
|
) -> Output {
|
||||||
});
|
let pubkey = req.pubkey.as_array().ok_or(Status::invalid_argument(
|
||||||
|
"Expected pubkey to have specific length",
|
||||||
|
))?;
|
||||||
|
let pubkey = VerifyingKey::from_bytes(pubkey).map_err(|err| {
|
||||||
|
error!(?pubkey, "Failed to convert to VerifyingKey");
|
||||||
|
Status::invalid_argument("Failed to convert pubkey to VerifyingKey")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if let Some(token) = req.bootstrap_token {
|
||||||
|
return self
|
||||||
|
.auth_with_bootstrap_token(pubkey, token)
|
||||||
|
.await
|
||||||
|
.map_err(|_| Status::internal("Failed to authenticate with bootstrap token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[message(ctx)]
|
||||||
|
async fn handle_auth_challenge_solution(
|
||||||
|
&mut self,
|
||||||
|
_solution: auth::AuthChallengeSolution,
|
||||||
|
ctx: &mut Context<Self, Output>,
|
||||||
|
) -> Output {
|
||||||
|
todo!()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
#![allow(unused)]
|
#![allow(unused)]
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
use arbiter_proto::{
|
use arbiter_proto::{
|
||||||
proto::{ClientRequest, ClientResponse, UserAgentRequest, UserAgentResponse},
|
proto::{
|
||||||
|
ClientRequest, ClientResponse, UserAgentRequest, UserAgentResponse,
|
||||||
|
auth::{
|
||||||
|
self, AuthChallengeRequest, ClientMessage, client_message::Payload as ClientAuthPayload,
|
||||||
|
},
|
||||||
|
user_agent_request::Payload as UserAgentRequestPayload,
|
||||||
|
user_agent_request::*,
|
||||||
|
},
|
||||||
transport::BiStream,
|
transport::BiStream,
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -61,15 +70,53 @@ impl arbiter_proto::proto::arbiter_service_server::ArbiterService for Server {
|
|||||||
let mut req_stream = request.into_inner();
|
let mut req_stream = request.into_inner();
|
||||||
let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
|
let (tx, rx) = mpsc::channel(DEFAULT_CHANNEL_SIZE);
|
||||||
|
|
||||||
let actor = UserAgentActor::spawn(UserAgentActor::new(self.context.clone(), tx));
|
let actor = UserAgentActor::spawn(UserAgentActor::new(self.context.clone(), tx.clone()));
|
||||||
|
|
||||||
tokio::task::spawn(async move {
|
tokio::task::spawn(async move {
|
||||||
while let Some(Ok(req)) = req_stream.next().await && actor.is_alive() {
|
while let Some(Ok(req)) = req_stream.next().await
|
||||||
if actor.tell(user_agent::Grpc {msg: req}).await.is_err() {
|
&& actor.is_alive()
|
||||||
error!("Failed to send message to UserAgentActor");
|
{
|
||||||
break;
|
let Some(msg) = req.payload else {
|
||||||
}
|
error!(actor = "useragent", "Received message with no payload");
|
||||||
}
|
actor.kill();
|
||||||
|
tx.send(Err(Status::invalid_argument(
|
||||||
|
"Expected message with payload",
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let UserAgentRequestPayload::AuthMessage(ClientMessage {
|
||||||
|
payload: Some(client_message),
|
||||||
|
}) = msg
|
||||||
|
else {
|
||||||
|
error!(
|
||||||
|
actor = "useragent",
|
||||||
|
"Received unexpected message type during authentication"
|
||||||
|
);
|
||||||
|
actor.kill();
|
||||||
|
tx.send(Err(Status::invalid_argument(
|
||||||
|
"Expected AuthMessage with ClientMessage payload",
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match client_message {
|
||||||
|
ClientAuthPayload::AuthChallengeRequest(req) => {}
|
||||||
|
ClientAuthPayload::AuthChallengeSolution(_auth_challenge_solution) => todo!(),
|
||||||
|
_ => {
|
||||||
|
error!(actor = "useragent", "Received unexpected message type");
|
||||||
|
actor.kill();
|
||||||
|
tx.send(Err(Status::invalid_argument(
|
||||||
|
"Expected AuthMessage with ClientMessage payload",
|
||||||
|
)))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Response::new(ReceiverStream::new(rx)))
|
Ok(Response::new(ReceiverStream::new(rx)))
|
||||||
|
|||||||
Reference in New Issue
Block a user