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:
@@ -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 }
|
||||
|
||||
62
components/scripts/email_verification.rhai
Normal file
62
components/scripts/email_verification.rhai
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
253
components/src/freezone_client.rs
Normal file
253
components/src/freezone_client.rs
Normal 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
483
components/src/identity.rs
Normal 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! {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
409
components/src/login.rs
Normal 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>
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
318
components/src/sign.rs
Normal 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
281
components/src/vault.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
681
components/src/vault_manager.rs
Normal file
681
components/src/vault_manager.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user