Close XCA parity gaps: advanced extensions, CA/revocation UI, imports, ZIP export, E2E

This commit is contained in:
2026-10-23 04:26:35 +00:00
parent 4f3a632f4b
commit 5eb7866f5b
27 changed files with 2054 additions and 134 deletions
+15
View File
@@ -175,6 +175,16 @@ pub async fn update_vault_settings(id: &str, settings: &serde_json::Value, versi
check(response).await
}
pub async fn export_vault(id: &str, password: Option<&str>) -> Result<ExportPayload, String> {
let response = send(
"POST",
&format!("/api/vaults/{id}/export"),
Some(&json!({"password": password})),
)
.await?;
check(response).await
}
pub async fn vault_summary(id: &str) -> Result<SummaryPage, String> {
let response = Request::get(&format!("/api/vaults/{id}/summary")).send().await.map_err(|e| e.to_string())?;
check(response).await
@@ -287,6 +297,11 @@ pub async fn certificate(vault_id: &str, cert_id: &str) -> Result<CertificateDet
check(response).await
}
pub async fn preview_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<serde_json::Value, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates/preview"), Some(payload)).await?;
check(response).await
}
pub async fn create_certificate(vault_id: &str, payload: &serde_json::Value) -> Result<CertificateDetail, String> {
let response = send("POST", &format!("/api/vaults/{vault_id}/certificates"), Some(payload)).await?;
check(response).await
+360
View File
@@ -0,0 +1,360 @@
//! Shared editor for the less common X.509 extensions.
//!
//! The common controls (basic constraints, key usage, EKU, SAN, key IDs) stay
//! in the certificate/template pages; this module covers CRL distribution
//! points, AIA, name constraints, policies, policy constraints and an
//! "advanced JSON" escape hatch for anything else (raw OID/DER, Netscape,
//! certificate policies with full qualifiers, ...).
use serde_json::{json, Map, Value};
use sycamore::prelude::*;
#[derive(Clone, Copy)]
pub struct ExtensionSignals {
pub crl_urls: Signal<String>,
pub aia_ocsp: Signal<String>,
pub aia_ca_issuers: Signal<String>,
pub nc_permitted: Signal<String>,
pub nc_excluded: Signal<String>,
pub policy_oids: Signal<String>,
pub policy_cps: Signal<String>,
pub policy_require: Signal<String>,
pub policy_inhibit: Signal<String>,
pub inhibit_any: Signal<String>,
pub advanced_json: Signal<String>,
}
pub fn extension_fields(s: &ExtensionSignals) -> View {
let crl_urls = s.crl_urls;
let aia_ocsp = s.aia_ocsp;
let aia_ca_issuers = s.aia_ca_issuers;
let nc_permitted = s.nc_permitted;
let nc_excluded = s.nc_excluded;
let policy_oids = s.policy_oids;
let policy_cps = s.policy_cps;
let policy_require = s.policy_require;
let policy_inhibit = s.policy_inhibit;
let inhibit_any = s.inhibit_any;
let advanced_json = s.advanced_json;
view! {
div(class="form-section") {
div(class="form-section-title") { "Advanced extensions" }
div(class="row g-3") {
div(class="col-md-6") {
label(class="form-label") { "CRL distribution point URLs" }
textarea(class="form-control", rows="2", placeholder="http://example.com/ca.crl", bind:value=crl_urls) {}
}
div(class="col-md-3") {
label(class="form-label") { "AIA · OCSP" }
textarea(class="form-control", rows="2", placeholder="http://ocsp.example.com", bind:value=aia_ocsp) {}
}
div(class="col-md-3") {
label(class="form-label") { "AIA · caIssuers" }
textarea(class="form-control", rows="2", placeholder="http://example.com/ca.crt", bind:value=aia_ca_issuers) {}
}
div(class="col-md-6") {
label(class="form-label") { "Name constraints · permitted" }
textarea(class="form-control", rows="2", placeholder="DNS:.example.com, IP:10.0.0.0/8", bind:value=nc_permitted) {}
}
div(class="col-md-6") {
label(class="form-label") { "Name constraints · excluded" }
textarea(class="form-control", rows="2", placeholder="DNS:.bad.example", bind:value=nc_excluded) {}
}
div(class="col-md-4") {
label(class="form-label") { "Certificate policy OIDs" }
input(class="form-control", placeholder="1.3.6.1.4.1.1", bind:value=policy_oids)
}
div(class="col-md-4") {
label(class="form-label") { "Policy CPS URIs" }
input(class="form-control", placeholder="https://example.com/cps", bind:value=policy_cps)
}
div(class="col-md-2") {
label(class="form-label") { "Require explicit policy" }
input(class="form-control", placeholder="0", bind:value=policy_require)
}
div(class="col-md-2") {
label(class="form-label") { "Inhibit policy mapping" }
input(class="form-control", placeholder="0", bind:value=policy_inhibit)
}
div(class="col-md-3") {
label(class="form-label") { "Inhibit anyPolicy" }
input(class="form-control", placeholder="0", bind:value=inhibit_any)
}
div(class="col-12") {
label(class="form-label") { "Advanced extension JSON" }
textarea(class="form-control mono", rows="5",
placeholder=r#"{"raw":[{"oid":"1.2.3.4","critical":false,"der_hex":"0c03616263"}]}"#,
bind:value=advanced_json) {}
div(class="form-text") {
"Merged into the extension spec; this is the escape hatch for raw DER, Netscape and other less common extensions."
}
}
}
}
}
}
pub fn build_extra_extensions(s: &ExtensionSignals) -> Result<Value, String> {
let mut extensions = Map::new();
let crl_urls = split_list(&s.crl_urls.get_clone());
if !crl_urls.is_empty() {
extensions.insert(
"crl_distribution_points".to_string(),
json!({"critical": false, "urls": crl_urls}),
);
}
let mut aia = Vec::new();
for url in split_list(&s.aia_ocsp.get_clone()) {
aia.push(json!({"method": "ocsp", "location": {"type": "uri", "value": url}}));
}
for url in split_list(&s.aia_ca_issuers.get_clone()) {
aia.push(json!({"method": "ca_issuers", "location": {"type": "uri", "value": url}}));
}
if !aia.is_empty() {
extensions.insert(
"authority_information_access".to_string(),
json!({"critical": false, "descriptions": aia}),
);
}
let permitted = parse_general_names(&s.nc_permitted.get_clone())?;
let excluded = parse_general_names(&s.nc_excluded.get_clone())?;
if !permitted.is_empty() || !excluded.is_empty() {
extensions.insert(
"name_constraints".to_string(),
json!({"critical": true, "permitted": permitted, "excluded": excluded}),
);
}
let policy_oids = split_list(&s.policy_oids.get_clone());
if !policy_oids.is_empty() {
let cps = split_list(&s.policy_cps.get_clone());
let policies = policy_oids
.iter()
.map(|oid| json!({"oid": oid, "cps": cps}))
.collect::<Vec<_>>();
extensions.insert(
"certificate_policies".to_string(),
json!({"critical": false, "policies": policies}),
);
}
let require = optional_int(&s.policy_require.get_clone(), "require explicit policy")?;
let inhibit = optional_int(&s.policy_inhibit.get_clone(), "inhibit policy mapping")?;
if require.is_some() || inhibit.is_some() {
extensions.insert(
"policy_constraints".to_string(),
json!({"critical": true, "require_explicit_policy": require, "inhibit_policy_mapping": inhibit}),
);
}
let inhibit_any = optional_int(&s.inhibit_any.get_clone(), "inhibit anyPolicy")?;
if let Some(skip) = inhibit_any {
extensions.insert(
"inhibit_any_policy".to_string(),
json!({"critical": true, "skip_certs": skip}),
);
}
let advanced = s.advanced_json.get_clone();
if !advanced.trim().is_empty() {
let parsed: Value = serde_json::from_str(&advanced)
.map_err(|_| "advanced extension JSON is not valid JSON".to_string())?;
let object = parsed
.as_object()
.ok_or_else(|| "advanced extension JSON must be an object".to_string())?;
for (key, value) in object {
extensions.insert(key.clone(), value.clone());
}
}
Ok(Value::Object(extensions))
}
pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) {
s.crl_urls.set(
spec.get("crl_distribution_points")
.and_then(|v| v.get("urls"))
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default(),
);
let descriptions = spec
.get("authority_information_access")
.and_then(|v| v.get("descriptions"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let ocsp = descriptions
.iter()
.filter(|item| item.get("method").and_then(|v| v.as_str()) == Some("ocsp"))
.filter_map(|item| {
item.get("location")
.and_then(|v| v.get("value"))
.and_then(|v| v.as_str())
})
.collect::<Vec<_>>()
.join("\n");
let ca_issuers = descriptions
.iter()
.filter(|item| item.get("method").and_then(|v| v.as_str()) == Some("ca_issuers"))
.filter_map(|item| {
item.get("location")
.and_then(|v| v.get("value"))
.and_then(|v| v.as_str())
})
.collect::<Vec<_>>()
.join("\n");
s.aia_ocsp.set(ocsp);
s.aia_ca_issuers.set(ca_issuers);
let nc = spec.get("name_constraints");
s.nc_permitted
.set(names_to_text(nc.and_then(|v| v.get("permitted"))));
s.nc_excluded
.set(names_to_text(nc.and_then(|v| v.get("excluded"))));
let policies = spec
.get("certificate_policies")
.and_then(|v| v.get("policies"))
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
s.policy_oids.set(
policies
.iter()
.filter_map(|item| item.get("oid").and_then(|v| v.as_str()))
.collect::<Vec<_>>()
.join(", "),
);
let cps = policies
.iter()
.flat_map(|item| {
item.get("cps")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default()
})
.filter_map(|value| value.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>();
s.policy_cps.set(cps.join(", "));
let pc = spec.get("policy_constraints");
s.policy_require.set(
pc.and_then(|v| v.get("require_explicit_policy"))
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default(),
);
s.policy_inhibit.set(
pc.and_then(|v| v.get("inhibit_policy_mapping"))
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default(),
);
s.inhibit_any.set(
spec.get("inhibit_any_policy")
.and_then(|v| v.get("skip_certs"))
.and_then(|v| v.as_i64())
.map(|v| v.to_string())
.unwrap_or_default(),
);
let mut remaining = spec.clone();
if let Some(object) = remaining.as_object_mut() {
for key in [
"basic_constraints",
"key_usage",
"extended_key_usage",
"subject_alt_name",
"subject_key_identifier",
"authority_key_identifier",
"crl_distribution_points",
"authority_information_access",
"name_constraints",
"certificate_policies",
"policy_constraints",
"inhibit_any_policy",
] {
object.remove(key);
}
if object.is_empty() {
s.advanced_json.set(String::new());
} else {
s.advanced_json
.set(serde_json::to_string_pretty(&remaining).unwrap_or_default());
}
}
}
fn split_list(text: &str) -> Vec<String> {
text.split(|c| c == ',' || c == '\n')
.map(|item| item.trim().to_string())
.filter(|item| !item.is_empty())
.collect()
}
fn optional_int(text: &str, field: &str) -> Result<Option<i64>, String> {
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
text.parse::<i64>()
.map(Some)
.map_err(|_| format!("{field} must be an integer"))
}
fn parse_general_names(text: &str) -> Result<Vec<Value>, String> {
split_list(text)
.into_iter()
.map(|item| {
if let Some((prefix, value)) = item.split_once(':') {
let prefix = prefix.trim().to_lowercase();
let kind = match prefix.as_str() {
"dns" => "dns",
"ip" => "ip",
"uri" => "uri",
"email" => "email",
"rid" => "rid",
"upn" => "upn",
"othername" | "other_name" => "other_name",
_ => return Err(format!("unknown general-name type {prefix:?}")),
};
if kind == "upn" {
Ok(json!({"type": "upn", "value": value.trim()}))
} else {
Ok(json!({"type": kind, "value": value.trim()}))
}
} else {
Ok(json!({"type": "dns", "value": item}))
}
})
.collect()
}
fn names_to_text(value: Option<&Value>) -> String {
value
.and_then(|v| v.as_array())
.map(|items| {
items
.iter()
.filter_map(|item| {
let kind = item.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
let value = item.get("value").and_then(|v| v.as_str()).unwrap_or("");
if value.is_empty() {
None
} else {
Some(format!("{}:{}", kind.to_uppercase(), value))
}
})
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default()
}
+1
View File
@@ -1,3 +1,4 @@
pub mod extension_form;
pub mod nav;
pub mod toast;
pub mod ui;
+4 -1
View File
@@ -19,6 +19,8 @@ enum AppRoutes {
Tokens,
#[to("/vaults/<id>/certificates/new")]
CertificateNew { id: String },
#[to("/vaults/<id>/certificates/<cert_id>/similar")]
CertificateSimilar { id: String, cert_id: String },
#[to("/vaults/<id>/certificates/<cert_id>/renew")]
CertificateRenew { id: String, cert_id: String },
#[to("/vaults/<id>/certificates/<cert_id>")]
@@ -72,7 +74,8 @@ fn main() {
AppRoutes::Dashboard => view! { pages::dashboard::DashboardPage() },
AppRoutes::Login => view! { pages::login::LoginPage() },
AppRoutes::Tokens => view! { pages::tokens::TokensPage() },
AppRoutes::CertificateNew { id } => view! { (pages::certificate_new::CertificateNewPage(id.clone())) },
AppRoutes::CertificateNew { id } => view! { (pages::certificate_new::CertificateNewPage(id.clone(), None)) },
AppRoutes::CertificateSimilar { id, cert_id } => view! { (pages::certificate_new::CertificateNewPage(id.clone(), Some(cert_id.clone()))) },
AppRoutes::CertificateRenew { id, cert_id } => view! { (pages::certificate_new::CertificateRenewPage(id.clone(), cert_id.clone())) },
AppRoutes::CertificateDetail { id, cert_id } => view! { (pages::certificate_detail::CertificateDetailPage(id.clone(), cert_id.clone())) },
AppRoutes::Certificates { id } => view! { (pages::certificates::CertificatesPage(id.clone())) },
+3 -1
View File
@@ -190,6 +190,8 @@ pub struct CertificateSummary {
#[serde(default)]
pub not_after: Option<String>,
#[serde(default)]
pub no_expiration: bool,
#[serde(default)]
pub is_ca: bool,
#[serde(default)]
pub path_length: Option<i64>,
@@ -315,7 +317,7 @@ pub struct AuthorityInfo {
#[serde(default)]
pub crl_days: Option<i64>,
#[serde(default)]
pub default_template_id: Option<String>,
pub default_template: Option<String>,
#[serde(default)]
pub has_tls_auth_key: bool,
}
+130 -16
View File
@@ -7,7 +7,7 @@ use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{copy_button, empty_state, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_date, CertificateDetail, Me, Revocation};
use crate::model::{fmt_date, CertificateDetail, Me, Revocation, TemplateSummary};
pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
@@ -27,6 +27,10 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
let chain = create_signal(Vec::<crate::model::ChainItem>::new());
let rev_reason = create_signal("unspecified".to_string());
let rev_serial = create_signal(String::new());
let rev_invalidity = create_signal(String::new());
let editing_rev = create_signal(String::new());
let ca_template = create_signal(String::new());
let templates = create_signal(Vec::<TemplateSummary>::new());
let ca_days = create_signal("30".to_string());
let crl_days = create_signal("30".to_string());
let crl_hash = create_signal("sha256".to_string());
@@ -44,6 +48,14 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
ca_days.set(days.to_string());
crl_days.set(days.to_string());
}
if let Some(template) = value
.authority
.as_ref()
.and_then(|a| a.default_template.clone())
{
ca_template.set(template);
}
let is_ca = value.summary.is_ca;
subject_entries.set(value.subject_entries.clone());
issuer_entries.set(value.issuer_entries.clone());
extensions.set(value.extensions.clone());
@@ -51,6 +63,11 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
purposes.set(value.purposes.clone());
chain.set(value.chain.clone());
cert.set(Some(value));
if is_ca {
if let Ok(page) = api::templates(id).await {
templates.set(page.results);
}
}
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
@@ -76,7 +93,11 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
spawn_local(async move {
match api::export_certificate(id, cert_id_value, &payload).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
if let Err(message) = dom::download_base64(
&payload.filename,
&payload.content_type,
&payload.content_base64,
) {
error.set(Some(message));
}
}
@@ -143,6 +164,20 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
});
};
let save_ca = move |_| {
let payload = json!({
"crl_days": ca_days.get_clone().parse::<i64>().unwrap_or(30),
"template_id": if ca_template.get_clone().is_empty() { serde_json::Value::Null } else { json!(ca_template.get_clone()) },
});
spawn_local(async move {
match api::update_ca(id, cert_id_value, &payload).await {
Ok(_) => {}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let generate_crl = move |_| {
let payload = json!({
"days": crl_days.get_clone().parse::<i64>().unwrap_or(30),
@@ -152,22 +187,36 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
});
spawn_local(async move {
match api::generate_crl(id, cert_id_value, &payload).await {
Ok(crl) => sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id)),
Ok(crl) => {
sycamore_router::navigate(&format!("/vaults/{id}/crls/{}", crl.summary.id))
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let add_revocation = move |_| {
let payload = json!({"reason": rev_reason.get_clone(), "serial_hex": rev_serial.get_clone()});
let save_revocation = move |_| {
let payload = json!({
"reason": rev_reason.get_clone(),
"serial_hex": rev_serial.get_clone(),
"invalidity_date": if rev_invalidity.get_clone().is_empty() { serde_json::Value::Null } else { json!(rev_invalidity.get_clone()) },
});
let editing = editing_rev.get_clone();
spawn_local(async move {
match api::add_revocation(id, cert_id_value, &payload).await {
let result = if editing.is_empty() {
api::add_revocation(id, cert_id_value, &payload).await
} else {
api::update_revocation(id, cert_id_value, &editing, &payload).await
};
match result {
Ok(_) => {
if let Ok(page) = api::revocations(id, cert_id_value).await {
revocations.set(page.results);
}
rev_serial.set(String::new());
rev_invalidity.set(String::new());
editing_rev.set(String::new());
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
@@ -175,6 +224,18 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
});
};
let edit_revocation = move |rev: Revocation| {
editing_rev.set(rev.id.clone());
rev_serial.set(rev.serial_hex.clone());
rev_reason.set(rev.reason.clone());
rev_invalidity.set(
rev.invalidity_date
.clone()
.map(|value| value.chars().take(16).collect::<String>())
.unwrap_or_default(),
);
};
let delete_revocation = move |rev_id: String| {
spawn_local(async move {
match api::delete_revocation(id, cert_id_value, &rev_id).await {
@@ -340,6 +401,7 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
button(class="btn btn-outline-light", on:click=move |_| transform("open-key")) { "Extract public key" }
button(class="btn btn-outline-light", on:click=move |_| transform("request")) { "Create request" }
button(class="btn btn-outline-light", on:click=move |_| transform("template")) { "Create template" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/certificates/{cert_id_value}/similar")) { "Similar certificate" }
}
}
}
@@ -349,6 +411,24 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
div(class="card mt-3") {
div(class="card-header") { "CA operations" }
div(class="card-body") {
div(class="row g-2 align-items-end mb-3") {
div(class="col-md-3") {
label(class="form-label") { "CA · CRL days" }
input(class="form-control", bind:value=ca_days)
}
div(class="col-md-5") {
label(class="form-label") { "CA · default signing template" }
select(class="form-select", bind:value=ca_template) {
option(value="") { "— none —" }
(templates.get_clone().into_iter().map(|template| view! {
option(value=template.id.clone()) { (template.name.clone()) }
}).collect::<Vec<View>>())
}
}
div(class="col-md-2") {
button(class="btn btn-outline-light w-100", on:click=save_ca) { "Save CA" }
}
}
div(class="row g-2 align-items-end mb-3") {
div(class="col-md-3") {
label(class="form-label") { "CRL days" }
@@ -376,19 +456,44 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
})
h3(class="h6 mt-3") { "Revocations" }
div(class="row g-2 align-items-end mb-2") {
div(class="col-md-4") {
div(class="col-md-3") {
input(class="form-control", placeholder="serial (hex)", bind:value=rev_serial)
}
div(class="col-md-4") {
div(class="col-md-3") {
select(class="form-select", bind:value=rev_reason) { (reason_options()) }
}
div(class="col-md-4") {
button(class="btn btn-outline-light", on:click=add_revocation) { "Add revocation" }
div(class="col-md-3") {
input(class="form-control", r#type="datetime-local", bind:value=rev_invalidity)
}
div(class="col-md-3 d-flex gap-2") {
button(class="btn btn-outline-light", on:click=save_revocation) {
(if editing_rev.get_clone().is_empty() { "Add revocation" } else { "Save revocation" })
}
(if !editing_rev.get_clone().is_empty() {
view! {
button(class="btn btn-outline-secondary", on:click=move |_| {
editing_rev.set(String::new());
rev_serial.set(String::new());
rev_invalidity.set(String::new());
}) { "Cancel" }
}
} else { view! {} })
}
}
table(class="table table-sm mb-0") {
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() {} } }
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); view! { tr() { td(class="fingerprint") { (rev.serial_hex) } td() { (fmt_date(&rev.revoked_at)) } td() { (rev.reason) } td() { button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" } } } } }) }
thead() { tr() { th() { "Serial" } th() { "Revoked" } th() { "Reason" } th() { "Invalid since" } th() {} } }
tbody() { Indexed(list=revocations, view=move |rev| { let rev_id = rev.id.clone(); let rev_for_edit = rev.clone(); view! {
tr() {
td(class="fingerprint") { (rev.serial_hex) }
td() { (fmt_date(&rev.revoked_at)) }
td() { (rev.reason) }
td() { (rev.invalidity_date.clone().map(|v| fmt_date(&Some(v))).unwrap_or_else(|| "—".to_string())) }
td(class="table-actions") {
button(class="btn btn-sm btn-outline-light me-1", on:click=move |_| edit_revocation(rev_for_edit.clone())) { "Edit" }
button(class="btn btn-sm btn-outline-danger", on:click=move |_| delete_revocation(rev_id.clone())) { "Delete" }
}
}
} }) }
}
}
}
@@ -414,8 +519,17 @@ pub fn CertificateDetailPage(vault_id: String, cert_id: String) -> View {
}
fn reason_options() -> Vec<View> {
["unspecified", "keyCompromise", "CACompromise", "affiliationChanged", "superseded", "cessationOfOperation", "certificateHold", "privilegeWithdrawn"]
.iter()
.map(|reason| view! { option(value=*reason) { (*reason) } })
.collect::<Vec<View>>()
[
"unspecified",
"keyCompromise",
"CACompromise",
"affiliationChanged",
"superseded",
"cessationOfOperation",
"certificateHold",
"privilegeWithdrawn",
]
.iter()
.map(|reason| view! { option(value=*reason) { (*reason) } })
.collect::<Vec<View>>()
}
+449 -45
View File
@@ -4,11 +4,14 @@ use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::extension_form::{
build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals,
};
use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::{CertificateDetail, CertificateSummary, KeySummary, Me, TemplateSummary};
pub fn CertificateNewPage(vault_id: String) -> View {
pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let me = create_signal(None::<Me>);
let keys = create_signal(Vec::<KeySummary>::new());
@@ -59,12 +62,40 @@ pub fn CertificateNewPage(vault_id: String) -> View {
let ski = create_signal(true);
let aki = create_signal(true);
let ocsp_staple = create_signal(false);
let crl_urls = create_signal(String::new());
let aia_ocsp = create_signal(String::new());
let aia_ca_issuers = create_signal(String::new());
let nc_permitted = create_signal(String::new());
let nc_excluded = create_signal(String::new());
let policy_oids = create_signal(String::new());
let policy_cps = create_signal(String::new());
let policy_require = create_signal(String::new());
let policy_inhibit = create_signal(String::new());
let inhibit_any = create_signal(String::new());
let advanced_json = create_signal(String::new());
let ext = ExtensionSignals {
crl_urls,
aia_ocsp,
aia_ca_issuers,
nc_permitted,
nc_excluded,
policy_oids,
policy_cps,
policy_require,
policy_inhibit,
inhibit_any,
advanced_json,
};
let preview_text = create_signal(String::new());
let validity_mode = create_signal("duration".to_string());
let validity_amount = create_signal("365".to_string());
let validity_unit = create_signal("days".to_string());
let midnight = create_signal(false);
let not_before_text = create_signal(String::new());
let not_after_text = create_signal(String::new());
let no_expiration = create_signal(false);
spawn_local(async move {
if let Ok(user) = api::me().await {
if me.is_alive() {
@@ -75,11 +106,134 @@ pub fn CertificateNewPage(vault_id: String) -> View {
keys.set(page.results);
}
if let Ok(page) = api::certificates(&id).await {
signers.set(page.results.into_iter().filter(|cert| cert.is_ca && cert.has_private_key).collect());
signers.set(
page.results
.into_iter()
.filter(|cert| cert.is_ca && cert.has_private_key)
.collect(),
);
}
if let Ok(page) = api::templates(&id).await {
templates.set(page.results);
}
if let Ok(value) = api::vault_settings(&id).await {
if let Some(settings) = value.get("settings") {
if let Some(hash) = settings.get("default_hash").and_then(|v| v.as_str()) {
signature_hash.set(hash.to_string());
}
if let Some(key) = settings.get("default_key") {
if let Some(kind) = key.get("type").and_then(|v| v.as_str()) {
key_type.set(kind.to_string());
}
if let Some(size) = key.get("size").and_then(|v| v.as_i64()) {
key_size.set(size.to_string());
}
}
if let Some(validity) = settings.get("default_validity") {
if let Some(amount) = validity.get("amount").and_then(|v| v.as_i64()) {
validity_amount.set(amount.to_string());
}
if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) {
validity_unit.set(unit.to_string());
}
}
}
}
if !request_mode {
if let Some(cert_id) = from_cert.clone() {
if let Ok(value) = api::certificate(&id, &cert_id).await {
name.set(format!("{} copy", value.summary.name));
comment.set(value.summary.comment.clone());
if let Some(key) = value.summary.key_id.clone() {
key_id.set(key);
}
if let Some(spec) = value
.cert_spec
.get("signature_hash")
.and_then(|v| v.as_str())
{
signature_hash.set(spec.to_string());
}
for entry in &value.subject_entries {
match entry.name.as_str() {
"CN" => cn.set(entry.value.clone()),
"O" => organization.set(entry.value.clone()),
"OU" => org_unit.set(entry.value.clone()),
"C" => country.set(entry.value.clone()),
"ST" => state.set(entry.value.clone()),
"L" => locality.set(entry.value.clone()),
"emailAddress" => email.set(entry.value.clone()),
_ => {}
}
}
let spec = &value.extension_spec;
load_extra_extensions(&ext, spec);
if let Some(bc) = spec.get("basic_constraints") {
if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) {
ca_mode.set("ca".to_string());
if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) {
path_length.set(len.to_string());
}
} else if bc.get("ca").is_some() {
ca_mode.set("end".to_string());
}
bc_critical
.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
}
if let Some(ku) = spec.get("key_usage") {
let usages = string_array(ku.get("usages"));
ku_digital.set(usages.iter().any(|s| s == "digital_signature"));
ku_encipher.set(usages.iter().any(|s| s == "key_encipherment"));
ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement"));
ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign"));
ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign"));
ku_critical
.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
}
if let Some(eku) = spec.get("extended_key_usage") {
let usages = string_array(eku.get("usages"));
eku_server.set(usages.iter().any(|s| s == "serverAuth"));
eku_client.set(usages.iter().any(|s| s == "clientAuth"));
eku_code.set(usages.iter().any(|s| s == "codeSigning"));
eku_email.set(usages.iter().any(|s| s == "emailProtection"));
eku_time.set(usages.iter().any(|s| s == "timeStamping"));
eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning"));
eku_critical.set(
eku.get("critical")
.and_then(|v| v.as_bool())
.unwrap_or(false),
);
}
if let Some(san) = spec.get("subject_alt_name") {
let entries = san
.get("entries")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let text = entries
.iter()
.filter_map(|entry| {
let kind =
entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
let value =
entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
if value.is_empty() {
None
} else {
Some(format!("{}:{}", kind.to_uppercase(), value))
}
})
.collect::<Vec<_>>()
.join(", ");
san_text.set(text);
}
ski.set(spec.get("subject_key_identifier").is_some());
aki.set(spec.get("authority_key_identifier").is_some());
ocsp_staple.set(spec.get("tls_feature").is_some());
no_expiration.set(value.summary.no_expiration);
}
}
}
if request_mode {
if let Ok(value) = api::request(&id, &request_id_for_load).await {
name.set(format!("{} certificate", value.summary.name));
@@ -101,7 +255,11 @@ pub fn CertificateNewPage(vault_id: String) -> View {
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "L") {
locality.set(first.value.clone());
}
if let Some(first) = value.subject_entries.iter().find(|e| e.name == "emailAddress") {
if let Some(first) = value
.subject_entries
.iter()
.find(|e| e.name == "emailAddress")
{
email.set(first.value.clone());
}
}
@@ -120,16 +278,21 @@ pub fn CertificateNewPage(vault_id: String) -> View {
match api::template(&id, &template).await {
Ok(template) => {
if let Some(settings) = template.settings.get("extensions") {
load_extra_extensions(&ext, settings);
if let Some(bc) = settings.get("basic_constraints") {
if bc.get("ca").and_then(|v| v.as_bool()).unwrap_or(false) {
ca_mode.set("ca".to_string());
if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) {
if let Some(len) =
bc.get("path_length").and_then(|v| v.as_i64())
{
path_length.set(len.to_string());
}
} else if bc.get("ca").is_some() {
ca_mode.set("end".to_string());
}
bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
bc_critical.set(
bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true),
);
}
if let Some(ku) = settings.get("key_usage") {
let usages = string_array(ku.get("usages"));
@@ -138,7 +301,9 @@ pub fn CertificateNewPage(vault_id: String) -> View {
ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement"));
ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign"));
ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign"));
ku_critical.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
ku_critical.set(
ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true),
);
}
if let Some(eku) = settings.get("extended_key_usage") {
let usages = string_array(eku.get("usages"));
@@ -148,15 +313,37 @@ pub fn CertificateNewPage(vault_id: String) -> View {
eku_email.set(usages.iter().any(|s| s == "emailProtection"));
eku_time.set(usages.iter().any(|s| s == "timeStamping"));
eku_ocsp.set(usages.iter().any(|s| s == "OCSPSigning"));
eku_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false));
eku_critical.set(
eku.get("critical")
.and_then(|v| v.as_bool())
.unwrap_or(false),
);
}
if let Some(san) = settings.get("subject_alt_name") {
let entries = san.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let text = entries.iter().filter_map(|entry| {
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
if value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) }
}).collect::<Vec<_>>().join(", ");
let entries = san
.get("entries")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let text = entries
.iter()
.filter_map(|entry| {
let kind = entry
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("dns");
let value = entry
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("");
if value.is_empty() {
None
} else {
Some(format!("{}:{}", kind.to_uppercase(), value))
}
})
.collect::<Vec<_>>()
.join(", ");
san_text.set(text);
}
ski.set(settings.get("subject_key_identifier").is_some());
@@ -165,7 +352,11 @@ pub fn CertificateNewPage(vault_id: String) -> View {
if let Some(subject) = template.subject.as_array() {
for entry in subject {
let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
let value = entry
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
match key {
"CN" => cn.set(value),
"O" => organization.set(value),
@@ -185,7 +376,12 @@ pub fn CertificateNewPage(vault_id: String) -> View {
if let Some(unit) = validity.get("unit").and_then(|v| v.as_str()) {
validity_unit.set(unit.to_string());
}
midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false));
midnight.set(
validity
.get("midnight")
.and_then(|v| v.as_bool())
.unwrap_or(false),
);
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
@@ -227,7 +423,8 @@ pub fn CertificateNewPage(vault_id: String) -> View {
"path_length": path_length.get_clone().parse::<i64>().ok(),
});
} else if mode == "end" {
extensions["basic_constraints"] = json!({"critical": crit, "ca": false, "path_length": null});
extensions["basic_constraints"] =
json!({"critical": crit, "ca": false, "path_length": null});
}
let usages = [
(ku_digital.get(), "digital_signature"),
@@ -253,7 +450,8 @@ pub fn CertificateNewPage(vault_id: String) -> View {
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
extensions["extended_key_usage"] = json!({"critical": eku_critical.get(), "usages": eku});
extensions["extended_key_usage"] =
json!({"critical": eku_critical.get(), "usages": eku});
let san = parse_san(&san_text.get_clone());
if !san.is_empty() {
extensions["subject_alt_name"] = json!({"critical": false, "entries": san});
@@ -265,7 +463,21 @@ pub fn CertificateNewPage(vault_id: String) -> View {
extensions["authority_key_identifier"] = json!({"critical": false});
}
if ocsp_staple.get() {
extensions["tls_feature"] = json!({"critical": false, "features": ["status_request"]});
extensions["tls_feature"] =
json!({"critical": false, "features": ["status_request"]});
}
match build_extra_extensions(&ext) {
Ok(extra) => {
if let Some(object) = extra.as_object() {
for (key, value) in object {
extensions[key] = value.clone();
}
}
}
Err(message) => {
error.set(Some(message));
return;
}
}
let signer = signer_id.get_clone();
let mut payload = json!({
@@ -275,12 +487,22 @@ pub fn CertificateNewPage(vault_id: String) -> View {
"extensions": extensions,
"signature_hash": signature_hash.get_clone(),
"signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) },
"validity": {
"amount": validity_amount.get_clone().parse::<i64>().unwrap_or(365),
"unit": validity_unit.get_clone(),
"midnight": midnight.get(),
"validity": if no_expiration.get() {
json!({"no_expiration": true})
} else if validity_mode.get_clone() == "absolute" {
json!({})
} else {
json!({
"amount": validity_amount.get_clone().parse::<i64>().unwrap_or(365),
"unit": validity_unit.get_clone(),
"midnight": midnight.get(),
})
},
});
if validity_mode.get_clone() == "absolute" && !no_expiration.get() {
payload["not_before"] = json!(not_before_text.get_clone());
payload["not_after"] = json!(not_after_text.get_clone());
}
if !request_id.is_empty() {
payload["request_id"] = json!(request_id);
payload["modify_subject"] = json!(true);
@@ -303,26 +525,158 @@ pub fn CertificateNewPage(vault_id: String) -> View {
match api::create_certificate(&id, &payload).await {
Ok(cert) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, cert.summary.id));
sycamore_router::navigate(&format!(
"/vaults/{}/certificates/{}",
id, cert.summary.id
));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
if saving.is_alive() {
saving.set(false);
}
});
}
};
let validate = {
let id = id.clone();
move |_| {
let subject = build_subject(&[
("C", country.get_clone()),
("ST", state.get_clone()),
("L", locality.get_clone()),
("O", organization.get_clone()),
("OU", org_unit.get_clone()),
("CN", cn.get_clone()),
("emailAddress", email.get_clone()),
]);
let mut extensions = json!({});
let mode = ca_mode.get_clone();
let crit = bc_critical.get();
if mode == "ca" {
extensions["basic_constraints"] = json!({"critical": crit, "ca": true, "path_length": path_length.get_clone().parse::<i64>().ok()});
} else if mode == "end" {
extensions["basic_constraints"] =
json!({"critical": crit, "ca": false, "path_length": null});
}
let usages = [
(ku_digital.get(), "digital_signature"),
(ku_encipher.get(), "key_encipherment"),
(ku_key_agreement.get(), "key_agreement"),
(ku_cert_sign.get(), "key_cert_sign"),
(ku_crl_sign.get(), "crl_sign"),
]
.iter()
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
extensions["key_usage"] = json!({"critical": ku_critical.get(), "usages": usages});
let eku = [
(eku_server.get(), "serverAuth"),
(eku_client.get(), "clientAuth"),
(eku_code.get(), "codeSigning"),
(eku_email.get(), "emailProtection"),
(eku_time.get(), "timeStamping"),
(eku_ocsp.get(), "OCSPSigning"),
]
.iter()
.filter(|(checked, _)| *checked)
.map(|(_, name)| json!(name))
.collect::<Vec<_>>();
extensions["extended_key_usage"] =
json!({"critical": eku_critical.get(), "usages": eku});
let san = parse_san(&san_text.get_clone());
if !san.is_empty() {
extensions["subject_alt_name"] = json!({"critical": false, "entries": san});
}
if ski.get() {
extensions["subject_key_identifier"] = json!({"critical": false});
}
if aki.get() {
extensions["authority_key_identifier"] = json!({"critical": false});
}
if ocsp_staple.get() {
extensions["tls_feature"] =
json!({"critical": false, "features": ["status_request"]});
}
match build_extra_extensions(&ext) {
Ok(extra) => {
if let Some(object) = extra.as_object() {
for (key, value) in object {
extensions[key] = value.clone();
}
}
}
Err(message) => {
error.set(Some(message));
return;
}
}
let signer = signer_id.get_clone();
let payload = json!({
"name": if name.get_clone().is_empty() { "preview".to_string() } else { name.get_clone() },
"subject": subject,
"extensions": extensions,
"signature_hash": signature_hash.get_clone(),
"signer_id": if signer == "self" { serde_json::Value::Null } else { json!(signer) },
"validity": if no_expiration.get() {
json!({"no_expiration": true})
} else if validity_mode.get_clone() == "absolute" {
json!({})
} else {
json!({"amount": validity_amount.get_clone().parse::<i64>().unwrap_or(365), "unit": validity_unit.get_clone(), "no_expiration": false})
},
});
let mut payload = payload;
if validity_mode.get_clone() == "absolute" && !no_expiration.get() {
payload["not_before"] = json!(not_before_text.get_clone());
payload["not_after"] = json!(not_after_text.get_clone());
}
let id = id.clone();
spawn_local(async move {
match api::preview_certificate(&id, &payload).await {
Ok(value) => {
let mut text = String::new();
if let Some(rows) = value.get("extensions").and_then(|v| v.as_array()) {
for row in rows {
let name = row.get("name").and_then(|v| v.as_str()).unwrap_or("");
let value = row.get("value").and_then(|v| v.as_str()).unwrap_or("");
text.push_str(&format!("{name}: {value}\n"));
}
}
preview_text.set(text);
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
}
};
let signer_options = move || {
signers.get_clone().into_iter().map(|cert| view! {
option(value=cert.id.clone()) { (cert.name.clone()) }
}).collect::<Vec<View>>()
signers
.get_clone()
.into_iter()
.map(|cert| {
view! {
option(value=cert.id.clone()) { (cert.name.clone()) }
}
})
.collect::<Vec<View>>()
};
let key_options = move || {
keys.get_clone().into_iter().filter(|key| key.has_private).map(|key| view! {
option(value=key.id.clone()) { (key.name.clone()) }
}).collect::<Vec<View>>()
keys.get_clone()
.into_iter()
.filter(|key| key.has_private)
.map(|key| {
view! {
option(value=key.id.clone()) { (key.name.clone()) }
}
})
.collect::<Vec<View>>()
};
let template_options = move || {
templates.get_clone().into_iter().map(|template| view! {
@@ -500,29 +854,69 @@ pub fn CertificateNewPage(vault_id: String) -> View {
}
}
}
(extension_fields(&ext))
div(class="form-section") {
div(class="form-section-title") { "Validity" }
div(class="row g-3") {
div(class="col-md-3") {
label(class="form-label") { "Duration" }
input(class="form-control", bind:value=validity_amount)
}
div(class="col-md-3") {
label(class="form-label") { "Unit" }
select(class="form-select", bind:value=validity_unit) {
option(value="days") { "Days" }
option(value="months") { "Months" }
option(value="years") { "Years" }
label(class="form-label") { "Mode" }
select(class="form-select", bind:value=validity_mode) {
option(value="duration") { "Duration" }
option(value="absolute") { "Absolute dates" }
}
}
(if validity_mode.get_clone() == "duration" {
view! {
div(class="col-md-3") {
label(class="form-label") { "Duration" }
input(class="form-control", disabled=no_expiration.get(), bind:value=validity_amount)
}
div(class="col-md-3") {
label(class="form-label") { "Unit" }
select(class="form-select", disabled=no_expiration.get(), bind:value=validity_unit) {
option(value="days") { "Days" }
option(value="months") { "Months" }
option(value="years") { "Years" }
}
}
div(class="col-md-3 d-flex align-items-end") {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", disabled=no_expiration.get(), bind:checked=midnight)
label(class="form-check-label") { "Round to midnight" }
}
}
}
} else {
view! {
div(class="col-md-4") {
label(class="form-label") { "Not before" }
input(class="form-control", r#type="datetime-local", disabled=no_expiration.get(), bind:value=not_before_text)
}
div(class="col-md-4") {
label(class="form-label") { "Not after" }
input(class="form-control", r#type="datetime-local", disabled=no_expiration.get(), bind:value=not_after_text)
}
}
})
div(class="col-md-3 d-flex align-items-end") {
div(class="form-check") {
input(class="form-check-input", r#type="checkbox", bind:checked=midnight)
label(class="form-check-label") { "Round to midnight" }
input(class="form-check-input", r#type="checkbox", bind:checked=no_expiration)
label(class="form-check-label") { "No well-defined expiration" }
}
}
}
}
(if preview_text.get_clone().is_empty() {
view! {}
} else {
view! {
div(class="card mb-3") {
div(class="card-header") { "Extension preview" }
div(class="card-body") { pre(class="pem mb-0") { (preview_text.get_clone()) } }
}
}
})
button(class="btn btn-outline-light btn-lg me-2", on:click=validate) { "Validate extensions" }
button(class="btn btn-primary btn-lg", disabled=saving.get(), on:click=submit) {
(if saving.get() { "Issuing…" } else if request_mode { "Issue certificate" } else { "Create certificate" })
}
@@ -583,13 +977,18 @@ pub fn CertificateRenewPage(vault_id: String, cert_id: String) -> View {
match api::renew_certificate(&id, &cert_id_value, &payload).await {
Ok(value) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/certificates/{}", id, value.summary.id));
sycamore_router::navigate(&format!(
"/vaults/{}/certificates/{}",
id, value.summary.id
));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
if saving.is_alive() {
saving.set(false);
}
});
}
};
@@ -710,7 +1109,12 @@ fn parse_san(text: &str) -> Vec<serde_json::Value> {
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
value
.and_then(|v| v.as_array())
.map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.map(|items| {
items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
+10 -3
View File
@@ -124,7 +124,7 @@ fn flat_html(rows: &[CertificateSummary], vault_id: &str) -> String {
escape(&cert.issuer_name),
if cert.is_ca { "CA" } else { "end entity" },
escape(&cert.serial_hex),
fmt_date(&cert.not_after),
if cert.no_expiration { "No expiration".to_string() } else { fmt_date(&cert.not_after) },
badge_class,
badge_label,
if cert.has_private_key { " <span class=\"badge badge-soft\">key</span>" } else { "" },
@@ -146,7 +146,10 @@ fn tree_html(rows: &[CertificateSummary], vault_id: &str) -> String {
for cert in rows {
match &cert.issuer_id {
Some(issuer) if ids.contains(issuer) && issuer != &cert.id => {
children.entry(issuer.clone()).or_default().push(cert.clone());
children
.entry(issuer.clone())
.or_default()
.push(cert.clone());
}
_ => roots.push(cert.clone()),
}
@@ -200,5 +203,9 @@ fn node_html(
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
+104 -22
View File
@@ -1,6 +1,7 @@
//! Import files, PEM bundles and XCA databases.
use serde_json::json;
use sycamore::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::api;
@@ -21,7 +22,8 @@ pub fn ImportPage(vault_id: String) -> View {
let pem_text = create_signal(String::new());
let password = create_signal(String::new());
let xca_db = create_signal(false);
let preview_text = create_signal(String::new());
let preview_items = create_signal(Vec::<ImportItem>::new());
let selected = create_signal(Vec::<i64>::new());
let result_text = create_signal(String::new());
spawn_local(async move {
@@ -32,11 +34,14 @@ pub fn ImportPage(vault_id: String) -> View {
});
let choose_file = move |event: web_sys::Event| {
let Some(file) = dom::input_file(&event) else { return };
let Some(file) = dom::input_file(&event) else {
return;
};
let name = file.name();
xca_db.set(name.to_lowercase().ends_with(".xdb"));
filename.set(name);
preview_text.set(String::new());
preview_items.set(Vec::new());
selected.set(Vec::new());
result_text.set(String::new());
content_base64.set(String::new());
spawn_local(async move {
@@ -69,11 +74,21 @@ pub fn ImportPage(vault_id: String) -> View {
return;
}
busy.set(true);
preview_text.set(String::new());
preview_items.set(Vec::new());
selected.set(Vec::new());
result_text.set(String::new());
spawn_local(async move {
match api::import_items(id, &payload).await {
Ok(value) => preview_text.set(preview_lines(&value.items)),
Ok(value) => {
let chosen = value
.items
.iter()
.filter(|item| !item.duplicate)
.map(|item| item.index)
.collect::<Vec<_>>();
selected.set(chosen);
preview_items.set(value.items);
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
@@ -82,7 +97,10 @@ pub fn ImportPage(vault_id: String) -> View {
};
let commit = move |_| {
let payload = build_payload(false);
let mut payload = build_payload(false);
if !preview_items.get_clone().is_empty() {
payload["selected"] = json!(selected.get_clone());
}
if payload.get("pem").is_none() && payload.get("content_base64").is_none() {
error.set(Some("choose a file or paste PEM text".to_string()));
return;
@@ -92,9 +110,14 @@ pub fn ImportPage(vault_id: String) -> View {
spawn_local(async move {
match api::import_commit(id, &payload).await {
Ok(value) => {
info.set(Some(format!("Imported {} item(s), skipped {}.", value.imported.len(), value.skipped.len())));
info.set(Some(format!(
"Imported {} item(s), skipped {}.",
value.imported.len(),
value.skipped.len()
)));
result_text.set(import_result_lines(&value.imported, &value.skipped));
preview_text.set(String::new());
preview_items.set(Vec::new());
selected.set(Vec::new());
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
@@ -103,10 +126,41 @@ pub fn ImportPage(vault_id: String) -> View {
});
};
let toggle = move |event: web_sys::Event| {
if let Some(target) = event
.target()
.and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
{
if let Some(raw) = target.get_attribute("data-index") {
if let Ok(index) = raw.parse::<i64>() {
let mut current = selected.get_clone();
if target.checked() {
if !current.contains(&index) {
current.push(index);
}
} else {
current.retain(|value| *value != index);
}
selected.set(current);
}
}
}
};
let select_all = move |_| {
let all = preview_items
.get_clone()
.iter()
.map(|item| item.index)
.collect::<Vec<_>>();
selected.set(all);
};
let select_none = move |_| selected.set(Vec::new());
let content = move || {
if loading.get() {
return spinner();
}
let preview_html = preview_table(&preview_items.get_clone(), &selected.get_clone());
view! {
(page_header(
"Import",
@@ -152,10 +206,14 @@ pub fn ImportPage(vault_id: String) -> View {
}
div(class="col-12 col-lg-7") {
div(class="card mb-3") {
div(class="card-header") { "Preview" }
div(class="card-body") {
pre(class="pem mb-0") { (preview_text.get_clone()) }
div(class="card-header d-flex justify-content-between align-items-center") {
span() { "Preview" }
div(class="d-flex gap-2") {
button(class="btn btn-sm btn-outline-light", on:click=select_all) { "All" }
button(class="btn btn-sm btn-outline-light", on:click=select_none) { "None" }
}
}
div(class="table-responsive", on:change=toggle, dangerously_set_inner_html=preview_html) {}
}
div(class="card") {
div(class="card-header") { "Result" }
@@ -182,17 +240,38 @@ pub fn ImportPage(vault_id: String) -> View {
}
}
fn preview_lines(items: &[ImportItem]) -> String {
items.iter().map(|item| {
format!(
"[{}] {} {}{}{}",
fn preview_table(items: &[ImportItem], selected: &[i64]) -> String {
if items.is_empty() {
return String::new();
}
let mut rows = String::new();
for item in items {
let checked = if selected.contains(&item.index) {
" checked"
} else {
""
};
rows.push_str(&format!(
"<tr><td><input class=\"form-check-input\" type=\"checkbox\" data-index=\"{}\"{checked}></td><td>{}</td><td>{}{}</td><td class=\"item-sub\">{}</td><td>{}</td></tr>",
item.index,
item.kind,
item.name,
if item.is_private { " (private)" } else { "" },
if item.duplicate { " [duplicate]" } else { "" },
)
}).collect::<Vec<_>>().join("\n")
escape(&item.kind),
escape(&item.name),
if item.is_private { " <span class=\"badge badge-ca\">private</span>" } else { "" },
escape(&item.summary),
if item.duplicate { "<span class=\"badge badge-warn\">duplicate</span>" } else { "<span class=\"badge badge-soft\">new</span>" },
));
}
format!(
"<table class=\"table table-sm mb-0\"><thead><tr><th></th><th>Kind</th><th>Name</th><th>Summary</th><th>Status</th></tr></thead><tbody>{rows}</tbody></table>"
)
}
fn escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn import_result_lines(imported: &[ImportItem], skipped: &[ImportItem]) -> String {
@@ -201,7 +280,10 @@ fn import_result_lines(imported: &[ImportItem], skipped: &[ImportItem]) -> Strin
lines.push(format!("imported {} {}", item.kind, item.name));
}
for item in skipped {
lines.push(format!("skipped {} {} ({})", item.kind, item.name, item.summary));
lines.push(format!(
"skipped {} {} ({})",
item.kind, item.name, item.summary
));
}
lines.join("\n")
}
+113 -19
View File
@@ -4,6 +4,9 @@ use sycamore::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::extension_form::{
build_extra_extensions, extension_fields, load_extra_extensions, ExtensionSignals,
};
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::dom;
@@ -11,7 +14,9 @@ use crate::model::{Me, TemplateSummary};
pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View {
let id: &'static str = Box::leak(vault_id.clone().into_boxed_str());
let template_id_value: Option<&'static str> = template_id.as_ref().map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str);
let template_id_value: Option<&'static str> = template_id
.as_ref()
.map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str);
let me = create_signal(None::<Me>);
let template = create_signal(None::<TemplateSummary>);
let error = create_signal(None::<String>);
@@ -45,6 +50,30 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
let san_text = create_signal(String::new());
let ski = create_signal(true);
let aki = create_signal(true);
let crl_urls = create_signal(String::new());
let aia_ocsp = create_signal(String::new());
let aia_ca_issuers = create_signal(String::new());
let nc_permitted = create_signal(String::new());
let nc_excluded = create_signal(String::new());
let policy_oids = create_signal(String::new());
let policy_cps = create_signal(String::new());
let policy_require = create_signal(String::new());
let policy_inhibit = create_signal(String::new());
let inhibit_any = create_signal(String::new());
let advanced_json = create_signal(String::new());
let ext = ExtensionSignals {
crl_urls,
aia_ocsp,
aia_ca_issuers,
nc_permitted,
nc_excluded,
policy_oids,
policy_cps,
policy_require,
policy_inhibit,
inhibit_any,
advanced_json,
};
if let Some(template_id) = template_id.clone() {
spawn_local(async move {
@@ -59,7 +88,11 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
if let Some(subject) = value.subject.as_array() {
for entry in subject {
let key = entry.get("name").and_then(|v| v.as_str()).unwrap_or("");
let val = entry.get("value").and_then(|v| v.as_str()).unwrap_or("").to_string();
let val = entry
.get("value")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
match key {
"CN" => cn.set(val),
"O" => organization.set(val),
@@ -76,7 +109,12 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
if let Some(v) = validity.get("unit").and_then(|v| v.as_str()) {
unit.set(v.to_string());
}
midnight.set(validity.get("midnight").and_then(|v| v.as_bool()).unwrap_or(false));
midnight.set(
validity
.get("midnight")
.and_then(|v| v.as_bool())
.unwrap_or(false),
);
}
if let Some(extensions) = value.settings.get("extensions") {
if let Some(bc) = extensions.get("basic_constraints") {
@@ -87,7 +125,8 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
} else {
ca_mode.set("none".to_string());
}
bc_critical.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
bc_critical
.set(bc.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
if let Some(len) = bc.get("path_length").and_then(|v| v.as_i64()) {
path_length.set(len.to_string());
}
@@ -99,7 +138,8 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
ku_key_agreement.set(usages.iter().any(|s| s == "key_agreement"));
ku_cert_sign.set(usages.iter().any(|s| s == "key_cert_sign"));
ku_crl_sign.set(usages.iter().any(|s| s == "crl_sign"));
ku_critical.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
ku_critical
.set(ku.get("critical").and_then(|v| v.as_bool()).unwrap_or(true));
}
if let Some(eku) = extensions.get("extended_key_usage") {
let usages = string_array(eku.get("usages"));
@@ -107,19 +147,38 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
eku_client.set(usages.iter().any(|s| s == "clientAuth"));
eku_code.set(usages.iter().any(|s| s == "codeSigning"));
eku_email.set(usages.iter().any(|s| s == "emailProtection"));
eku_critical.set(eku.get("critical").and_then(|v| v.as_bool()).unwrap_or(false));
eku_critical.set(
eku.get("critical")
.and_then(|v| v.as_bool())
.unwrap_or(false),
);
}
if let Some(san) = extensions.get("subject_alt_name") {
let entries = san.get("entries").and_then(|v| v.as_array()).cloned().unwrap_or_default();
let text = entries.iter().filter_map(|entry| {
let kind = entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
let value = entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
if value.is_empty() { None } else { Some(format!("{}:{}", kind.to_uppercase(), value)) }
}).collect::<Vec<_>>().join(", ");
let entries = san
.get("entries")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let text = entries
.iter()
.filter_map(|entry| {
let kind =
entry.get("type").and_then(|v| v.as_str()).unwrap_or("dns");
let value =
entry.get("value").and_then(|v| v.as_str()).unwrap_or("");
if value.is_empty() {
None
} else {
Some(format!("{}:{}", kind.to_uppercase(), value))
}
})
.collect::<Vec<_>>()
.join(", ");
san_text.set(text);
}
ski.set(extensions.get("subject_key_identifier").is_some());
aki.set(extensions.get("authority_key_identifier").is_some());
load_extra_extensions(&ext, extensions);
}
template.set(Some(value));
}
@@ -138,7 +197,9 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
let submit = {
let id = id.clone();
let template_id: Option<&'static str> = template_id.as_ref().map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str);
let template_id: Option<&'static str> = template_id
.as_ref()
.map(|value| Box::leak(value.clone().into_boxed_str()) as &'static str);
move |_| {
let id = id.clone();
let template_id = template_id.clone();
@@ -180,12 +241,30 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
if mode == "ca" {
extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": true, "path_length": path_length.get_clone().parse::<i64>().ok()});
} else if mode == "end" {
extensions["basic_constraints"] = json!({"critical": bc_critical.get(), "ca": false, "path_length": null});
extensions["basic_constraints"] =
json!({"critical": bc_critical.get(), "ca": false, "path_length": null});
} else {
extensions["basic_constraints"] = serde_json::Value::Null;
}
let san = parse_san(&san_text.get_clone());
extensions["subject_alt_name"] = if san.is_empty() { serde_json::Value::Null } else { json!({"critical": false, "entries": san}) };
extensions["subject_alt_name"] = if san.is_empty() {
serde_json::Value::Null
} else {
json!({"critical": false, "entries": san})
};
match build_extra_extensions(&ext) {
Ok(extra) => {
if let Some(object) = extra.as_object() {
for (key, value) in object {
extensions[key] = value.clone();
}
}
}
Err(message) => {
error.set(Some(message));
return;
}
}
let payload = json!({
"name": name.get_clone(),
"comment": comment.get_clone(),
@@ -204,13 +283,18 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
match result {
Ok(value) => {
saving.set(false);
sycamore_router::navigate(&format!("/vaults/{}/templates/{}", id, value.id));
sycamore_router::navigate(&format!(
"/vaults/{}/templates/{}",
id, value.id
));
return;
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
if saving.is_alive() { saving.set(false); }
if saving.is_alive() {
saving.set(false);
}
});
}
};
@@ -220,7 +304,11 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
spawn_local(async move {
match api::export_template(id, template_id, &json!({"format": "xca"})).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(&payload.filename, &payload.content_type, &payload.content_base64) {
if let Err(message) = dom::download_base64(
&payload.filename,
&payload.content_type,
&payload.content_base64,
) {
error.set(Some(message));
}
}
@@ -326,6 +414,7 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
}
}
}
(extension_fields(&ext))
div(class="form-section") {
div(class="form-section-title") { "Default validity" }
div(class="row g-3") {
@@ -417,6 +506,11 @@ fn parse_san(text: &str) -> Vec<serde_json::Value> {
fn string_array(value: Option<&serde_json::Value>) -> Vec<String> {
value
.and_then(|v| v.as_array())
.map(|items| items.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.map(|items| {
items
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
+31 -1
View File
@@ -5,6 +5,7 @@ use wasm_bindgen_futures::spawn_local;
use crate::api;
use crate::components::nav::{app_nav, spinner, whoami};
use crate::components::ui::{empty_state, page_header, toast_error};
use crate::dom;
use crate::model::{fmt_date, item_total, Me, SummaryPage, VaultDetail};
pub fn VaultPage(vault_id: String) -> View {
@@ -59,6 +60,30 @@ pub fn VaultPage(vault_id: String) -> View {
}
});
let export = move |_| {
let password = web_sys::window().and_then(|window| {
window
.prompt_with_message("Password for private keys (empty: public material only)")
.ok()
.flatten()
});
spawn_local(async move {
match api::export_vault(id, password.as_deref()).await {
Ok(payload) => {
if let Err(message) = dom::download_base64(
&payload.filename,
&payload.content_type,
&payload.content_base64,
) {
error.set(Some(message));
}
}
Err(message) if message == api::LOGIN_REQUIRED => {}
Err(message) => error.set(Some(message)),
}
});
};
let content = move || {
if loading.get() {
return spinner();
@@ -98,6 +123,7 @@ pub fn VaultPage(vault_id: String) -> View {
a(class="btn btn-outline-light", href=format!("/vaults/{id}/keys/new")) { "New key" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/import")) { "Import" }
a(class="btn btn-outline-light", href=format!("/vaults/{id}/settings")) { "Settings" }
button(class="btn btn-outline-light", on:click=export) { "Export ZIP" }
},
))
div(class="row g-3 mb-4") {
@@ -169,5 +195,9 @@ fn stat_card(label: impl Into<String>, value: i64, href: String) -> View {
}
fn escape(value: &str) -> String {
value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}