implement signature requests over ws

This commit is contained in:
timurgordon
2025-05-19 14:48:40 +03:00
parent 2fd74defab
commit 83dde53555
27 changed files with 10791 additions and 0 deletions

2575
sigsocket/examples/client_app/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,22 @@
[package]
name = "sigsocket-client-example"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1.28.0", features = ["full"] }
tokio-tungstenite = { version = "0.18.0", features = ["native-tls"] }
futures-util = "0.3.28"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
env_logger = "0.10.0"
secp256k1 = { version = "0.26.0", features = ["rand-std"] }
sha2 = "0.10.6"
rand = "0.8.5"
hex = "0.4.3"
base64 = "0.21.2"
actix-web = "4.3.1"
actix-files = "0.6.2"
tera = "1.19.0"
url = "2.4.0"

View File

@@ -0,0 +1,474 @@
use actix_files as fs;
use actix_web::{web, App, HttpServer, Responder, HttpResponse, Result};
use serde::{Deserialize, Serialize};
use tera::{Tera, Context};
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
use tokio_tungstenite::{connect_async, tungstenite};
use futures_util::{StreamExt, SinkExt};
use secp256k1::{Secp256k1, SecretKey, Message};
use sha2::{Sha256, Digest};
use url::Url;
use std::thread;
// Struct for representing a sign request
#[derive(Serialize, Deserialize, Clone, Debug)]
struct SignRequest {
id: String,
message: String,
#[serde(skip)]
message_raw: String, // Original base64 message for sending back in the response
#[serde(skip)]
message_decoded: String, // Decoded message for display
}
// Struct for representing the application state
struct AppState {
templates: Tera,
keypair: Arc<KeyPair>,
pending_request: Arc<Mutex<Option<SignRequest>>>,
websocket_sender: mpsc::Sender<WebSocketCommand>,
}
// Commands that can be sent to the WebSocket connection
enum WebSocketCommand {
Sign { id: String, message: String, signature: Vec<u8> },
Close,
}
// Keypair for signing messages
struct KeyPair {
secret_key: SecretKey,
public_key_hex: String,
}
impl KeyPair {
fn new() -> Self {
let secp = Secp256k1::new();
let mut rng = rand::thread_rng();
// Generate a new random keypair
let (secret_key, public_key) = secp.generate_keypair(&mut rng);
// Convert public key to hex for identification
let public_key_hex = hex::encode(public_key.serialize());
KeyPair {
secret_key,
public_key_hex,
}
}
fn sign(&self, message: &[u8]) -> Vec<u8> {
// Hash the message first (secp256k1 requires a 32-byte hash)
let mut hasher = Sha256::new();
hasher.update(message);
let message_hash = hasher.finalize();
// Create a secp256k1 message from the hash
let secp_message = Message::from_slice(&message_hash).unwrap();
// Sign the message
let secp = Secp256k1::new();
let signature = secp.sign_ecdsa(&secp_message, &self.secret_key);
// Return the serialized signature
signature.serialize_compact().to_vec()
}
}
// Controller for the index page
async fn index(data: web::Data<AppState>) -> Result<HttpResponse> {
let mut context = Context::new();
// Add the keypair to the context
context.insert("public_key", &data.keypair.public_key_hex);
// Add the pending request if there is one
if let Some(request) = &*data.pending_request.lock().unwrap() {
context.insert("request", request);
}
let rendered = data.templates.render("index.html", &context)
.map_err(|e| {
eprintln!("Template error: {}", e);
actix_web::error::ErrorInternalServerError("Template error")
})?;
Ok(HttpResponse::Ok().content_type("text/html").body(rendered))
}
// Controller for the sign endpoint
async fn sign_request(
data: web::Data<AppState>,
form: web::Form<SignRequestForm>,
) -> impl Responder {
println!("SIGN ENDPOINT: Starting sign_request handler for form ID: {}", form.id);
// Try to get a lock on the pending request
println!("SIGN ENDPOINT: Attempting to acquire lock on pending_request");
match data.pending_request.try_lock() {
Ok(mut guard) => {
// Check if we have a pending request
if let Some(request) = &*guard {
println!("SIGN ENDPOINT: Found pending request with ID: {}", request.id);
// Get the request ID
let id = request.id.clone();
// Verify that the request ID matches
if id == form.id {
println!("SIGN ENDPOINT: Request ID matches form ID: {}", id);
// Sign the message
let message = request.message.as_bytes();
println!("SIGN ENDPOINT: About to sign message: {} (length: {})",
String::from_utf8_lossy(message), message.len());
let signature = data.keypair.sign(message);
println!("SIGN ENDPOINT: Message signed successfully. Signature length: {}", signature.len());
// Send the signature via WebSocket
println!("SIGN ENDPOINT: About to send signature via websocket channel");
match data.websocket_sender.send(WebSocketCommand::Sign {
id: id.clone(),
message: request.message_raw.clone(), // Include the original base64 message
signature
}).await {
Ok(_) => {
println!("SIGN ENDPOINT: Successfully sent signature to websocket channel");
},
Err(e) => {
let error_msg = format!("Failed to send signature: {}", e);
println!("SIGN ENDPOINT ERROR: {}", error_msg);
return HttpResponse::InternalServerError()
.content_type("text/html")
.body(format!("<h1>Error sending signature</h1><p>{}</p><p><a href='/'>Return to home</a></p>", error_msg));
}
}
// Clear the pending request
println!("SIGN ENDPOINT: Clearing pending request");
*guard = None;
// Return a success page that continues to the next step
println!("SIGN ENDPOINT: Returning success response");
return HttpResponse::Ok()
.content_type("text/html")
.body(r#"<html>
<head>
<title>Signature Sent</title>
<meta http-equiv="refresh" content="2; url=/" />
<script type="text/javascript">
console.log("Signature sent successfully, redirecting in 2 seconds...");
setTimeout(function() { window.location.href = '/'; }, 2000);
</script>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
.success { color: green; }
</style>
</head>
<body>
<h1 class="success">✓ Signature Sent Successfully!</h1>
<p>Redirecting back to home page...</p>
<p><a href="/">Click here if you're not redirected automatically</a></p>
</body>
</html>"#);
} else {
println!("SIGN ENDPOINT: Request ID {} does not match form ID {}", request.id, form.id);
}
} else {
println!("SIGN ENDPOINT: No pending request found");
}
},
Err(e) => {
let error_msg = format!("Failed to acquire lock on pending_request: {}", e);
println!("SIGN ENDPOINT ERROR: {}", error_msg);
return HttpResponse::InternalServerError()
.content_type("text/html")
.body(format!("<h1>Error processing request</h1><p>{}</p><p><a href='/'>Return to home</a></p>", error_msg));
}
}
// Redirect back to the index page (if no request was found or ID didn't match)
println!("SIGN ENDPOINT: No matching request found, redirecting to home");
HttpResponse::SeeOther()
.append_header(("Location", "/"))
.finish()
}
// Form for submitting a signature
#[derive(Deserialize)]
struct SignRequestForm {
id: String,
}
// WebSocket client task that connects to the SigSocket server
async fn websocket_client_task(
keypair: Arc<KeyPair>,
pending_request: Arc<Mutex<Option<SignRequest>>>,
mut command_receiver: mpsc::Receiver<WebSocketCommand>,
) {
// Connect directly to the web app's integrated SigSocket endpoint
let sigsocket_url = "ws://127.0.0.1:8080/ws";
// Reconnection settings
let mut retry_count = 0;
const MAX_RETRY_COUNT: u32 = 10; // Reset retry counter after this many attempts
const BASE_RETRY_DELAY_MS: u64 = 1000; // Start with 1 second
const MAX_RETRY_DELAY_MS: u64 = 30000; // Cap at 30 seconds
loop {
// Calculate backoff delay with jitter for retry
let delay_ms = if retry_count > 0 {
let base_delay = BASE_RETRY_DELAY_MS * 2u64.pow(retry_count.min(6));
let jitter = rand::random::<u64>() % 500; // Add up to 500ms of jitter
(base_delay + jitter).min(MAX_RETRY_DELAY_MS)
} else {
0 // No delay on first attempt
};
if retry_count > 0 {
println!("Reconnection attempt {} in {} ms...", retry_count, delay_ms);
tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await;
}
// Connect to the SigSocket server with timeout
println!("Connecting to SigSocket server at {}", sigsocket_url);
let connect_result = tokio::time::timeout(
tokio::time::Duration::from_secs(10), // Connection timeout
connect_async(Url::parse(sigsocket_url).unwrap())
).await;
match connect_result {
// Timeout error
Err(_) => {
eprintln!("Connection attempt timed out");
retry_count = (retry_count + 1) % MAX_RETRY_COUNT;
continue;
},
// Connection result
Ok(conn_result) => match conn_result {
// Connection successful
Ok((mut ws_stream, _)) => {
println!("Connected to SigSocket server");
// Reset retry counter on successful connection
retry_count = 0;
// Heartbeat functionality has been removed
println!("DEBUG: Running without heartbeat functionality");
// Send the initial message with just the raw public key
let intro_message = keypair.public_key_hex.clone();
if let Err(e) = ws_stream.send(tungstenite::Message::Text(intro_message)).await {
eprintln!("Failed to send introduction message: {}", e);
continue;
}
println!("Sent introduction with public key: {}", keypair.public_key_hex);
// Last time we received a message or pong from the server
let mut last_server_response = std::time::Instant::now();
// Process incoming messages and commands
loop {
tokio::select! {
// Handle WebSocket message
msg = ws_stream.next() => {
match msg {
Some(Ok(tungstenite::Message::Text(text))) => {
println!("Received message: {}", text);
last_server_response = std::time::Instant::now();
// Parse the message as a sign request
match serde_json::from_str::<SignRequest>(&text) {
Ok(mut request) => {
println!("DEBUG: Successfully parsed sign request with ID: {}", request.id);
println!("DEBUG: Base64 message: {}", request.message);
// Save the original base64 message for later use in response
request.message_raw = request.message.clone();
// Decode the base64 message content
match base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &request.message) {
Ok(decoded) => {
let decoded_text = String::from_utf8_lossy(&decoded).to_string();
println!("DEBUG: Decoded message: {}", decoded_text);
// Store the decoded message for display
request.message_decoded = decoded_text;
// Update the message for displaying in the UI
request.message = request.message_decoded.clone();
// Store the request for display in the UI
*pending_request.lock().unwrap() = Some(request);
println!("Received signing request. Please check the web UI to approve it.");
},
Err(e) => {
eprintln!("Error decoding base64 message: {}", e);
}
}
},
Err(e) => {
eprintln!("Error parsing sign request JSON: {}", e);
eprintln!("Raw message: {}", text);
}
}
},
Some(Ok(tungstenite::Message::Ping(data))) => {
// Respond to ping with pong
last_server_response = std::time::Instant::now();
if let Err(e) = ws_stream.send(tungstenite::Message::Pong(data)).await {
eprintln!("Failed to send pong: {}", e);
break;
}
},
Some(Ok(tungstenite::Message::Pong(_))) => {
// Got pong response from the server
last_server_response = std::time::Instant::now();
},
Some(Ok(_)) => {
// Ignore other types of messages
last_server_response = std::time::Instant::now();
},
Some(Err(e)) => {
eprintln!("WebSocket error: {}", e);
break;
},
None => {
eprintln!("WebSocket connection closed");
break;
},
}
},
// Heartbeat functionality has been removed
// Handle signing command from the web interface
cmd = command_receiver.recv() => {
match cmd {
Some(WebSocketCommand::Sign { id, message, signature }) => {
println!("DEBUG: Signing request ID: {}", id);
println!("DEBUG: Raw signature bytes: {:?}", signature);
println!("DEBUG: Using message from command: {}", message);
// Convert signature bytes to base64
let sig_base64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &signature);
println!("DEBUG: Base64 signature: {}", sig_base64);
// Create a JSON response with explicit ID and message/signature fields
let response = format!("{{\"id\": \"{}\", \"message\": \"{}\", \"signature\": \"{}\"}}",
id, message, sig_base64);
println!("DEBUG: Preparing to send JSON response: {}", response);
println!("DEBUG: Response length: {} bytes", response.len());
// Log that we're about to send on the WebSocket connection
println!("DEBUG: About to send on WebSocket connection");
// Send the signature response right away - with extra logging
println!("!!!! ATTEMPTING TO SEND SIGNATURE RESPONSE NOW !!!!");
match ws_stream.send(tungstenite::Message::Text(response.clone())).await {
Ok(_) => {
last_server_response = std::time::Instant::now();
println!("!!!! SUCCESSFULLY SENT SIGNATURE RESPONSE !!!!");
println!("!!!! SIGNATURE SENT FOR REQUEST ID: {} !!!!", id);
// Clear the pending request after successful signature
*pending_request.lock().unwrap() = None;
// Send another simple message to confirm the connection is still working
if let Err(e) = ws_stream.send(tungstenite::Message::Text("CONFIRM_SIGNATURE_SENT".to_string())).await {
println!("DEBUG: Failed to send confirmation message: {}", e);
} else {
println!("DEBUG: Sent confirmation message after signature");
}
},
Err(e) => {
eprintln!("!!!! FAILED TO SEND SIGNATURE RESPONSE: {} !!!!", e);
// Try to reconnect or recover
println!("DEBUG: Attempting to diagnose connection issue...");
break;
}
}
},
Some(WebSocketCommand::Close) => {
println!("DEBUG: Received close command, closing connection");
break;
},
None => {
eprintln!("Command channel closed");
break;
}
}
}
}
}
// Connection loop has ended, will attempt to reconnect
println!("WebSocket connection closed, will attempt to reconnect...");
},
// Connection error
Err(e) => {
eprintln!("Failed to connect to SigSocket server: {}", e);
}
}
}
// Increment retry counter but don't exceed MAX_RETRY_COUNT
retry_count = (retry_count + 1) % MAX_RETRY_COUNT;
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Setup logger
env_logger::init_from_env(env_logger::Env::default().default_filter_or("info"));
// Initialize templates
let mut tera = Tera::default();
tera.add_raw_templates(vec![
("index.html", include_str!("../templates/index.html")),
]).unwrap();
// Generate a keypair for signing
let keypair = Arc::new(KeyPair::new());
println!("Generated keypair with public key: {}", keypair.public_key_hex);
// Create a channel for sending commands to the WebSocket client
let (command_sender, command_receiver) = mpsc::channel::<WebSocketCommand>(32);
// Create the pending request mutex
let pending_request = Arc::new(Mutex::new(None::<SignRequest>));
// Spawn the WebSocket client task
let ws_keypair = keypair.clone();
let ws_pending_request = pending_request.clone();
tokio::spawn(async move {
websocket_client_task(ws_keypair, ws_pending_request, command_receiver).await;
});
// Create the app state
let app_state = web::Data::new(AppState {
templates: tera,
keypair,
pending_request,
websocket_sender: command_sender,
});
println!("Client App server starting on http://127.0.0.1:8082");
// Start the web server
HttpServer::new(move || {
App::new()
.app_data(app_state.clone())
// Register routes
.route("/", web::get().to(index))
.route("/sign", web::post().to(sign_request))
// Static files
.service(fs::Files::new("/static", "./static"))
})
.bind("127.0.0.1:8082")?
.run()
.await
}

View File

@@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SigSocket Client Demo</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<style>
body {
font-family: Arial, sans-serif;
max-width: 900px;
margin: 0 auto;
padding: 20px;
line-height: 1.6;
}
h1, h2 {
color: #333;
text-align: center;
}
.status-box {
text-align: center;
padding: 15px;
margin-bottom: 30px;
border-radius: 5px;
background-color: #f5f5f5;
}
.status-connected {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.client-info {
margin-bottom: 30px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
.keypair-info {
font-family: monospace;
word-break: break-all;
margin: 10px 0;
}
.request-panel {
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
margin-bottom: 30px;
background-color: #fff;
}
.message-box {
font-family: monospace;
background-color: #f8f9fa;
padding: 15px;
border: 1px solid #ddd;
border-radius: 4px;
margin: 15px 0;
white-space: pre-wrap;
word-break: break-all;
}
.no-requests {
text-align: center;
padding: 30px;
color: #6c757d;
}
button {
background-color: #4CAF50;
color: white;
padding: 10px 15px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
display: block;
margin: 0 auto;
}
button:hover {
background-color: #45a049;
}
.footer {
text-align: center;
margin-top: 30px;
color: #6c757d;
font-size: 0.9em;
}
</style>
</head>
<body>
<h1>SigSocket Client Demo</h1>
<div class="status-box status-connected">
<p><strong>Status:</strong> Connected to SigSocket Server</p>
</div>
<div class="client-info">
<h2>Client Information</h2>
<p><strong>Public Key:</strong></p>
<p class="keypair-info">{{ public_key }}</p>
<p>This public key is used to identify this client to the SigSocket server.</p>
</div>
{% if request %}
<div class="request-panel">
<h2>Pending Sign Request</h2>
<p><strong>Request ID:</strong> {{ request.id }}</p>
<p><strong>Message to Sign:</strong></p>
<div class="message-box">{{ request.message }}</div>
<form action="/sign" method="post">
<input type="hidden" name="id" value="{{ request.id }}">
<button type="submit">Sign Message</button>
</form>
</div>
{% else %}
<div class="request-panel no-requests">
<h2>No Pending Requests</h2>
<p>Waiting for a sign request from the SigSocket server...</p>
</div>
{% endif %}
<div class="footer">
<p>This client connects to a SigSocket server via WebSocket and responds to signature requests.</p>
<p>The signing is done using Secp256k1 ECDSA with a randomly generated keypair.</p>
</div>
<!-- Toast container for notifications -->
<div class="toast-container position-fixed bottom-0 start-0 p-3" style="z-index: 11; width: 100%;">
<!-- Toasts will be added here dynamically -->
</div>
<script>
// Override console.log to show toast messages
const originalConsoleLog = console.log;
const originalConsoleError = console.error;
console.log = function(message) {
// Call the original console.log
originalConsoleLog.apply(console, arguments);
// Show toast with the message
showToast(message, 'info');
};
console.error = function(message) {
// Call the original console.error
originalConsoleError.apply(console, arguments);
// Show toast with the error message
showToast(message, 'danger');
};
function showToast(message, type = 'info') {
// Create toast element
const toastId = 'toast-' + Date.now();
const toastElement = document.createElement('div');
toastElement.id = toastId;
toastElement.className = 'toast w-100';
toastElement.setAttribute('role', 'alert');
toastElement.setAttribute('aria-live', 'assertive');
toastElement.setAttribute('aria-atomic', 'true');
// Set toast content
toastElement.innerHTML = `
<div class="toast-header bg-${type} text-white">
<strong class="me-auto">${type === 'danger' ? 'Error' : 'Info'}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
${message}
</div>
`;
// Append to container
document.querySelector('.toast-container').appendChild(toastElement);
// Initialize and show the toast
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: 5000
});
toast.show();
// Remove toast after it's hidden
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
// Test toast
console.log('Client app loaded successfully!');
</script>
</body>
</html>