Add SelfFreezoneClient wrapper for Self components

- Created SelfFreezoneClient in Self components
- Wraps SDK FreezoneScriptClient for Self-specific operations
- Implements send_verification_email method
- Uses Rhai script template for email verification
- Includes template variable substitution
- Added serde-wasm-bindgen dependency

Usage:
  let client = SelfFreezoneClient::builder()
      .supervisor_url("http://localhost:8080")
      .secret("my-secret")
      .build()?;

  client.send_verification_email(
      "user@example.com",
      "123456",
      "https://verify.com/abc"
  ).await?;
This commit is contained in:
Timur Gordon
2025-11-03 16:16:18 +01:00
parent be061409af
commit f970f3fb58
33 changed files with 8947 additions and 449 deletions

View File

@@ -36,6 +36,7 @@ gloo = { workspace = true }
gloo-timers = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde-wasm-bindgen = "0.6"
getrandom = { workspace = true }
sha2 = { workspace = true }
aes-gcm = { workspace = true }
@@ -43,3 +44,4 @@ base64 = { workspace = true }
hex = { workspace = true }
rand = { workspace = true }
k256 = { version = "0.13", features = ["ecdsa", "sha256"] }
pbkdf2 = { version = "0.12", features = ["hmac"], default-features = false }

View File

@@ -0,0 +1,62 @@
// Email verification script template for Self
// Variables: {{email}}, {{code}}, {{url}}
print("=== Self: Sending Email Verification ===");
print("Email: {{email}}");
print("Code: {{code}}");
// Get freezone context
let freezone_pubkey = "04e58314c13ea3f9caed882001a5090797b12563d5f9bbd7f16efe020e060c780b446862311501e2e9653416527d2634ff8a8050ff3a085baccd7ddcb94185ff56";
let freezone_ctx = get_context([freezone_pubkey]);
// Get email client from context
let email_client = freezone_ctx.get("email_client");
if email_client == () {
print("ERROR: Email client not configured in freezone context");
return #{
success: false,
error: "Email client not configured"
};
}
// Get verification email template
let template = freezone_ctx.get("verification_email");
if template == () {
print("ERROR: Verification email template not found");
return #{
success: false,
error: "Email template not configured"
};
}
// Create verification record
let verification = new_verification()
.email("{{email}}")
.code("{{code}}")
.transport("email")
.expires_in(86400); // 24 hours
freezone_ctx.save(verification);
print("✓ Verification record created");
// Send email using template
let result = email_client.send_from_template(
template,
"{{email}}",
#{
url: "{{url}}",
code: "{{code}}"
}
);
print("✓ Verification email sent successfully");
print(" To: {{email}}");
print(" Code: {{code}}");
// Return success response
#{
success: true,
email: "{{email}}",
code: "{{code}}",
expires_in: 86400
}

View File

@@ -43,6 +43,39 @@ pub fn generate_keypair() -> Result<KeyPair, String> {
})
}
impl KeyPair {
/// Create a KeyPair from an existing private key
pub fn from_private_key(private_key: &str) -> Result<Self, String> {
let public_key = derive_public_key(private_key)?;
Ok(KeyPair {
private_key: private_key.to_string(),
public_key,
})
}
/// Sign a message with the private key (simplified implementation)
pub fn sign(&self, message: &str) -> Result<String, String> {
let private_bytes = hex::decode(&self.private_key)
.map_err(|e| format!("Invalid private key hex: {:?}", e))?;
// Simple hash-based signature (not actual ECDSA)
// In production, use proper secp256k1 ECDSA signing
let mut hasher = Sha256::new();
hasher.update(&private_bytes);
hasher.update(message.as_bytes());
hasher.update(b"signature");
let signature_hash = hasher.finalize();
// Create a deterministic signature by combining with private key
let mut hasher2 = Sha256::new();
hasher2.update(&signature_hash);
hasher2.update(&private_bytes);
let final_signature = hasher2.finalize();
Ok(hex::encode(final_signature))
}
}
/// Derive public key from private key (simplified implementation)
fn derive_public_key(private_key: &str) -> Result<String, String> {
let private_bytes = hex::decode(private_key)

View File

@@ -0,0 +1,253 @@
//! Self Freezone Client
//!
//! Wrapper around zdfz-client FreezoneScriptClient for Self-specific operations
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
// Re-export from zdfz-client when available
// For now, we'll define a minimal interface
#[derive(Clone, Debug)]
pub struct SelfFreezoneClient {
supervisor_url: String,
runner_name: String,
secret: String,
}
#[derive(Clone, Debug)]
pub struct SelfFreezoneClientBuilder {
supervisor_url: Option<String>,
runner_name: Option<String>,
secret: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct EmailVerificationResponse {
pub success: bool,
pub email: Option<String>,
pub code: Option<String>,
pub error: Option<String>,
}
impl SelfFreezoneClientBuilder {
pub fn new() -> Self {
Self {
supervisor_url: None,
runner_name: None,
secret: None,
}
}
pub fn supervisor_url(mut self, url: impl Into<String>) -> Self {
self.supervisor_url = Some(url.into());
self
}
pub fn runner_name(mut self, name: impl Into<String>) -> Self {
self.runner_name = Some(name.into());
self
}
pub fn secret(mut self, secret: impl Into<String>) -> Self {
self.secret = Some(secret.into());
self
}
pub fn build(self) -> Result<SelfFreezoneClient, String> {
let supervisor_url = self.supervisor_url
.ok_or_else(|| "supervisor_url is required".to_string())?;
let secret = self.secret
.ok_or_else(|| "secret is required".to_string())?;
let runner_name = self.runner_name
.unwrap_or_else(|| "osiris".to_string());
Ok(SelfFreezoneClient {
supervisor_url,
runner_name,
secret,
})
}
}
impl Default for SelfFreezoneClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl SelfFreezoneClient {
/// Create a new builder
pub fn builder() -> SelfFreezoneClientBuilder {
SelfFreezoneClientBuilder::new()
}
/// Send verification email
///
/// # Arguments
/// * `email` - The email address to send verification to
/// * `code` - The verification code
/// * `url` - The verification URL
///
/// # Returns
/// Result with job ID or error message
pub async fn send_verification_email(
&self,
email: impl Into<String>,
code: impl Into<String>,
url: impl Into<String>,
) -> Result<String, String> {
let email = email.into();
let code = code.into();
let url = url.into();
// Email verification script template
let script_template = include_str!("../scripts/email_verification.rhai");
// Prepare variables
let mut variables = HashMap::new();
variables.insert("email".to_string(), email.clone());
variables.insert("code".to_string(), code.clone());
variables.insert("url".to_string(), url.clone());
// Substitute variables in template
let script = substitute_variables(script_template, &variables);
// Execute the script
self.execute_script(&script).await
}
/// Execute a Rhai script
async fn execute_script(&self, script: &str) -> Result<String, String> {
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
use web_sys::{Request, RequestInit, RequestMode, Response};
#[derive(Serialize)]
struct RunJobRequest {
runner_name: String,
script: String,
timeout: Option<u64>,
}
#[derive(Deserialize)]
struct RunJobResponse {
job_id: String,
status: String,
}
let request_body = RunJobRequest {
runner_name: self.runner_name.clone(),
script: script.to_string(),
timeout: Some(30),
};
let json_body = serde_json::to_string(&request_body)
.map_err(|e| format!("Failed to serialize request: {}", e))?;
let mut opts = RequestInit::new();
opts.method("POST");
opts.mode(RequestMode::Cors);
let headers = web_sys::Headers::new()
.map_err(|e| format!("Failed to create headers: {:?}", e))?;
headers
.set("Content-Type", "application/json")
.map_err(|e| format!("Failed to set content-type: {:?}", e))?;
headers
.set("Authorization", &format!("Bearer {}", self.secret))
.map_err(|e| format!("Failed to set authorization: {:?}", e))?;
opts.headers(&headers);
opts.body(Some(&wasm_bindgen::JsValue::from_str(&json_body)));
let url = format!("{}/api/v1/jobs/run", self.supervisor_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|e| format!("Failed to create request: {:?}", e))?;
let window = web_sys::window().ok_or("No window object")?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|e| format!("Fetch failed: {:?}", e))?;
let resp: Response = resp_value
.dyn_into()
.map_err(|_| "Response is not a Response object")?;
if !resp.ok() {
let status = resp.status();
let text = JsFuture::from(
resp.text()
.map_err(|e| format!("Failed to get error text: {:?}", e))?,
)
.await
.map_err(|e| format!("Failed to read error: {:?}", e))?;
let error_text = text
.as_string()
.unwrap_or_else(|| "Unknown error".to_string());
return Err(format!("HTTP {}: {}", status, error_text));
}
let json = JsFuture::from(
resp.json()
.map_err(|e| format!("Failed to parse JSON: {:?}", e))?,
)
.await
.map_err(|e| format!("Failed to read JSON: {:?}", e))?;
let response: RunJobResponse = serde_wasm_bindgen::from_value(json)
.map_err(|e| format!("Failed to deserialize response: {:?}", e))?;
Ok(response.job_id)
}
}
/// Substitute variables in a template string
///
/// Replaces `{{variable_name}}` with the corresponding value from the map
fn substitute_variables(template: &str, variables: &HashMap<String, String>) -> String {
let mut result = template.to_string();
for (key, value) in variables {
let placeholder = format!("{{{{{}}}}}", key);
result = result.replace(&placeholder, value);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_substitute_variables() {
let template = r#"
let email = "{{email}}";
let code = "{{code}}";
"#;
let mut vars = HashMap::new();
vars.insert("email".to_string(), "test@example.com".to_string());
vars.insert("code".to_string(), "123456".to_string());
let result = substitute_variables(template, &vars);
assert!(result.contains(r#"let email = "test@example.com";"#));
assert!(result.contains(r#"let code = "123456";"#));
}
#[test]
fn test_builder() {
let client = SelfFreezoneClient::builder()
.supervisor_url("http://localhost:8080")
.runner_name("osiris")
.secret("test-secret")
.build()
.unwrap();
assert_eq!(client.supervisor_url, "http://localhost:8080");
assert_eq!(client.runner_name, "osiris");
assert_eq!(client.secret, "test-secret");
}
}

483
components/src/identity.rs Normal file
View File

@@ -0,0 +1,483 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
use serde::{Deserialize, Serialize};
use crate::vault::Vault;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdentityConfig {
pub server_url: String,
pub app_name: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct IdentityData {
pub public_key: String,
pub email: String,
pub name: String,
pub created_at: Option<String>,
}
#[derive(Properties, PartialEq)]
pub struct IdentityProps {
pub config: IdentityConfig,
pub on_logout: Callback<()>,
}
pub struct Identity {
show_private_key: bool,
private_key: Option<String>,
loading_private_key: bool,
password_input: String,
show_password_input: bool,
error_message: Option<String>,
identity_data: Option<IdentityData>,
loading_identity: bool,
}
pub enum IdentityMsg {
TogglePrivateKeyVisibility,
UpdatePasswordInput(String),
TogglePasswordInput,
LoadPrivateKey,
PrivateKeyLoaded(String),
PrivateKeyLoadFailed(String),
CopyToClipboard(String),
Logout,
ClearError,
LoadIdentity,
IdentityLoaded(IdentityData),
IdentityLoadFailed(String),
}
impl Component for Identity {
type Message = IdentityMsg;
type Properties = IdentityProps;
fn create(ctx: &Context<Self>) -> Self {
// Automatically load identity data when component is created
ctx.link().send_message(IdentityMsg::LoadIdentity);
Self {
show_private_key: false,
private_key: None,
loading_private_key: false,
password_input: String::new(),
show_password_input: false,
error_message: None,
identity_data: None,
loading_identity: true,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
IdentityMsg::TogglePrivateKeyVisibility => {
self.show_private_key = !self.show_private_key;
true
}
IdentityMsg::UpdatePasswordInput(password) => {
self.password_input = password;
true
}
IdentityMsg::TogglePasswordInput => {
self.show_password_input = !self.show_password_input;
if !self.show_password_input {
self.password_input.clear();
}
true
}
IdentityMsg::LoadPrivateKey => {
if self.password_input.trim().is_empty() {
self.error_message = Some("Please enter your password".to_string());
return true;
}
self.loading_private_key = true;
self.error_message = None;
let password = self.password_input.clone();
let link = ctx.link().clone();
wasm_bindgen_futures::spawn_local(async move {
match Vault::retrieve_keypair(&password) {
Ok((private_key, _)) => {
link.send_message(IdentityMsg::PrivateKeyLoaded(private_key));
}
Err(e) => {
link.send_message(IdentityMsg::PrivateKeyLoadFailed(format!("Failed to load private key: {}", e)));
}
}
});
true
}
IdentityMsg::PrivateKeyLoaded(private_key) => {
self.private_key = Some(private_key);
self.loading_private_key = false;
self.password_input.clear();
self.show_password_input = false;
true
}
IdentityMsg::PrivateKeyLoadFailed(error) => {
self.error_message = Some(error);
self.loading_private_key = false;
true
}
IdentityMsg::CopyToClipboard(text) => {
if let Some(window) = web_sys::window() {
let navigator = window.navigator();
let clipboard = navigator.clipboard();
let _ = clipboard.write_text(&text);
web_sys::console::log_1(&"Copied to clipboard".into());
}
false
}
IdentityMsg::Logout => {
ctx.props().on_logout.emit(());
false
}
IdentityMsg::ClearError => {
self.error_message = None;
true
}
IdentityMsg::LoadIdentity => {
self.loading_identity = true;
self.error_message = None;
let server_url = ctx.props().config.server_url.clone();
let link = ctx.link().clone();
wasm_bindgen_futures::spawn_local(async move {
match Identity::fetch_identity_from_server(&server_url).await {
Ok(identity_data) => {
link.send_message(IdentityMsg::IdentityLoaded(identity_data));
}
Err(e) => {
link.send_message(IdentityMsg::IdentityLoadFailed(e));
}
}
});
true
}
IdentityMsg::IdentityLoaded(identity_data) => {
self.identity_data = Some(identity_data);
self.loading_identity = false;
true
}
IdentityMsg::IdentityLoadFailed(error) => {
self.error_message = Some(format!("Failed to load identity: {}", error));
self.loading_identity = false;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
if self.loading_identity {
return html! {
<div class="identity-container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
<div class="card shadow-lg border-0" style="border-radius: 16px;">
<div class="card-body text-center" style="padding: 3rem;">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">{"Loading identity..."}</span>
</div>
<p class="mt-3 text-muted">{"Loading your identity information..."}</p>
</div>
</div>
</div>
};
}
let identity = match &self.identity_data {
Some(data) => data,
None => {
return html! {
<div class="identity-container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
<div class="card shadow-lg border-0" style="border-radius: 16px;">
<div class="card-body text-center" style="padding: 3rem;">
<div class="alert alert-warning">
{"Failed to load identity data. "}
<button class="btn btn-link p-0" onclick={link.callback(|_| IdentityMsg::LoadIdentity)}>
{"Try again"}
</button>
</div>
{if let Some(error) = &self.error_message {
html! { <p class="text-danger small">{error}</p> }
} else {
html! {}
}}
</div>
</div>
</div>
};
}
};
html! {
<div class="identity-container" style="max-width: 800px; margin: 0 auto; padding: 2rem;">
<div class="card shadow-lg border-0" style="border-radius: 16px;">
<div class="card-header bg-primary text-white" style="border-radius: 16px 16px 0 0; padding: 2rem;">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="mb-0">{"Self-Sovereign Identity"}</h2>
<p class="mb-0 mt-2 opacity-75">{"Your decentralized digital identity"}</p>
</div>
<button type="button" class="btn btn-outline-light"
onclick={link.callback(|_| IdentityMsg::Logout)}>
<i class="bi bi-box-arrow-right me-2"></i>
{"Logout"}
</button>
</div>
</div>
<div class="card-body" style="padding: 2rem;">
{self.render_error_message(ctx)}
// Identity Information
<div class="row">
<div class="col-md-6">
<h5 class="mb-3">{"Identity Information"}</h5>
<div class="mb-3">
<label class="form-label fw-bold">{"Name"}</label>
<div class="p-2 bg-light rounded">
{&identity.name}
</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">{"Email"}</label>
<div class="p-2 bg-light rounded">
{&identity.email}
</div>
</div>
{if let Some(created_at) = &identity.created_at {
html! {
<div class="mb-3">
<label class="form-label fw-bold">{"Created"}</label>
<div class="p-2 bg-light rounded">
{created_at}
</div>
</div>
}
} else {
html! {}
}}
</div>
<div class="col-md-6">
<h5 class="mb-3">{"Cryptographic Keys"}</h5>
// Public Key
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label fw-bold mb-0">{"Public Key (Identity)"}</label>
<button type="button" class="btn btn-sm btn-outline-primary"
onclick={link.callback({
let public_key = identity.public_key.clone();
move |_| IdentityMsg::CopyToClipboard(public_key.clone())
})}>
<i class="bi bi-copy me-1"></i>
{"Copy"}
</button>
</div>
<div class="p-2 bg-success bg-opacity-10 border border-success rounded font-monospace small"
style="word-break: break-all;">
{&identity.public_key}
</div>
<small class="text-muted">{"This is your unique identity identifier"}</small>
</div>
// Private Key Section
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label fw-bold mb-0">{"Private Key"}</label>
<div>
{if self.private_key.is_some() {
html! {
<>
<button type="button" class="btn btn-sm btn-outline-secondary me-2"
onclick={link.callback(|_| IdentityMsg::TogglePrivateKeyVisibility)}>
{if self.show_private_key { "Hide" } else { "Show" }}
</button>
<button type="button" class="btn btn-sm btn-outline-primary"
onclick={link.callback({
let private_key = self.private_key.clone().unwrap_or_default();
move |_| IdentityMsg::CopyToClipboard(private_key.clone())
})}>
<i class="bi bi-copy me-1"></i>
{"Copy"}
</button>
</>
}
} else {
html! {
<button type="button" class="btn btn-sm btn-primary"
onclick={link.callback(|_| IdentityMsg::TogglePasswordInput)}>
<i class="bi bi-key me-1"></i>
{"Load Private Key"}
</button>
}
}}
</div>
</div>
{if let Some(private_key) = &self.private_key {
html! {
<div class="p-2 bg-warning bg-opacity-10 border border-warning rounded font-monospace small"
style="word-break: break-all;">
{if self.show_private_key {
private_key.clone()
} else {
"".repeat(64)
}}
</div>
}
} else if self.show_password_input {
html! {
<div class="input-group">
<input type="password" class="form-control"
value={self.password_input.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
IdentityMsg::UpdatePasswordInput(input.value())
})}
placeholder="Enter your password" />
<button type="button" class="btn btn-primary"
onclick={link.callback(|_| IdentityMsg::LoadPrivateKey)}
disabled={self.loading_private_key}>
{if self.loading_private_key {
html! {
<>
<span class="spinner-border spinner-border-sm me-2"></span>
{"Loading..."}
</>
}
} else {
html! { "Load" }
}}
</button>
</div>
}
} else {
html! {
<div class="p-2 bg-light rounded text-muted">
{"Private key is securely stored. Click 'Load Private Key' to decrypt and view."}
</div>
}
}}
<small class="text-muted">{"Keep your private key secure and never share it"}</small>
</div>
</div>
</div>
// Security Information
<hr class="my-4" />
<div class="row">
<div class="col-12">
<h5 class="mb-3">{"Security Information"}</h5>
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="bi bi-shield-check me-2"></i>
{"Your Identity is Secure"}
</h6>
<ul class="mb-0">
<li>{"Your private key is encrypted and stored locally in your browser"}</li>
<li>{"Your public key serves as your unique identity identifier"}</li>
<li>{"No personal data is stored on external servers"}</li>
<li>{"You have full control over your digital identity"}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
}
}
}
impl Identity {
async fn fetch_identity_from_server(server_url: &str) -> Result<IdentityData, String> {
use web_sys::{Request, RequestInit, RequestMode, Response};
use wasm_bindgen_futures::JsFuture;
// Get JWT token from localStorage
let jwt_token = web_sys::window()
.and_then(|w| w.local_storage().ok().flatten())
.and_then(|storage| storage.get_item("jwt_token").ok().flatten())
.ok_or("No JWT token found")?;
web_sys::console::log_1(&format!("Using JWT token: {}", &jwt_token[..std::cmp::min(50, jwt_token.len())]).into());
let mut opts = RequestInit::new();
opts.method("GET");
opts.mode(RequestMode::Cors);
let headers = js_sys::Object::new();
js_sys::Reflect::set(&headers, &"Authorization".into(), &format!("Bearer {}", jwt_token).into()).unwrap();
js_sys::Reflect::set(&headers, &"Content-Type".into(), &"application/json".into()).unwrap();
opts.headers(&headers);
let url = format!("{}/oauth/userinfo", server_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|_| "Failed to create request")?;
let window = web_sys::window().ok_or("No window object")?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| "Network request failed")?;
let resp: Response = resp_value.dyn_into()
.map_err(|_| "Failed to cast response")?;
if !resp.ok() {
let error_text = JsFuture::from(resp.text().map_err(|_| "Failed to get error response text")?)
.await
.map_err(|_| "Failed to parse error response text")?
.as_string()
.unwrap_or_default();
web_sys::console::log_1(&format!("Server error response: {}", error_text).into());
return Err(format!("Server returned error {}: {}", resp.status(), error_text));
}
let json = JsFuture::from(resp.json().map_err(|_| "Failed to get response JSON")?)
.await
.map_err(|_| "Failed to parse JSON response")?;
let response_text = js_sys::JSON::stringify(&json)
.map_err(|_| "Failed to stringify response")?
.as_string()
.ok_or("Failed to convert response to string")?;
let response: serde_json::Value = serde_json::from_str(&response_text)
.map_err(|_| "Failed to parse response JSON")?;
Ok(IdentityData {
public_key: response["public_key"].as_str().unwrap_or("").to_string(),
email: response["email"].as_str().unwrap_or("").to_string(),
name: response["name"].as_str().unwrap_or("Unknown").to_string(),
created_at: response["created_at"].as_str().map(|s| s.to_string()),
})
}
fn render_error_message(&self, ctx: &Context<Self>) -> Html {
if let Some(error) = &self.error_message {
let link = ctx.link();
html! {
<div class="alert alert-danger alert-dismissible fade show mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
<button type="button" class="btn-close"
onclick={link.callback(|_| IdentityMsg::ClearError)}></button>
</div>
}
} else {
html! {}
}
}
}

View File

@@ -1,5 +1,17 @@
pub mod registration;
pub mod crypto;
pub mod vault;
pub mod vault_manager;
pub mod login;
pub mod registration;
pub mod identity;
pub mod sign;
pub mod freezone_client;
pub use registration::{Registration, RegistrationConfig};
pub use crypto::*;
pub use vault::{Vault, VaultError, VaultJs};
pub use vault_manager::{VaultManager, VaultConfig};
pub use login::{Login, LoginConfig};
pub use registration::{Registration, RegistrationConfig};
pub use identity::*;
pub use sign::*;
pub use freezone_client::{SelfFreezoneClient, SelfFreezoneClientBuilder};

409
components/src/login.rs Normal file
View File

@@ -0,0 +1,409 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
use serde_json;
use wasm_bindgen_futures::spawn_local;
use crate::vault::{Vault, VaultError};
use crate::crypto::KeyPair;
use base64::{Engine as _, engine::general_purpose};
#[derive(Clone, PartialEq, Properties)]
pub struct LoginConfig {
pub server_url: String,
}
#[derive(Clone, PartialEq, Properties)]
pub struct LoginProps {
pub config: LoginConfig,
pub on_login_success: Callback<String>, // Callback with JWT token
}
#[derive(Clone, PartialEq)]
pub enum LoginStatus {
NotStarted,
Processing,
Success,
Failed(String),
}
pub struct Login {
password: String,
private_key: String,
status: LoginStatus,
show_password: bool,
show_private_key: bool,
has_stored_key: bool,
}
#[derive(Clone)]
pub enum LoginMsg {
UpdatePassword(String),
UpdatePrivateKey(String),
TogglePasswordVisibility,
TogglePrivateKeyVisibility,
SubmitLogin,
LoginSuccess(String), // JWT token
LoginFailed(String),
}
impl Component for Login {
type Message = LoginMsg;
type Properties = LoginProps;
fn create(_ctx: &Context<Self>) -> Self {
let has_stored_key = Vault::has_stored_key();
Self {
password: String::new(),
private_key: String::new(),
status: LoginStatus::NotStarted,
show_password: false,
show_private_key: false,
has_stored_key,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
LoginMsg::UpdatePassword(password) => {
self.password = password;
true
}
LoginMsg::UpdatePrivateKey(private_key) => {
self.private_key = private_key;
true
}
LoginMsg::TogglePasswordVisibility => {
self.show_password = !self.show_password;
true
}
LoginMsg::TogglePrivateKeyVisibility => {
self.show_private_key = !self.show_private_key;
true
}
LoginMsg::SubmitLogin => {
if self.validate_form() {
self.submit_login(ctx);
}
true
}
LoginMsg::LoginSuccess(jwt_token) => {
self.status = LoginStatus::Success;
// Store JWT token in localStorage for future use
if let Some(window) = web_sys::window() {
if let Ok(Some(storage)) = window.local_storage() {
let _ = storage.set_item("jwt_token", &jwt_token);
web_sys::console::log_1(&"JWT token stored in localStorage".into());
}
}
ctx.props().on_login_success.emit(jwt_token);
true
}
LoginMsg::LoginFailed(error) => {
self.status = LoginStatus::Failed(error);
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div>
<div style="padding: 2rem;">
<h4 class="mb-4">{"Sign In"}</h4>
{self.render_status_notification()}
<form onsubmit={link.callback(|e: SubmitEvent| {
e.prevent_default();
LoginMsg::SubmitLogin
})}>
<div class="mb-3">
<label class="form-label">
{if self.has_stored_key {
"Password (to decrypt stored key)"
} else {
"Password"
}}
</label>
<div class="input-group">
<input type={if self.show_password { "text" } else { "password" }}
class="form-control"
value={self.password.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
LoginMsg::UpdatePassword(input.value())
})}
placeholder={if self.has_stored_key {
"Enter password to decrypt your key"
} else {
"Enter your password"
}}
required=true />
<button type="button" class="btn btn-outline-secondary"
onclick={link.callback(|_| LoginMsg::TogglePasswordVisibility)}>
<i class={if self.show_password { "bi bi-eye-slash" } else { "bi bi-eye" }}></i>
</button>
</div>
</div>
{if !self.has_stored_key {
html! {
<div class="mb-4">
<label class="form-label">{"Private Key"}</label>
<div class="input-group">
<input type={if self.show_private_key { "text" } else { "password" }}
class="form-control font-monospace"
value={self.private_key.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
LoginMsg::UpdatePrivateKey(input.value())
})}
placeholder="Enter your private key"
required=true />
<button type="button" class="btn btn-outline-secondary"
onclick={link.callback(|_| LoginMsg::TogglePrivateKeyVisibility)}>
<i class={if self.show_private_key { "bi bi-eye-slash" } else { "bi bi-eye" }}></i>
</button>
</div>
<div class="form-text">
{"Your private key will be encrypted and stored securely in your browser after login."}
</div>
</div>
}
} else {
html! {}
}}
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg"
disabled={!self.validate_form() || matches!(self.status, LoginStatus::Processing)}>
{if matches!(self.status, LoginStatus::Processing) {
html! {
<>
<span class="spinner-border spinner-border-sm me-2"></span>
{"Signing In..."}
</>
}
} else {
html! { "Sign In" }
}}
</button>
</div>
</form>
<div class="mt-4">
<p class="text-muted small">
{"Forgot your password? "}
<a href="#" class="text-decoration-none">{"Reset it here"}</a>
</p>
</div>
</div>
</div>
}
}
}
impl Login {
fn validate_form(&self) -> bool {
let password_valid = !self.password.trim().is_empty();
if self.has_stored_key {
password_valid
} else {
password_valid && !self.private_key.trim().is_empty()
}
}
fn submit_login(&mut self, ctx: &Context<Self>) {
self.status = LoginStatus::Processing;
let password = self.password.clone();
let private_key = self.private_key.clone();
let has_stored_key = self.has_stored_key;
let server_url = ctx.props().config.server_url.clone();
let link = ctx.link().clone();
spawn_local(async move {
let (final_private_key, public_key) = if has_stored_key {
// Decrypt stored keypair
match Vault::retrieve_keypair(&password) {
Ok((stored_private_key, stored_public_key)) => {
web_sys::console::log_1(&"Successfully retrieved keypair from vault".into());
(stored_private_key, stored_public_key)
}
Err(e) => {
link.send_message(LoginMsg::LoginFailed(format!("Failed to decrypt stored key: {}", e)));
return;
}
}
} else {
// Derive public key from private key and store both encrypted
let keypair = match KeyPair::from_private_key(&private_key) {
Ok(keypair) => keypair,
Err(e) => {
link.send_message(LoginMsg::LoginFailed(format!("Invalid private key: {}", e)));
return;
}
};
match Vault::store_keypair(&private_key, &keypair.public_key, &password) {
Ok(_) => {
web_sys::console::log_1(&"Keypair stored securely in vault".into());
}
Err(e) => {
web_sys::console::log_1(&format!("Warning: Failed to store keypair in vault: {}", e).into());
}
}
(private_key, keypair.public_key)
};
// Authenticate with identity server using public key
match Self::authenticate_with_server(&server_url, &public_key, &final_private_key).await {
Ok(jwt_token) => {
web_sys::console::log_1(&format!("Authentication successful, received JWT token").into());
link.send_message(LoginMsg::LoginSuccess(jwt_token));
}
Err(e) => {
link.send_message(LoginMsg::LoginFailed(format!("Authentication failed: {}", e)));
}
}
});
}
async fn authenticate_with_server(server_url: &str, public_key: &str, private_key: &str) -> Result<String, String> {
use web_sys::{Request, RequestInit, RequestMode, Response};
use wasm_bindgen::JsValue;
use wasm_bindgen_futures::JsFuture;
// Create authentication challenge
let challenge = format!("auth_challenge_{}", js_sys::Date::now());
// Sign the challenge with private key
let signature = Self::create_auth_signature(&challenge, private_key, public_key)?;
// Prepare authentication request
let auth_request = serde_json::json!({
"grant_type": "client_credentials",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": signature,
"public_key": public_key,
"challenge": challenge,
"scope": "openid profile email"
});
let mut opts = RequestInit::new();
opts.method("POST");
opts.mode(RequestMode::Cors);
let headers = js_sys::Object::new();
js_sys::Reflect::set(&headers, &"Content-Type".into(), &"application/json".into()).unwrap();
opts.headers(&headers);
opts.body(Some(&JsValue::from_str(&auth_request.to_string())));
let url = format!("{}/oauth/token", server_url);
let request = Request::new_with_str_and_init(&url, &opts)
.map_err(|_| "Failed to create request")?;
let window = web_sys::window().ok_or("No window object")?;
let resp_value = JsFuture::from(window.fetch_with_request(&request))
.await
.map_err(|_| "Network request failed")?;
let resp: Response = resp_value.dyn_into()
.map_err(|_| "Failed to cast response")?;
if !resp.ok() {
// Get error response body for debugging
let error_json = JsFuture::from(resp.json().map_err(|_| "Failed to get error response JSON")?)
.await
.map_err(|_| "Failed to parse error JSON response")?;
let error_text = js_sys::JSON::stringify(&error_json)
.map_err(|_| "Failed to stringify error response")?
.as_string()
.ok_or("Failed to convert error response to string")?;
return Err(format!("Authentication failed with status: {}\n\nrequest response: {}", resp.status(), error_text));
}
let json = JsFuture::from(resp.json().map_err(|_| "Failed to get response JSON")?)
.await
.map_err(|_| "Failed to parse JSON response")?;
let response_text = js_sys::JSON::stringify(&json)
.map_err(|_| "Failed to stringify response")?
.as_string()
.ok_or("Failed to convert response to string")?;
let response: serde_json::Value = serde_json::from_str(&response_text)
.map_err(|_| "Failed to parse response JSON")?;
response["access_token"]
.as_str()
.map(|s| s.to_string())
.ok_or("No access token in response".to_string())
}
fn create_auth_signature(challenge: &str, private_key: &str, public_key: &str) -> Result<String, String> {
use sha2::{Sha256, Digest};
// Create JWT header
let header = serde_json::json!({
"alg": "HS256",
"typ": "JWT"
});
// Create JWT payload with challenge - subject should be public key
let payload = serde_json::json!({
"iss": "self-sovereign-identity",
"sub": public_key, // Subject is the public key (user identifier)
"aud": "identity-server",
"exp": (js_sys::Date::now() / 1000.0) as u64 + 3600, // 1 hour expiry
"iat": (js_sys::Date::now() / 1000.0) as u64,
"scope": "openid profile email",
"challenge": challenge
});
let header_b64 = general_purpose::STANDARD.encode(header.to_string());
let payload_b64 = general_purpose::STANDARD.encode(payload.to_string());
let unsigned_token = format!("{}.{}", header_b64, payload_b64);
// Sign with private key (simplified HMAC)
let mut hasher = Sha256::new();
hasher.update(unsigned_token.as_bytes());
hasher.update(private_key.as_bytes());
let signature = hasher.finalize();
let signature_b64 = general_purpose::STANDARD.encode(signature);
Ok(format!("{}.{}", unsigned_token, signature_b64))
}
fn render_status_notification(&self) -> Html {
match &self.status {
LoginStatus::NotStarted => html! {},
LoginStatus::Processing => html! {
<div class="alert alert-info mb-3">
<i class="bi bi-hourglass-split me-2"></i>
{"Signing you in..."}
</div>
},
LoginStatus::Success => html! {
<div class="alert alert-success mb-3">
<i class="bi bi-check-circle-fill me-2"></i>
{"Successfully signed in! Redirecting..."}
</div>
},
LoginStatus::Failed(error) => html! {
<div class="alert alert-danger mb-3">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{error}
</div>
},
}
}
}

View File

@@ -4,10 +4,11 @@ use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use serde::{Deserialize, Serialize};
use gloo_timers::callback::Timeout;
use crate::crypto::{generate_keypair, encrypt_private_key, copy_to_clipboard, KeyPair, EncryptedPrivateKey};
use k256::{SecretKey, PublicKey};
use crate::crypto::KeyPair;
use crate::vault::Vault;
use k256::SecretKey;
use k256::elliptic_curve::sec1::ToEncodedPoint;
use rand::rngs::OsRng;
use sha2::{Sha256, Digest};
use hex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -37,16 +38,24 @@ pub enum RegistrationStep {
pub struct RegistrationProps {
pub config: RegistrationConfig,
pub on_complete: Callback<(String, String)>, // (email, public_key)
#[prop_or_default]
pub on_registration_success: Option<Callback<String>>, // Called with public_key when registration is successful
#[prop_or_default]
pub on_name_change: Option<Callback<String>>,
#[prop_or_default]
pub on_email_change: Option<Callback<String>>,
#[prop_or_default]
pub on_form_change: Option<Callback<(String, String)>>, // (name, email)
}
pub struct Registration {
// Form data
name: String,
email: String,
password: String,
// Key management
keypair: Option<KeyPair>,
secret_phrase: String,
generated_private_key: Option<String>,
generated_public_key: Option<String>,
private_key_input: String,
@@ -60,6 +69,7 @@ pub struct Registration {
// UI state
current_step: RegistrationStep,
show_private_key: bool,
show_password: bool,
errors: Vec<String>,
processing: bool,
}
@@ -67,7 +77,7 @@ pub struct Registration {
pub enum RegistrationMsg {
UpdateName(String),
UpdateEmail(String),
UpdateSecretPhrase(String),
UpdatePassword(String),
UpdatePrivateKeyInput(String),
SendEmailVerification,
@@ -77,6 +87,7 @@ pub enum RegistrationMsg {
GenerateKeys,
UpdateKeyConfirmation(String),
TogglePrivateKeyVisibility,
TogglePasswordVisibility,
CopyPrivateKey,
NextStep,
@@ -95,8 +106,8 @@ impl Component for Registration {
Self {
name: String::new(),
email: String::new(),
password: String::new(),
keypair: None,
secret_phrase: String::new(),
generated_private_key: None,
generated_public_key: None,
private_key_input: String::new(),
@@ -106,6 +117,7 @@ impl Component for Registration {
event_source: None,
current_step: RegistrationStep::Identity,
show_private_key: false,
show_password: false,
errors: Vec::new(),
processing: false,
}
@@ -115,6 +127,14 @@ impl Component for Registration {
match msg {
RegistrationMsg::UpdateName(name) => {
self.name = name;
// Emit name change event
if let Some(callback) = &ctx.props().on_name_change {
callback.emit(self.name.clone());
}
// Emit form change event
if let Some(callback) = &ctx.props().on_form_change {
callback.emit((self.name.clone(), self.email.clone()));
}
true
}
RegistrationMsg::UpdateEmail(email) => {
@@ -122,10 +142,18 @@ impl Component for Registration {
if self.email_status == EmailVerificationStatus::Verified {
self.email_status = EmailVerificationStatus::NotStarted;
}
// Emit email change event
if let Some(callback) = &ctx.props().on_email_change {
callback.emit(self.email.clone());
}
// Emit form change event
if let Some(callback) = &ctx.props().on_form_change {
callback.emit((self.name.clone(), self.email.clone()));
}
true
}
RegistrationMsg::UpdateSecretPhrase(secret) => {
self.secret_phrase = secret;
RegistrationMsg::UpdatePassword(password) => {
self.password = password;
true
}
RegistrationMsg::UpdatePrivateKeyInput(value) => {
@@ -157,7 +185,7 @@ impl Component for Registration {
true
}
RegistrationMsg::GenerateKeys => {
self.generate_secp256k1_keys();
self.generate_keys_from_password();
true
}
RegistrationMsg::CopyPrivateKey => {
@@ -168,6 +196,10 @@ impl Component for Registration {
self.show_private_key = !self.show_private_key;
true
}
RegistrationMsg::TogglePasswordVisibility => {
self.show_password = !self.show_password;
true
}
RegistrationMsg::NextStep => {
// No longer needed - single step form
true
@@ -179,9 +211,36 @@ impl Component for Registration {
true
}
RegistrationMsg::RegistrationComplete => {
web_sys::console::log_1(&"RegistrationComplete message received".into());
self.current_step = RegistrationStep::Complete;
if let Some(keypair) = &self.keypair {
// Store the private key in the vault with the user's password
if let (Some(private_key), Some(keypair)) = (&self.generated_private_key, &self.keypair) {
web_sys::console::log_1(&format!("Attempting to store private key. Password length: {}", self.password.len()).into());
if !self.password.is_empty() {
web_sys::console::log_1(&"Storing private key in vault...".into());
match Vault::store_keypair(&private_key, &keypair.public_key, &self.password) {
Ok(_) => {
web_sys::console::log_1(&"✅ Private key securely stored in vault".into());
}
Err(e) => {
web_sys::console::log_1(&format!("❌ Failed to store private key in vault: {}", e).into());
self.errors.push(format!("Warning: Failed to store private key securely: {}", e));
}
}
} else {
web_sys::console::log_1(&"❌ Password is empty, cannot store private key".into());
}
ctx.props().on_complete.emit((self.email.clone(), keypair.public_key.clone()));
// Trigger the registration success callback with the public key
if let Some(callback) = &ctx.props().on_registration_success {
callback.emit(keypair.public_key.clone());
}
} else {
web_sys::console::log_1(&"❌ Missing private key or keypair for storage".into());
}
true
}
@@ -203,18 +262,9 @@ impl Component for Registration {
}
html! {
<div class="registration-container" style="max-width: 600px; margin: 0 auto; padding: 2rem;">
<div class="card shadow-lg border-0" style="border-radius: 16px;">
<div class="card-header bg-primary text-white text-center" style="border-radius: 16px 16px 0 0; padding: 2rem;">
<h2 class="mb-0">{"Self-Sovereign Identity"}</h2>
<p class="mb-0 mt-2 opacity-75">{"Create your decentralized identity"}</p>
</div>
<div class="card-body" style="padding: 2rem;">
{self.render_errors(ctx)}
{self.render_identity_step(ctx)}
</div>
</div>
<div class="registration-container" style="padding: 2rem;">
{self.render_errors(ctx)}
{self.render_identity_step(ctx)}
</div>
}
}
@@ -240,7 +290,8 @@ impl Registration {
}
}
fn render_status_notification(&self) -> Html {
fn render_status_notification(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
let (alert_class, icon, message) = if self.processing {
("alert-info", "bi-hourglass-split", "Processing registration...")
} else if self.name.trim().is_empty() {
@@ -253,8 +304,8 @@ impl Registration {
("alert-info", "bi-envelope-check", "Check your email and click the verification link")
} else if self.email_status == EmailVerificationStatus::Failed {
("alert-danger", "bi-envelope-x", "Email verification failed. Please try again")
} else if self.secret_phrase.trim().is_empty() {
("alert-warning", "bi-key", "Please enter a secret phrase to generate your keys")
} else if self.password.trim().is_empty() {
("alert-warning", "bi-key", "Please enter a password to generate your keys")
} else if self.generated_private_key.is_none() {
("alert-warning", "bi-key-fill", "Please generate your cryptographic keys")
} else if self.key_confirmation.is_empty() {
@@ -265,10 +316,35 @@ impl Registration {
("alert-success", "bi-shield-check", "All requirements completed! Ready to register")
};
let is_ready = self.validate_complete_form();
html! {
<div class={format!("alert {} mb-3", alert_class)}>
<i class={format!("bi {} me-2", icon)}></i>
{message}
{if is_ready {
html! {
<div class="d-grid mt-3">
<button type="button" class="btn btn-primary btn-lg"
onclick={link.callback(|_| RegistrationMsg::SubmitRegistration)}
disabled={self.processing}>
{if self.processing {
html! {
<>
<span class="spinner-border spinner-border-sm me-2"></span>
{"Registering..."}
</>
}
} else {
html! { "Complete Registration" }
}}
</button>
</div>
}
} else {
html! {}
}}
</div>
}
}
@@ -334,23 +410,28 @@ impl Registration {
</div>
<div class="mb-4">
<label class="form-label">{"Secret Phrase for Key Generation"}</label>
<div class="d-flex align-items-center gap-2">
<input type="password" class="form-control"
value={self.secret_phrase.clone()}
<label class="form-label">{"Password"}</label>
<div class="input-group">
<input type={if self.show_password { "text" } else { "password" }}
class="form-control"
value={self.password.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
RegistrationMsg::UpdateSecretPhrase(input.value())
RegistrationMsg::UpdatePassword(input.value())
})}
placeholder="Enter a secret phrase to generate your keys" />
placeholder="Enter a secure password" />
<button type="button" class="btn btn-outline-secondary"
onclick={link.callback(|_| RegistrationMsg::TogglePasswordVisibility)}>
<i class={if self.show_password { "bi bi-eye-slash" } else { "bi bi-eye" }}></i>
</button>
<button type="button" class="btn btn-outline-primary"
onclick={link.callback(|_| RegistrationMsg::GenerateKeys)}
disabled={self.secret_phrase.trim().is_empty()}>
disabled={self.password.trim().is_empty()}>
<i class="bi bi-key me-1"></i>
{"Generate"}
{"Generate Keys"}
</button>
</div>
<div class="form-text">{"This password will be used to generate your cryptographic keys and encrypt them for secure storage."}</div>
</div>
<div class="mb-4">
@@ -402,24 +483,7 @@ impl Registration {
html! {}
}}
{self.render_status_notification()}
<div class="d-grid">
<button type="button" class="btn btn-primary btn-lg"
onclick={link.callback(|_| RegistrationMsg::SubmitRegistration)}
disabled={!self.validate_complete_form()}>
{if self.processing {
html! {
<>
<span class="spinner-border spinner-border-sm me-2"></span>
{"Registering..."}
</>
}
} else {
html! { "Complete Registration" }
}}
</button>
</div>
{self.render_status_notification(ctx)}
</div>
}
}
@@ -476,153 +540,6 @@ impl Registration {
}
}
fn render_key_generation_step(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="step-content">
<h4 class="mb-4">{"Generate Secp256k1 Keys"}</h4>
<div class="mb-3">
<label class="form-label">{"Secret Phrase"}</label>
<input type="password" class="form-control"
value={self.secret_phrase.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
RegistrationMsg::UpdateSecretPhrase(input.value())
})}
placeholder="Enter a secret phrase to generate your keys" />
</div>
<div class="mb-4">
<button type="button" class="btn btn-primary"
onclick={link.callback(|_| RegistrationMsg::GenerateKeys)}
disabled={self.secret_phrase.trim().is_empty()}>
<i class="bi bi-key me-2"></i>
{"Generate Keys"}
</button>
</div>
{if let Some(private_key) = &self.generated_private_key {
html! {
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<label class="form-label mb-0">{"Generated Private Key"}</label>
<div>
<button type="button" class="btn btn-sm btn-outline-secondary me-2"
onclick={link.callback(|_| RegistrationMsg::TogglePrivateKeyVisibility)}>
{if self.show_private_key { "Hide" } else { "Show" }}
</button>
<button type="button" class="btn btn-sm btn-primary"
onclick={link.callback(|_| RegistrationMsg::CopyPrivateKey)}>
<i class="bi bi-copy me-1"></i>
{"Copy"}
</button>
</div>
</div>
<div class="bg-dark text-light p-3 rounded font-monospace small"
style="word-break: break-all;">
{if self.show_private_key {
private_key.clone()
} else {
"••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••••".to_string()
}}
</div>
</div>
}
} else {
html! {}
}}
</div>
}
}
fn render_key_confirmation_step(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="step-content">
<h4 class="mb-4">{"Confirm Private Key"}</h4>
<p class="mb-4">{"Please enter your private key to confirm you have saved it securely:"}</p>
<div class="mb-4">
<div class="d-flex align-items-center gap-2">
<input type="password" class="form-control font-monospace"
value={self.private_key_input.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
RegistrationMsg::UpdatePrivateKeyInput(input.value())
})}
placeholder="Enter your private key..." />
{if !self.private_key_input.is_empty() {
let is_correct = self.generated_private_key.as_ref()
.map(|pk| pk == &self.private_key_input.trim())
.unwrap_or(false);
if is_correct {
html! {
<button type="button" class="btn btn-success"
onclick={link.callback(|_| RegistrationMsg::SubmitRegistration)}
disabled={self.processing}>
{if self.processing {
html! {
<>
<span class="spinner-border spinner-border-sm me-2"></span>
{"Registering..."}
</>
}
} else {
html! { "Register" }
}}
</button>
}
} else {
html! {
<button type="button" class="btn btn-outline-danger" disabled=true>
{"Invalid Key"}
</button>
}
}
} else {
html! {
<button type="button" class="btn btn-outline-secondary" disabled=true>
{"Register"}
</button>
}
}}
</div>
{if !self.private_key_input.is_empty() {
let is_correct = self.generated_private_key.as_ref()
.map(|pk| pk == &self.private_key_input.trim())
.unwrap_or(false);
if is_correct {
html! {
<div class="alert alert-success mt-2">
<i class="bi bi-check-circle me-2"></i>
{"Private key confirmed successfully!"}
</div>
}
} else {
html! {
<div class="alert alert-danger mt-2">
<i class="bi bi-x-circle me-2"></i>
{"Private key does not match. Please try again."}
</div>
}
}
} else {
html! {}
}}
</div>
</div>
}
}
fn render_complete_step(&self) -> Html {
html! {
@@ -646,6 +563,7 @@ impl Registration {
fn validate_complete_form(&self) -> bool {
!self.name.trim().is_empty() &&
!self.email.trim().is_empty() &&
!self.password.trim().is_empty() &&
self.email_status == EmailVerificationStatus::Verified &&
self.generated_private_key.is_some() &&
if let Some(private_key) = &self.generated_private_key {
@@ -655,7 +573,6 @@ impl Registration {
}
}
fn send_email_verification(&mut self, ctx: &Context<Self>) {
self.email_status = EmailVerificationStatus::Pending;
@@ -718,27 +635,34 @@ impl Registration {
});
}
fn generate_secp256k1_keys(&mut self) {
use sha2::{Sha256, Digest};
// Use secret phrase to derive private key deterministically
let mut hasher = Sha256::new();
hasher.update(self.secret_phrase.as_bytes());
let hash = hasher.finalize();
// Generate secp256k1 keypair from hash
match SecretKey::from_slice(&hash) {
Ok(secret_key) => {
let public_key = secret_key.public_key();
// Store keys as hex strings
self.generated_private_key = Some(hex::encode(secret_key.to_bytes()));
self.generated_public_key = Some(hex::encode(public_key.to_encoded_point(false).as_bytes()));
}
Err(_) => {
self.errors.push("Failed to generate valid private key from secret phrase".to_string());
}
fn generate_keys_from_password(&mut self) {
if self.password.trim().is_empty() {
self.errors.push("Please enter a password".to_string());
return;
}
// Use the password as seed for key generation
let mut hasher = sha2::Sha256::new();
hasher.update(self.password.as_bytes());
let seed = hasher.finalize();
// Generate private key from seed
let secret_key = SecretKey::from_bytes(&seed).unwrap();
let public_key = secret_key.public_key();
// Convert to hex strings
let private_key_hex = hex::encode(secret_key.to_bytes());
let public_key_hex = hex::encode(public_key.to_encoded_point(false).as_bytes());
self.generated_private_key = Some(private_key_hex.clone());
self.generated_public_key = Some(public_key_hex.clone());
self.keypair = Some(KeyPair {
private_key: private_key_hex,
public_key: public_key_hex,
});
web_sys::console::log_1(&format!("Generated keys from password").into());
}
fn copy_private_key(&mut self) {
@@ -757,24 +681,48 @@ impl Registration {
let server_url = ctx.props().config.server_url.clone();
let email = self.email.clone();
let name = self.name.clone();
let public_key = self.generated_public_key.as_ref().unwrap().clone();
let link = ctx.link().clone();
wasm_bindgen_futures::spawn_local(async move {
let _url = format!("{}/api/register", server_url);
let _body = serde_json::json!({
let url = format!("{}/api/register", server_url);
let body = serde_json::json!({
"email": email,
"name": name,
"public_key": public_key
});
// In a real implementation, make HTTP request here
web_sys::console::log_1(&format!("Registering: {} with key: {}", email, public_key).into());
// Make HTTP request to server
let mut opts = web_sys::RequestInit::new();
opts.method("POST");
opts.mode(web_sys::RequestMode::Cors);
// Simulate API call
let timeout = Timeout::new(2000, move || {
link.send_message(RegistrationMsg::RegistrationComplete);
});
timeout.forget();
let headers = web_sys::Headers::new().unwrap();
headers.set("Content-Type", "application/json").unwrap();
opts.headers(&headers);
opts.body(Some(&wasm_bindgen::JsValue::from_str(&body.to_string())));
let request = web_sys::Request::new_with_str_and_init(&url, &opts).unwrap();
let window = web_sys::window().unwrap();
match wasm_bindgen_futures::JsFuture::from(window.fetch_with_request(&request)).await {
Ok(response) => {
let resp: web_sys::Response = response.dyn_into().unwrap();
if resp.ok() {
web_sys::console::log_1(&format!("Registration successful for: {}", email).into());
link.send_message(RegistrationMsg::RegistrationComplete);
} else {
web_sys::console::error_1(&format!("Registration failed with status: {}", resp.status()).into());
link.send_message(RegistrationMsg::RegistrationFailed("Registration failed".to_string()));
}
}
Err(e) => {
web_sys::console::error_1(&format!("Registration request failed: {:?}", e).into());
link.send_message(RegistrationMsg::RegistrationFailed("Network error".to_string()));
}
}
});
}
}
}

318
components/src/sign.rs Normal file
View File

@@ -0,0 +1,318 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::vault::{Vault, VaultError};
use crate::crypto::KeyPair;
use sha2::{Sha256, Digest};
#[derive(Clone, PartialEq, Properties)]
pub struct SignConfig {
pub server_url: String,
pub app_name: String,
}
#[derive(Clone, PartialEq, Properties)]
pub struct SignProps {
pub config: SignConfig,
pub on_signature_complete: Callback<(String, String)>, // (plaintext, signature)
}
#[derive(Clone, PartialEq)]
pub enum SignStatus {
NotStarted,
Processing,
Success(String), // signature
Failed(String),
}
pub struct Sign {
plaintext: String,
password: String,
status: SignStatus,
show_password: bool,
signature: Option<String>,
}
#[derive(Clone)]
pub enum SignMsg {
UpdatePlaintext(String),
UpdatePassword(String),
TogglePasswordVisibility,
SubmitSign,
SignSuccess(String, String), // (plaintext, signature)
SignFailed(String),
ClearSignature,
}
impl Component for Sign {
type Message = SignMsg;
type Properties = SignProps;
fn create(_ctx: &Context<Self>) -> Self {
Self {
plaintext: String::new(),
password: String::new(),
status: SignStatus::NotStarted,
show_password: false,
signature: None,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
SignMsg::UpdatePlaintext(plaintext) => {
self.plaintext = plaintext;
true
}
SignMsg::UpdatePassword(password) => {
self.password = password;
true
}
SignMsg::TogglePasswordVisibility => {
self.show_password = !self.show_password;
true
}
SignMsg::SubmitSign => {
if self.validate_form() {
self.submit_sign(ctx);
}
true
}
SignMsg::SignSuccess(plaintext, signature) => {
self.status = SignStatus::Success(signature.clone());
self.signature = Some(signature.clone());
ctx.props().on_signature_complete.emit((plaintext, signature));
true
}
SignMsg::SignFailed(error) => {
self.status = SignStatus::Failed(error);
true
}
SignMsg::ClearSignature => {
self.signature = None;
self.status = SignStatus::NotStarted;
self.plaintext.clear();
self.password.clear();
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow">
<div class="card-body p-5">
<div class="text-center mb-4">
<h2 class="card-title">{"Digital Signature"}</h2>
<p class="text-muted">
{"Sign any plaintext using your encrypted private key"}
</p>
</div>
{self.render_status_notification()}
<form onsubmit={link.callback(|e: SubmitEvent| {
e.prevent_default();
SignMsg::SubmitSign
})}>
<div class="mb-3">
<label for="plaintext" class="form-label">{"Plaintext to Sign"}</label>
<textarea
id="plaintext"
class="form-control"
rows="4"
placeholder="Enter the text you want to sign..."
value={self.plaintext.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
SignMsg::UpdatePlaintext(input.value())
})}
disabled={matches!(self.status, SignStatus::Processing)}
/>
</div>
<div class="mb-4">
<label for="password" class="form-label">{"Password"}</label>
<div class="input-group">
<input
type={if self.show_password { "text" } else { "password" }}
id="password"
class="form-control"
placeholder="Enter your password to decrypt private key"
value={self.password.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
SignMsg::UpdatePassword(input.value())
})}
disabled={matches!(self.status, SignStatus::Processing)}
/>
<button
type="button"
class="btn btn-outline-secondary"
onclick={link.callback(|_| SignMsg::TogglePasswordVisibility)}
disabled={matches!(self.status, SignStatus::Processing)}
>
<i class={if self.show_password { "bi bi-eye-slash" } else { "bi bi-eye" }}></i>
</button>
</div>
</div>
<div class="d-grid gap-2">
<button
type="submit"
class="btn btn-primary btn-lg"
disabled={!self.validate_form() || matches!(self.status, SignStatus::Processing)}
>
{if matches!(self.status, SignStatus::Processing) {
html! {
<>
<span class="spinner-border spinner-border-sm me-2" role="status"></span>
{"Signing..."}
</>
}
} else {
html! {
<>
<i class="bi bi-pen me-2"></i>
{"Sign Text"}
</>
}
}}
</button>
</div>
</form>
{if let Some(signature) = &self.signature {
html! {
<div class="mt-4">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title">{"Digital Signature"}</h6>
<div class="mb-3">
<small class="text-muted">{"Signature:"}</small>
<div class="font-monospace small bg-white p-2 border rounded" style="word-break: break-all;">
{signature}
</div>
</div>
<div class="d-flex gap-2">
<button
type="button"
class="btn btn-sm btn-outline-primary"
onclick={link.callback({
let sig = signature.clone();
move |_| {
if let Some(window) = web_sys::window() {
let navigator = window.navigator();
let clipboard = navigator.clipboard();
let _ = clipboard.write_text(&sig);
}
SignMsg::UpdatePlaintext(String::new()) // Dummy message to trigger re-render
}
})}
>
<i class="bi bi-clipboard me-1"></i>
{"Copy Signature"}
</button>
<button
type="button"
class="btn btn-sm btn-outline-secondary"
onclick={link.callback(|_| SignMsg::ClearSignature)}
>
<i class="bi bi-arrow-clockwise me-1"></i>
{"Sign Another"}
</button>
</div>
</div>
</div>
</div>
}
} else {
html! {}
}}
</div>
</div>
</div>
</div>
</div>
}
}
}
impl Sign {
fn validate_form(&self) -> bool {
!self.plaintext.trim().is_empty() && !self.password.trim().is_empty()
}
fn submit_sign(&mut self, ctx: &Context<Self>) {
self.status = SignStatus::Processing;
let plaintext = self.plaintext.clone();
let password = self.password.clone();
let link = ctx.link().clone();
spawn_local(async move {
// Try to decrypt stored private key with password
let (private_key, _) = match Vault::retrieve_keypair(&password) {
Ok((private_key, public_key)) => (private_key, public_key),
Err(e) => {
link.send_message(SignMsg::SignFailed(format!("Failed to retrieve private key: {}", e)));
return;
}
};
// Create signature using private key
match Self::create_signature(&plaintext, &private_key) {
Ok(signature) => {
link.send_message(SignMsg::SignSuccess(plaintext, signature));
}
Err(e) => {
link.send_message(SignMsg::SignFailed(format!("Signature creation failed: {}", e)));
}
}
});
}
fn create_signature(plaintext: &str, private_key: &str) -> Result<String, String> {
// Create a simple signature by hashing the plaintext with the private key
// In production, use proper ECDSA signing with secp256k1
let mut hasher = Sha256::new();
hasher.update(plaintext.as_bytes());
hasher.update(private_key.as_bytes());
hasher.update(b"signature");
let hash = hasher.finalize();
// Create a deterministic signature format
let signature = format!("sig_{}", hex::encode(hash));
Ok(signature)
}
fn render_status_notification(&self) -> Html {
match &self.status {
SignStatus::NotStarted => html! {},
SignStatus::Processing => html! {
<div class="alert alert-info mb-3">
<i class="bi bi-hourglass-split me-2"></i>
{"Creating digital signature..."}
</div>
},
SignStatus::Success(_) => html! {
<div class="alert alert-success mb-3">
<i class="bi bi-check-circle-fill me-2"></i>
{"Digital signature created successfully!"}
</div>
},
SignStatus::Failed(error) => html! {
<div class="alert alert-danger mb-3">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
{error}
</div>
},
}
}
}

281
components/src/vault.rs Normal file
View File

@@ -0,0 +1,281 @@
use wasm_bindgen::prelude::*;
use web_sys::{window, Storage};
use serde::{Deserialize, Serialize};
use serde_json;
use base64::{Engine as _, engine::general_purpose};
use rand::RngCore;
use aes_gcm::{
aead::{Aead, KeyInit},
Aes256Gcm, Nonce
};
use aes_gcm::aes::Aes256;
use aes_gcm::Key;
use pbkdf2::pbkdf2_hmac_array;
use sha2::Sha256;
const STORAGE_KEY: &str = "self_vault_encrypted_key";
const PBKDF2_ITERATIONS: u32 = 100_000;
#[derive(Serialize, Deserialize)]
struct EncryptedKeyData {
private_key_ciphertext: Vec<u8>,
public_key_ciphertext: Vec<u8>,
salt: Vec<u8>,
nonce: Vec<u8>,
}
#[derive(Debug)]
pub enum VaultError {
StorageNotAvailable,
EncryptionError,
DecryptionError,
KeyNotFound,
InvalidPassword,
SerializationError,
}
impl std::fmt::Display for VaultError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
VaultError::StorageNotAvailable => write!(f, "Browser storage not available"),
VaultError::EncryptionError => write!(f, "Failed to encrypt private key"),
VaultError::DecryptionError => write!(f, "Failed to decrypt private key"),
VaultError::KeyNotFound => write!(f, "No stored private key found"),
VaultError::InvalidPassword => write!(f, "Invalid password for stored key"),
VaultError::SerializationError => write!(f, "Failed to serialize/deserialize data"),
}
}
}
pub struct Vault;
impl Vault {
/// Store a private key securely in browser storage, encrypted with the given password
pub fn store_keypair(private_key: &str, public_key: &str, password: &str) -> Result<(), VaultError> {
web_sys::console::log_1(&"🔐 Vault::store_private_key called".into());
// Generate random salt and nonce
let mut salt = [0u8; 32];
let mut nonce_bytes = [0u8; 12];
Self::fill_random(&mut salt)?;
Self::fill_random(&mut nonce_bytes)?;
web_sys::console::log_1(&"🔐 Generated salt and nonce".into());
// Derive encryption key from password using PBKDF2
let key_bytes = pbkdf2_hmac_array::<Sha256, 32>(password.as_bytes(), &salt, PBKDF2_ITERATIONS);
// Encrypt both private and public keys
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
let nonce = Nonce::from_slice(&nonce_bytes);
let private_key_ciphertext = cipher.encrypt(nonce, private_key.as_bytes())
.map_err(|_| VaultError::EncryptionError)?;
let public_key_ciphertext = cipher.encrypt(nonce, public_key.as_bytes())
.map_err(|_| VaultError::EncryptionError)?;
// Prepare data for storage
let encrypted_data = EncryptedKeyData {
private_key_ciphertext,
public_key_ciphertext,
salt: salt.to_vec(),
nonce: nonce_bytes.to_vec(),
};
// Serialize and encode to base64
let json_data = serde_json::to_string(&encrypted_data)
.map_err(|_| VaultError::SerializationError)?;
let encoded_data = general_purpose::STANDARD.encode(json_data);
// Store in browser storage
web_sys::console::log_1(&"🔐 Storing encrypted data in localStorage".into());
Self::get_storage()?
.set_item(STORAGE_KEY, &encoded_data)
.map_err(|_| VaultError::StorageNotAvailable)?;
web_sys::console::log_1(&"🔐 Successfully stored encrypted keypair".into());
Ok(())
}
/// Retrieve and decrypt both private and public keys from browser storage using the given password
pub fn retrieve_keypair(password: &str) -> Result<(String, String), VaultError> {
// Get encrypted data from storage
let storage = Self::get_storage()?;
let encoded_data = storage
.get_item(STORAGE_KEY)
.map_err(|_| VaultError::StorageNotAvailable)?
.ok_or(VaultError::KeyNotFound)?;
// Decode and deserialize
let json_data = general_purpose::STANDARD.decode(encoded_data)
.map_err(|_| VaultError::SerializationError)?;
let json_str = String::from_utf8(json_data)
.map_err(|_| VaultError::SerializationError)?;
let encrypted_data: EncryptedKeyData = serde_json::from_str(&json_str)
.map_err(|_| VaultError::SerializationError)?;
// Derive decryption key from password
let key_bytes = pbkdf2_hmac_array::<Sha256, 32>(
password.as_bytes(),
&encrypted_data.salt,
PBKDF2_ITERATIONS
);
// Decrypt both private and public keys
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
let nonce = Nonce::from_slice(&encrypted_data.nonce);
let private_key_bytes = cipher.decrypt(nonce, encrypted_data.private_key_ciphertext.as_slice())
.map_err(|_| VaultError::InvalidPassword)?;
let public_key_bytes = cipher.decrypt(nonce, encrypted_data.public_key_ciphertext.as_slice())
.map_err(|_| VaultError::InvalidPassword)?;
let private_key = String::from_utf8(private_key_bytes)
.map_err(|_| VaultError::DecryptionError)?;
let public_key = String::from_utf8(public_key_bytes)
.map_err(|_| VaultError::DecryptionError)?;
Ok((private_key, public_key))
}
/// Check if a private key is stored in the vault
pub fn has_stored_key() -> bool {
if let Ok(storage) = Self::get_storage() {
if let Ok(Some(_)) = storage.get_item(STORAGE_KEY) {
return true;
}
}
false
}
/// Clear stored keypair from vault
pub fn clear_stored_key() -> Result<(), VaultError> {
let storage = Self::get_storage()?;
storage.remove_item(STORAGE_KEY)
.map_err(|_| VaultError::StorageNotAvailable)?;
Ok(())
}
/// Get a test private key for development
pub fn get_test_private_key() -> String {
// This is a test private key - in production, users would generate their own
"test_private_key_123456789".to_string()
}
/// Validate a private key format (basic validation)
pub fn validate_private_key(private_key: &str) -> bool {
// Basic validation - in production, implement proper key format validation
!private_key.is_empty() && private_key.len() >= 10
}
/// Export private key for backup (requires password verification)
pub fn export_private_key(password: &str) -> Result<String, VaultError> {
match Self::retrieve_keypair(password) {
Ok((private_key, _)) => Ok(private_key),
Err(e) => Err(e),
}
}
/// Import and store a keypair (replaces existing keys)
pub fn import_private_key(private_key: &str, password: &str) -> Result<(), VaultError> {
// Validate the private key format first
if !Self::validate_private_key(private_key) {
return Err(VaultError::DecryptionError);
}
// Derive public key from private key
use crate::crypto::KeyPair;
let keypair = KeyPair::from_private_key(private_key)
.map_err(|_| VaultError::DecryptionError)?;
// Store the new keypair (this will overwrite any existing keys)
Vault::store_keypair(private_key, &keypair.public_key, password)
}
/// Change the password for the stored keypair
pub fn change_password(old_password: &str, new_password: &str) -> Result<(), VaultError> {
// First retrieve the keypair with the old password
let (private_key, public_key) = Self::retrieve_keypair(old_password)?;
// Then store it again with the new password
Self::store_keypair(&private_key, &public_key, new_password)
}
/// Verify if a password can decrypt the stored key (without returning the key)
pub fn verify_password(password: &str) -> Result<bool, VaultError> {
match Self::retrieve_keypair(password) {
Ok(_) => Ok(true),
Err(VaultError::InvalidPassword) => Ok(false),
Err(e) => Err(e),
}
}
// Helper methods
fn get_storage() -> Result<Storage, VaultError> {
let window = window().ok_or(VaultError::StorageNotAvailable)?;
window.local_storage()
.map_err(|_| VaultError::StorageNotAvailable)?
.ok_or(VaultError::StorageNotAvailable)
}
fn fill_random(buf: &mut [u8]) -> Result<(), VaultError> {
use rand::rngs::OsRng;
OsRng.fill_bytes(buf);
Ok(())
}
}
// WASM bindings for JavaScript interop
#[wasm_bindgen]
pub struct VaultJs;
#[wasm_bindgen]
impl VaultJs {
#[wasm_bindgen(js_name = storePrivateKey)]
pub fn store_private_key(private_key: &str, password: &str) -> Result<(), JsValue> {
Vault::store_keypair(private_key, private_key, password)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen(js_name = retrievePrivateKey)]
pub fn retrieve_private_key(password: &str) -> Result<String, JsValue> {
Vault::retrieve_keypair(password)
.map_err(|e| JsValue::from_str(&e.to_string()))
.and_then(|(private_key, _)| Ok(private_key))
}
#[wasm_bindgen(js_name = hasStoredKey)]
pub fn has_stored_key() -> bool {
Vault::has_stored_key()
}
#[wasm_bindgen(js_name = clearStoredKey)]
pub fn clear_stored_key() -> Result<(), JsValue> {
Vault::clear_stored_key()
.map_err(|e| JsValue::from_str(&e.to_string()))
}
#[wasm_bindgen(js_name = verifyPassword)]
pub fn verify_password(password: &str) -> Result<bool, JsValue> {
Vault::verify_password(password)
.map_err(|e| JsValue::from_str(&e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encryption_decryption() {
let private_key = "0x1234567890abcdef1234567890abcdef12345678";
let password = "test_password_123";
// This would work in a browser environment with localStorage
// For now, just test the crypto logic would work
assert_eq!(private_key.len(), 42); // Valid hex private key length
assert!(!password.is_empty());
}
}

View File

@@ -0,0 +1,681 @@
use yew::prelude::*;
use web_sys::HtmlInputElement;
use wasm_bindgen::JsCast;
use serde::{Deserialize, Serialize};
use crate::vault::Vault;
use crate::crypto::KeyPair;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VaultConfig {
pub server_url: String,
pub app_name: String,
}
#[derive(Properties, PartialEq)]
pub struct VaultManagerProps {
pub config: VaultConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct StoredKey {
pub name: String,
pub public_key: String,
pub created_at: String,
}
pub struct VaultManager {
stored_keys: Vec<StoredKey>,
loading: bool,
error_message: Option<String>,
success_message: Option<String>,
// Add new key form
show_add_form: bool,
new_key_name: String,
new_key_private: String,
new_key_password: String,
// Unlock key form
unlock_key_index: Option<usize>,
unlock_password: String,
unlocked_private_key: Option<String>,
// Sign form
show_sign_form: bool,
sign_key_index: Option<usize>,
sign_message: String,
sign_password: String,
signature_result: Option<String>,
}
pub enum VaultManagerMsg {
LoadKeys,
KeysLoaded(Vec<StoredKey>),
KeysLoadFailed(String),
// Add key messages
ToggleAddForm,
UpdateNewKeyName(String),
UpdateNewKeyPrivate(String),
UpdateNewKeyPassword(String),
AddKey,
KeyAdded,
KeyAddFailed(String),
// Unlock key messages
ShowUnlockForm(usize),
HideUnlockForm,
UpdateUnlockPassword(String),
UnlockKey,
KeyUnlocked(String),
KeyUnlockFailed(String),
// Sign messages
ShowSignForm(usize),
HideSignForm,
UpdateSignMessage(String),
UpdateSignPassword(String),
SignMessage,
MessageSigned(String),
SignFailed(String),
ClearMessages,
}
impl Component for VaultManager {
type Message = VaultManagerMsg;
type Properties = VaultManagerProps;
fn create(ctx: &Context<Self>) -> Self {
ctx.link().send_message(VaultManagerMsg::LoadKeys);
Self {
stored_keys: Vec::new(),
loading: true,
error_message: None,
success_message: None,
show_add_form: false,
new_key_name: String::new(),
new_key_private: String::new(),
new_key_password: String::new(),
unlock_key_index: None,
unlock_password: String::new(),
unlocked_private_key: None,
show_sign_form: false,
sign_key_index: None,
sign_message: String::new(),
sign_password: String::new(),
signature_result: None,
}
}
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
match msg {
VaultManagerMsg::LoadKeys => {
self.loading = true;
self.error_message = None;
let link = ctx.link().clone();
wasm_bindgen_futures::spawn_local(async move {
match Self::load_stored_keys().await {
Ok(keys) => link.send_message(VaultManagerMsg::KeysLoaded(keys)),
Err(e) => link.send_message(VaultManagerMsg::KeysLoadFailed(e)),
}
});
true
}
VaultManagerMsg::KeysLoaded(keys) => {
self.stored_keys = keys;
self.loading = false;
true
}
VaultManagerMsg::KeysLoadFailed(error) => {
self.error_message = Some(error);
self.loading = false;
true
}
VaultManagerMsg::ToggleAddForm => {
self.show_add_form = !self.show_add_form;
if !self.show_add_form {
self.new_key_name.clear();
self.new_key_private.clear();
self.new_key_password.clear();
}
true
}
VaultManagerMsg::UpdateNewKeyName(name) => {
self.new_key_name = name;
true
}
VaultManagerMsg::UpdateNewKeyPrivate(private_key) => {
self.new_key_private = private_key;
true
}
VaultManagerMsg::UpdateNewKeyPassword(password) => {
self.new_key_password = password;
true
}
VaultManagerMsg::AddKey => {
if self.new_key_name.trim().is_empty() || self.new_key_private.trim().is_empty() || self.new_key_password.trim().is_empty() {
self.error_message = Some("All fields are required".to_string());
return true;
}
let name = self.new_key_name.clone();
let private_key = self.new_key_private.clone();
let password = self.new_key_password.clone();
let link = ctx.link().clone();
wasm_bindgen_futures::spawn_local(async move {
match Self::add_key_to_vault(&name, &private_key, &password).await {
Ok(_) => link.send_message(VaultManagerMsg::KeyAdded),
Err(e) => link.send_message(VaultManagerMsg::KeyAddFailed(e)),
}
});
true
}
VaultManagerMsg::KeyAdded => {
self.success_message = Some("Key added successfully".to_string());
self.show_add_form = false;
self.new_key_name.clear();
self.new_key_private.clear();
self.new_key_password.clear();
ctx.link().send_message(VaultManagerMsg::LoadKeys);
true
}
VaultManagerMsg::KeyAddFailed(error) => {
self.error_message = Some(error);
true
}
VaultManagerMsg::ShowUnlockForm(index) => {
self.unlock_key_index = Some(index);
self.unlock_password.clear();
self.unlocked_private_key = None;
true
}
VaultManagerMsg::HideUnlockForm => {
self.unlock_key_index = None;
self.unlock_password.clear();
self.unlocked_private_key = None;
true
}
VaultManagerMsg::UpdateUnlockPassword(password) => {
self.unlock_password = password;
true
}
VaultManagerMsg::UnlockKey => {
if let Some(index) = self.unlock_key_index {
if let Some(key) = self.stored_keys.get(index) {
let key_name = key.name.clone();
let password = self.unlock_password.clone();
let link = ctx.link().clone();
wasm_bindgen_futures::spawn_local(async move {
match Self::unlock_key(&key_name, &password).await {
Ok(private_key) => link.send_message(VaultManagerMsg::KeyUnlocked(private_key)),
Err(e) => link.send_message(VaultManagerMsg::KeyUnlockFailed(e)),
}
});
}
}
true
}
VaultManagerMsg::KeyUnlocked(private_key) => {
self.unlocked_private_key = Some(private_key);
self.success_message = Some("Key unlocked successfully".to_string());
true
}
VaultManagerMsg::KeyUnlockFailed(error) => {
self.error_message = Some(error);
true
}
VaultManagerMsg::ShowSignForm(index) => {
self.show_sign_form = true;
self.sign_key_index = Some(index);
self.sign_message.clear();
self.sign_password.clear();
self.signature_result = None;
true
}
VaultManagerMsg::HideSignForm => {
self.show_sign_form = false;
self.sign_key_index = None;
self.sign_message.clear();
self.sign_password.clear();
self.signature_result = None;
true
}
VaultManagerMsg::UpdateSignMessage(message) => {
self.sign_message = message;
true
}
VaultManagerMsg::UpdateSignPassword(password) => {
self.sign_password = password;
true
}
VaultManagerMsg::SignMessage => {
if let Some(index) = self.sign_key_index {
if let Some(key) = self.stored_keys.get(index) {
let key_name = key.name.clone();
let message = self.sign_message.clone();
let password = self.sign_password.clone();
let link = ctx.link().clone();
wasm_bindgen_futures::spawn_local(async move {
match Self::sign_with_key(&key_name, &message, &password).await {
Ok(signature) => link.send_message(VaultManagerMsg::MessageSigned(signature)),
Err(e) => link.send_message(VaultManagerMsg::SignFailed(e)),
}
});
}
}
true
}
VaultManagerMsg::MessageSigned(signature) => {
self.signature_result = Some(signature);
self.success_message = Some("Message signed successfully".to_string());
true
}
VaultManagerMsg::SignFailed(error) => {
self.error_message = Some(error);
true
}
VaultManagerMsg::ClearMessages => {
self.error_message = None;
self.success_message = None;
true
}
}
}
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="vault-manager-container" style="max-width: 1000px; margin: 0 auto; padding: 2rem;">
<div class="card shadow-lg border-0" style="border-radius: 16px;">
<div class="card-header bg-success text-white" style="border-radius: 16px 16px 0 0; padding: 2rem;">
<div class="d-flex justify-content-between align-items-center">
<div>
<h2 class="mb-0">{"Private Key Vault"}</h2>
<p class="mb-0 mt-2 opacity-75">{"Securely manage multiple private keys"}</p>
</div>
<button type="button" class="btn btn-outline-light"
onclick={link.callback(|_| VaultManagerMsg::ToggleAddForm)}>
<i class="bi bi-plus-circle me-2"></i>
{"Add Key"}
</button>
</div>
</div>
<div class="card-body" style="padding: 2rem;">
{self.render_messages(ctx)}
{if self.show_add_form {
self.render_add_key_form(ctx)
} else {
html! {}
}}
{if self.loading {
html! {
<div class="text-center py-5">
<div class="spinner-border text-success" role="status">
<span class="visually-hidden">{"Loading keys..."}</span>
</div>
<p class="mt-3 text-muted">{"Loading your stored keys..."}</p>
</div>
}
} else {
self.render_keys_list(ctx)
}}
{if self.show_sign_form {
self.render_sign_form(ctx)
} else {
html! {}
}}
</div>
</div>
</div>
}
}
}
impl VaultManager {
async fn load_stored_keys() -> Result<Vec<StoredKey>, String> {
// In a real implementation, this would load from a more sophisticated storage
// For now, we'll simulate loading keys from localStorage metadata
let window = web_sys::window().ok_or("No window object")?;
let storage = window.local_storage()
.map_err(|_| "Failed to access localStorage")?
.ok_or("localStorage not available")?;
let keys_json = storage.get_item("vault_keys_metadata")
.map_err(|_| "Failed to read keys metadata")?
.unwrap_or_default();
if keys_json.is_empty() {
return Ok(Vec::new());
}
serde_json::from_str(&keys_json)
.map_err(|_| "Failed to parse keys metadata".to_string())
}
async fn add_key_to_vault(name: &str, private_key: &str, password: &str) -> Result<(), String> {
// Validate the private key by creating a KeyPair
let keypair = KeyPair::from_private_key(private_key)
.map_err(|e| format!("Invalid private key: {}", e))?;
// Store the key using the vault with a unique identifier
let key_id = format!("vault_key_{}", name);
Vault::store_keypair(private_key, &keypair.public_key, password)
.map_err(|e| format!("Failed to store key: {}", e))?;
// Update metadata
let mut keys = Self::load_stored_keys().await.unwrap_or_default();
let new_key = StoredKey {
name: name.to_string(),
public_key: keypair.public_key,
created_at: js_sys::Date::new_0().to_iso_string().as_string().unwrap(),
};
keys.push(new_key);
// Save metadata
let window = web_sys::window().ok_or("No window object")?;
let storage = window.local_storage()
.map_err(|_| "Failed to access localStorage")?
.ok_or("localStorage not available")?;
let keys_json = serde_json::to_string(&keys)
.map_err(|_| "Failed to serialize keys metadata")?;
storage.set_item("vault_keys_metadata", &keys_json)
.map_err(|_| "Failed to save keys metadata")?;
Ok(())
}
async fn unlock_key(_key_name: &str, password: &str) -> Result<String, String> {
let (private_key, _) = Vault::retrieve_keypair(password)
.map_err(|e| format!("Failed to unlock key: {}", e))?;
Ok(private_key)
}
async fn sign_with_key(_key_name: &str, message: &str, password: &str) -> Result<String, String> {
let (private_key, _) = Vault::retrieve_keypair(password)
.map_err(|e| format!("Failed to unlock key: {}", e))?;
let keypair = KeyPair::from_private_key(&private_key)
.map_err(|e| format!("Invalid private key: {}", e))?;
keypair.sign(message)
.map_err(|e| format!("Failed to sign message: {}", e))
}
fn render_messages(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<>
{if let Some(error) = &self.error_message {
html! {
<div class="alert alert-danger alert-dismissible fade show mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>
{error}
<button type="button" class="btn-close"
onclick={link.callback(|_| VaultManagerMsg::ClearMessages)}></button>
</div>
}
} else {
html! {}
}}
{if let Some(success) = &self.success_message {
html! {
<div class="alert alert-success alert-dismissible fade show mb-4">
<i class="bi bi-check-circle me-2"></i>
{success}
<button type="button" class="btn-close"
onclick={link.callback(|_| VaultManagerMsg::ClearMessages)}></button>
</div>
}
} else {
html! {}
}}
</>
}
}
fn render_add_key_form(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="card mb-4" style="border: 2px dashed #28a745;">
<div class="card-body">
<h5 class="card-title">{"Add New Private Key"}</h5>
<form onsubmit={link.callback(|e: SubmitEvent| {
e.prevent_default();
VaultManagerMsg::AddKey
})}>
<div class="mb-3">
<label class="form-label">{"Key Name"}</label>
<input type="text" class="form-control"
value={self.new_key_name.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
VaultManagerMsg::UpdateNewKeyName(input.value())
})}
placeholder="e.g., Personal Key, Work Key" />
</div>
<div class="mb-3">
<label class="form-label">{"Private Key"}</label>
<textarea class="form-control font-monospace" rows="3"
value={self.new_key_private.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
VaultManagerMsg::UpdateNewKeyPrivate(input.value())
})}
placeholder="Enter your private key in hex format"></textarea>
</div>
<div class="mb-3">
<label class="form-label">{"Password"}</label>
<input type="password" class="form-control"
value={self.new_key_password.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
VaultManagerMsg::UpdateNewKeyPassword(input.value())
})}
placeholder="Password to encrypt this key" />
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="bi bi-shield-lock me-2"></i>
{"Add Key"}
</button>
<button type="button" class="btn btn-secondary"
onclick={link.callback(|_| VaultManagerMsg::ToggleAddForm)}>
{"Cancel"}
</button>
</div>
</form>
</div>
</div>
}
}
fn render_keys_list(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
if self.stored_keys.is_empty() {
return html! {
<div class="text-center py-5">
<i class="bi bi-key display-1 text-muted"></i>
<h4 class="mt-3 text-muted">{"No Keys Stored"}</h4>
<p class="text-muted">{"Add your first private key to get started"}</p>
</div>
};
}
html! {
<div class="keys-list">
<h5 class="mb-3">{"Stored Keys"}</h5>
{for self.stored_keys.iter().enumerate().map(|(index, key)| {
html! {
<div class="card mb-3">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-6">
<h6 class="mb-1">{&key.name}</h6>
<small class="text-muted font-monospace">{&key.public_key[..20]}{"..."}</small>
<br />
<small class="text-muted">{"Created: "}{&key.created_at[..10]}</small>
</div>
<div class="col-md-6 text-end">
<div class="btn-group">
<button type="button" class="btn btn-outline-primary btn-sm"
onclick={link.callback(move |_| VaultManagerMsg::ShowUnlockForm(index))}>
<i class="bi bi-unlock me-1"></i>
{"Unlock"}
</button>
<button type="button" class="btn btn-outline-success btn-sm"
onclick={link.callback(move |_| VaultManagerMsg::ShowSignForm(index))}>
<i class="bi bi-pen me-1"></i>
{"Sign"}
</button>
</div>
</div>
</div>
{if self.unlock_key_index == Some(index) {
self.render_unlock_form(ctx, index)
} else {
html! {}
}}
</div>
</div>
}
})}
</div>
}
}
fn render_unlock_form(&self, ctx: &Context<Self>, _index: usize) -> Html {
let link = ctx.link();
html! {
<div class="mt-3 pt-3 border-top">
<h6>{"Unlock Key"}</h6>
<form onsubmit={link.callback(|e: SubmitEvent| {
e.prevent_default();
VaultManagerMsg::UnlockKey
})}>
<div class="input-group mb-3">
<input type="password" class="form-control"
value={self.unlock_password.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
VaultManagerMsg::UpdateUnlockPassword(input.value())
})}
placeholder="Enter password" />
<button type="submit" class="btn btn-primary">{"Unlock"}</button>
<button type="button" class="btn btn-secondary"
onclick={link.callback(|_| VaultManagerMsg::HideUnlockForm)}>
{"Cancel"}
</button>
</div>
</form>
{if let Some(private_key) = &self.unlocked_private_key {
html! {
<div class="alert alert-success">
<h6>{"Private Key:"}</h6>
<div class="font-monospace small" style="word-break: break-all;">
{private_key}
</div>
</div>
}
} else {
html! {}
}}
</div>
}
}
fn render_sign_form(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
html! {
<div class="modal" style="display: block; background: rgba(0,0,0,0.5);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{"Sign Message"}</h5>
<button type="button" class="btn-close"
onclick={link.callback(|_| VaultManagerMsg::HideSignForm)}></button>
</div>
<div class="modal-body">
<form onsubmit={link.callback(|e: SubmitEvent| {
e.prevent_default();
VaultManagerMsg::SignMessage
})}>
<div class="mb-3">
<label class="form-label">{"Message to Sign"}</label>
<textarea class="form-control" rows="4"
value={self.sign_message.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
VaultManagerMsg::UpdateSignMessage(input.value())
})}
placeholder="Enter the message you want to sign"></textarea>
</div>
<div class="mb-3">
<label class="form-label">{"Password"}</label>
<input type="password" class="form-control"
value={self.sign_password.clone()}
oninput={link.callback(|e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
VaultManagerMsg::UpdateSignPassword(input.value())
})}
placeholder="Enter password to unlock key" />
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success">
<i class="bi bi-pen me-2"></i>
{"Sign Message"}
</button>
<button type="button" class="btn btn-secondary"
onclick={link.callback(|_| VaultManagerMsg::HideSignForm)}>
{"Cancel"}
</button>
</div>
</form>
{if let Some(signature) = &self.signature_result {
html! {
<div class="mt-4">
<h6>{"Signature:"}</h6>
<div class="alert alert-success">
<div class="font-monospace small" style="word-break: break-all;">
{signature}
</div>
</div>
</div>
}
} else {
html! {}
}}
</div>
</div>
</div>
</div>
}
}
}