Combine AIA fields into XCA-style editor
CI / Build and push docker image (push) Successful in 1m43s
CI / Build and push OCSP image (push) Successful in 33s

This commit is contained in:
Walter Oggioni
2026-10-26 23:55:50 +08:00
committed by woggioni
parent cc05d450a2
commit 555f3f28f7
6 changed files with 210 additions and 57 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ test("advanced extensions, validation and vault export", async ({ page }) => {
await (await field(page, "Country (C)")).fill("DE");
await page.locator("#new-key").check();
await page.locator("textarea[placeholder='http://example.com/ca.crl']").fill("http://example.com/ca.crl");
await page.locator("textarea[placeholder='http://ocsp.example.com']").fill("http://ocsp.example.com");
await page.locator("#aia").fill("OCSP;URI:http://ocsp.example.com");
await page.locator("textarea[placeholder*='raw']").fill(
'{"raw":[{"oid":"1.2.3.4","critical":false,"der_hex":"0c03616263"}]}'
);
+36
View File
@@ -38,6 +38,11 @@ test("advanced extension list editors", async ({ page }) => {
const vaultName = unique("List Editors");
const certName = unique("constraints.example");
const crlUrls = ["https://ca.example.com/root.crl", "https://backup.example.com/root.crl"];
const aiaEntries = [
["OCSP;URI", "https://ocsp.example.com"],
["caIssuers;URI", "https://ca.example.com/issuer.crt"],
];
const aiaText = aiaEntries.map(([type, value]) => `${type}:${value}`).join(", ");
const cpsUrls = ["https://example.com/cps", "https://example.com/policy"];
await login(page, user);
@@ -79,6 +84,24 @@ test("advanced extension list editors", async ({ page }) => {
await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked();
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
// AIA combines the access method and XCA general-name type in each row.
dialog = await openEditor(page, "#edit-aia", "Authority information access");
await expect(dialog.getByLabel("Critical", { exact: true })).not.toBeChecked();
for (const [type, value] of aiaEntries) {
await addRow(dialog, type, value);
}
await dialog.getByLabel("Critical", { exact: true }).check();
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.locator("#aia")).toHaveValue(aiaText);
dialog = await openEditor(page, "#edit-aia", "Authority information access");
await expect(dialog.locator(".list-entry")).toHaveCount(2);
await expect(dialog.locator(".list-entry").nth(0).locator("select")).toHaveValue("OCSP;URI");
await expect(dialog.locator(".list-entry").nth(1).locator("select")).toHaveValue("caIssuers;URI");
await expect(dialog.locator(".list-entry").nth(1).locator(".list-value")).toHaveValue(aiaEntries[1][1]);
await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked();
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
// Name constraints use typed rows and share one extension-level critical flag.
dialog = await openEditor(page, "#edit-nc-permitted", "Name constraints · permitted");
await expect(dialog.getByLabel("Critical", { exact: true })).toBeChecked();
@@ -117,6 +140,7 @@ test("advanced extension list editors", async ({ page }) => {
await expect(page.locator("#nc-excluded")).toHaveValue("DNS:.bad.example");
await page.getByRole("button", { name: "Validate extensions" }).click();
await expect(page.getByText("Extension preview")).toBeVisible();
await expect(page.locator("pre.pem")).toContainText("Authority Information Access");
await expect(page.locator("pre.pem")).toContainText("Name Constraints");
await expect(page.locator("pre.pem")).toContainText("Certificate Policies");
@@ -129,6 +153,13 @@ test("advanced extension list editors", async ({ page }) => {
}, certApiUrl);
const extensions = detail.extension_spec;
expect(extensions.crl_distribution_points).toEqual({ critical: true, urls: crlUrls });
expect(extensions.authority_information_access).toEqual({
critical: true,
descriptions: aiaEntries.map(([type, value]) => ({
method: type.startsWith("OCSP") ? "ocsp" : "ca_issuers",
location: { type: "uri", value },
})),
});
expect(extensions.name_constraints).toEqual({
critical: true,
permitted: [
@@ -149,4 +180,9 @@ test("advanced extension list editors", async ({ page }) => {
await addRow(dialog, "URI", "https://template.example.com/root.crl");
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.locator("#crl-urls")).toHaveValue("https://template.example.com/root.crl");
dialog = await openEditor(page, "#edit-aia", "Authority information access");
await addRow(dialog, "OCSP;URI", "https://template.example.com/ocsp");
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
await expect(page.locator("#aia")).toHaveValue("OCSP;URI:https://template.example.com/ocsp");
});
+33 -46
View File
@@ -9,16 +9,17 @@ use serde_json::{json, Map, Value};
use sycamore::prelude::*;
use crate::components::list_editor::{
general_name_entries_to_text, list_editor_dialog, open_list_editor, parse_other_name,
split_list, ListEditorConfig, ListEditorFormat, ListEditorSignals,
aia_entries_to_text, general_name_entries_to_text, list_editor_dialog, open_list_editor,
parse_aia_entries, parse_other_name, split_list, ListEditorConfig, ListEditorFormat,
ListEditorSignals,
};
#[derive(Clone, Copy)]
pub struct ExtensionSignals {
pub crl_urls: Signal<String>,
pub crl_critical: Signal<bool>,
pub aia_ocsp: Signal<String>,
pub aia_ca_issuers: Signal<String>,
pub aia: Signal<String>,
pub aia_critical: Signal<bool>,
pub nc_permitted: Signal<String>,
pub nc_excluded: Signal<String>,
pub nc_critical: Signal<bool>,
@@ -33,8 +34,7 @@ pub struct ExtensionSignals {
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 aia = s.aia;
let nc_permitted = s.nc_permitted;
let nc_excluded = s.nc_excluded;
let policy_oids = s.policy_oids;
@@ -45,6 +45,7 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
let advanced_json = s.advanced_json;
let crl_editor = ListEditorSignals::new();
let aia_editor = ListEditorSignals::new();
let nc_permitted_editor = ListEditorSignals::new();
let nc_excluded_editor = ListEditorSignals::new();
let policy_cps_editor = ListEditorSignals::new();
@@ -55,6 +56,13 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
critical: Some(s.crl_critical),
copy_common_name: false,
};
let aia_config = ListEditorConfig {
id: "aia",
title: "Authority information access",
format: ListEditorFormat::AuthorityInformationAccess,
critical: Some(s.aia_critical),
copy_common_name: false,
};
let nc_permitted_config = ListEditorConfig {
id: "nc-permitted",
title: "Name constraints · permitted",
@@ -77,6 +85,7 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
copy_common_name: false,
};
let edit_crl = move |_| open_list_editor(crl_editor, crl_urls, crl_config);
let edit_aia = move |_| open_list_editor(aia_editor, aia, aia_config);
let edit_nc_permitted =
move |_| open_list_editor(nc_permitted_editor, nc_permitted, nc_permitted_config);
let edit_nc_excluded =
@@ -95,13 +104,12 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
button(id="edit-crl-urls", class="btn btn-outline-light", r#type="button", on:click=edit_crl) { "Edit" }
}
}
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-6") {
label(class="form-label") { "Authority information access" }
div(class="input-group") {
textarea(id="aia", class="form-control", rows="2", placeholder="OCSP;URI:http://ocsp.example.com, caIssuers;URI:http://example.com/ca.crt", bind:value=aia) {}
button(id="edit-aia", class="btn btn-outline-light", r#type="button", on:click=edit_aia) { "Edit" }
}
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" }
@@ -152,6 +160,7 @@ pub fn extension_fields(s: &ExtensionSignals) -> View {
}
}
(list_editor_dialog(crl_editor, crl_urls, crl_config))
(list_editor_dialog(aia_editor, aia, aia_config))
(list_editor_dialog(
nc_permitted_editor,
nc_permitted,
@@ -181,17 +190,11 @@ pub fn build_extra_extensions(s: &ExtensionSignals) -> Result<Value, String> {
);
}
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}}));
}
let aia = parse_aia_entries(&s.aia.get_clone())?;
if !aia.is_empty() {
extensions.insert(
"authority_information_access".to_string(),
json!({"critical": false, "descriptions": aia}),
json!({"critical": s.aia_critical.get(), "descriptions": aia}),
);
}
@@ -268,34 +271,18 @@ pub fn load_extra_extensions(s: &ExtensionSignals, spec: &Value) {
.and_then(|v| v.as_bool())
.unwrap_or(false),
);
let descriptions = spec
.get("authority_information_access")
.and_then(|v| v.get("descriptions"))
.and_then(|v| v.as_array())
let aia = spec.get("authority_information_access");
let descriptions = aia
.and_then(|value| value.get("descriptions"))
.and_then(|value| value.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);
s.aia.set(aia_entries_to_text(&descriptions));
s.aia_critical.set(
aia.and_then(|value| value.get("critical"))
.and_then(|value| value.as_bool())
.unwrap_or(false),
);
let nc = spec.get("name_constraints");
s.nc_critical.set(
+129
View File
@@ -44,6 +44,7 @@ impl ListEditorSignals {
pub enum ListEditorFormat {
GeneralNames,
UriList(&'static str),
AuthorityInformationAccess,
}
#[derive(Clone, Copy)]
@@ -88,6 +89,25 @@ pub fn open_list_editor(editor: ListEditorSignals, text: Signal<String>, config:
}
}
}
ListEditorFormat::AuthorityInformationAccess => {
for entry in parse_aia_entries(&text.get_clone()).unwrap_or_default() {
let method =
if entry.get("method").and_then(|value| value.as_str()) == Some("ca_issuers") {
"caIssuers"
} else {
"OCSP"
};
let Some(location) = entry.get("location") else {
continue;
};
let location = general_name_entries_to_text(std::slice::from_ref(location));
let Some((kind, value)) = location.split_once(':') else {
continue;
};
rows.push(ListRow::new(next_id, &format!("{method};{kind}"), value));
next_id += 1;
}
}
}
editor.rows.set(rows);
editor.next_id.set(next_id);
@@ -120,6 +140,7 @@ pub fn list_editor_dialog(
let kind = match config.format {
ListEditorFormat::GeneralNames => "DNS",
ListEditorFormat::UriList(_) => "URI",
ListEditorFormat::AuthorityInformationAccess => "OCSP;URI",
};
rows.update(|rows| rows.push(ListRow::new(id, kind, "")));
};
@@ -139,11 +160,15 @@ pub fn list_editor_dialog(
format!("{}:{}", row.kind.get_clone(), value)
}
ListEditorFormat::UriList(_) => value.to_string(),
ListEditorFormat::AuthorityInformationAccess => {
format!("{}:{}", row.kind.get_clone(), value)
}
});
}
let separator = match config.format {
ListEditorFormat::GeneralNames => ", ",
ListEditorFormat::UriList(separator) => separator,
ListEditorFormat::AuthorityInformationAccess => ", ",
};
text.set(entries.join(separator));
if let Some(target) = config.critical {
@@ -252,6 +277,12 @@ pub fn list_editor_dialog(
"For otherName, use <OID>;UTF8:<content> or <OID>;DER:<hex>."
}
}
} else if matches!(config.format, ListEditorFormat::AuthorityInformationAccess) {
view! {
div(class="form-text mt-2") {
"Use OCSP or caIssuers with a URI, email, RID, DNS or IP location."
}
}
} else {
view! {}
})
@@ -286,9 +317,82 @@ fn list_type_options(format: ListEditorFormat) -> View {
ListEditorFormat::UriList(_) => view! {
option(value="URI") { "URI" }
},
ListEditorFormat::AuthorityInformationAccess => view! {
option(value="OCSP;URI") { "OCSP;URI" }
option(value="OCSP;email") { "OCSP;email" }
option(value="OCSP;RID") { "OCSP;RID" }
option(value="OCSP;DNS") { "OCSP;DNS" }
option(value="OCSP;IP") { "OCSP;IP" }
option(value="caIssuers;URI") { "caIssuers;URI" }
option(value="caIssuers;email") { "caIssuers;email" }
option(value="caIssuers;RID") { "caIssuers;RID" }
option(value="caIssuers;DNS") { "caIssuers;DNS" }
option(value="caIssuers;IP") { "caIssuers;IP" }
},
}
}
pub fn parse_aia_entries(text: &str) -> Result<Vec<Value>, String> {
let mut entries = Vec::new();
for chunk in split_list(text) {
let (prefix, value) = chunk.split_once(':').ok_or_else(|| {
format!("authority information access entry {chunk:?} must use METHOD;TYPE:value")
})?;
let (method, kind) = prefix.split_once(';').ok_or_else(|| {
format!("authority information access entry {chunk:?} must use METHOD;TYPE:value")
})?;
let method = match method.trim().to_ascii_lowercase().as_str() {
"ocsp" => "ocsp",
"caissuers" | "ca_issuers" | "ca-issuers" => "ca_issuers",
other => {
return Err(format!(
"unknown authority information access method {other:?}"
))
}
};
let kind = match kind.trim().to_ascii_lowercase().as_str() {
"uri" => "uri",
"email" => "email",
"rid" => "rid",
"dns" => "dns",
"ip" => "ip",
other => {
return Err(format!(
"unknown authority information access location type {other:?}"
))
}
};
let location = parse_general_name_entries(&format!("{kind}:{value}"))
.into_iter()
.next()
.ok_or_else(|| format!("authority information access entry {chunk:?} is invalid"))?;
entries.push(json!({"method": method, "location": location}));
}
Ok(entries)
}
pub fn aia_entries_to_text(entries: &[Value]) -> String {
entries
.iter()
.filter_map(|entry| {
let method = match entry
.get("method")
.and_then(|value| value.as_str())?
.to_ascii_lowercase()
.as_str()
{
"ocsp" => "OCSP",
"caissuers" | "ca_issuers" | "ca-issuers" => "caIssuers",
_ => return None,
};
let location =
general_name_entries_to_text(std::slice::from_ref(entry.get("location")?));
(!location.is_empty()).then(|| format!("{method};{location}"))
})
.collect::<Vec<_>>()
.join(", ")
}
pub fn split_list(text: &str) -> Vec<String> {
text.split(|c| c == ',' || c == '\n')
.map(|item| item.trim().to_string())
@@ -425,4 +529,29 @@ mod tests {
assert_eq!(uri_value(&items[0]), "https://one.example/crl");
assert_eq!(uri_value(&items[1]), "https://two.example/crl");
}
#[test]
fn aia_entries_round_trip_methods_and_location_types() {
let text = "OCSP;URI:https://ocsp.example.com, caIssuers;URI:https://example.com/ca.crt, OCSP;DNS:ocsp.example.com, caIssuers;email:pki@example.com, OCSP;RID:1.2.3.4.5, caIssuers;IP:192.0.2.1";
let entries = parse_aia_entries(text).unwrap();
assert_eq!(entries.len(), 6);
assert_eq!(entries[1]["method"], "ca_issuers");
assert_eq!(
entries[1]["location"],
json!({"type": "uri", "value": "https://example.com/ca.crt"})
);
assert_eq!(
entries[4]["location"],
json!({"type": "rid", "value": "1.2.3.4.5"})
);
assert_eq!(aia_entries_to_text(&entries), text);
}
#[test]
fn aia_entries_reject_invalid_prefixes() {
assert!(parse_aia_entries("OCSP;URI").is_err());
assert!(parse_aia_entries("bogus;URI:https://example.com").is_err());
assert!(parse_aia_entries("OCSP;UPN:user@example.com").is_err());
}
}
+6 -5
View File
@@ -18,7 +18,8 @@ use crate::components::list_editor::{
use crate::components::nav::{app_nav, whoami};
use crate::components::ui::{page_header, toast_error};
use crate::model::{
CertificateDetail, CertificateSummary, DnAttribute, KeySummary, Me, OptionsPage, TemplateSummary,
CertificateDetail, CertificateSummary, DnAttribute, KeySummary, Me, OptionsPage,
TemplateSummary,
};
const DEFAULT_EXPLICIT_DN: [&str; 7] = ["C", "ST", "L", "O", "OU", "CN", "emailAddress"];
@@ -134,8 +135,8 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
let ocsp_staple = create_signal(false);
let crl_urls = create_signal(String::new());
let crl_critical = create_signal(false);
let aia_ocsp = create_signal(String::new());
let aia_ca_issuers = create_signal(String::new());
let aia = create_signal(String::new());
let aia_critical = create_signal(false);
let nc_permitted = create_signal(String::new());
let nc_excluded = create_signal(String::new());
let nc_critical = create_signal(true);
@@ -149,8 +150,8 @@ pub fn CertificateNewPage(vault_id: String, from_cert: Option<String>) -> View {
let ext = ExtensionSignals {
crl_urls,
crl_critical,
aia_ocsp,
aia_ca_issuers,
aia,
aia_critical,
nc_permitted,
nc_excluded,
nc_critical,
+4 -4
View File
@@ -52,8 +52,8 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
let aki = create_signal(true);
let crl_urls = create_signal(String::new());
let crl_critical = create_signal(false);
let aia_ocsp = create_signal(String::new());
let aia_ca_issuers = create_signal(String::new());
let aia = create_signal(String::new());
let aia_critical = create_signal(false);
let nc_permitted = create_signal(String::new());
let nc_excluded = create_signal(String::new());
let nc_critical = create_signal(true);
@@ -67,8 +67,8 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option<String>) -> View
let ext = ExtensionSignals {
crl_urls,
crl_critical,
aia_ocsp,
aia_ca_issuers,
aia,
aia_critical,
nc_permitted,
nc_excluded,
nc_critical,