diff --git a/e2e/tests/advanced.spec.js b/e2e/tests/advanced.spec.js index b111c78..c6b9c41 100644 --- a/e2e/tests/advanced.spec.js +++ b/e2e/tests/advanced.spec.js @@ -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"}]}' ); diff --git a/e2e/tests/extension_editors.spec.js b/e2e/tests/extension_editors.spec.js index 2d8469f..2cad11e 100644 --- a/e2e/tests/extension_editors.spec.js +++ b/e2e/tests/extension_editors.spec.js @@ -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"); }); diff --git a/web/src/components/extension_form.rs b/web/src/components/extension_form.rs index 420de57..1cdc47c 100644 --- a/web/src/components/extension_form.rs +++ b/web/src/components/extension_form.rs @@ -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, pub crl_critical: Signal, - pub aia_ocsp: Signal, - pub aia_ca_issuers: Signal, + pub aia: Signal, + pub aia_critical: Signal, pub nc_permitted: Signal, pub nc_excluded: Signal, pub nc_critical: Signal, @@ -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-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") { "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-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 { ); } - 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::>() - .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::>() - .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( diff --git a/web/src/components/list_editor.rs b/web/src/components/list_editor.rs index bf8f752..450a9b6 100644 --- a/web/src/components/list_editor.rs +++ b/web/src/components/list_editor.rs @@ -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, 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 ;UTF8: or ;DER:." } } + } 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, 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::>() + .join(", ") +} + pub fn split_list(text: &str) -> Vec { 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()); + } } diff --git a/web/src/pages/certificate_new.rs b/web/src/pages/certificate_new.rs index cfd4005..fa3e392 100644 --- a/web/src/pages/certificate_new.rs +++ b/web/src/pages/certificate_new.rs @@ -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) -> 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) -> View { let ext = ExtensionSignals { crl_urls, crl_critical, - aia_ocsp, - aia_ca_issuers, + aia, + aia_critical, nc_permitted, nc_excluded, nc_critical, diff --git a/web/src/pages/template_editor.rs b/web/src/pages/template_editor.rs index 5ae41eb..a35c6e6 100644 --- a/web/src/pages/template_editor.rs +++ b/web/src/pages/template_editor.rs @@ -52,8 +52,8 @@ pub fn TemplateEditorPage(vault_id: String, template_id: Option) -> 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) -> View let ext = ExtensionSignals { crl_urls, crl_critical, - aia_ocsp, - aia_ca_issuers, + aia, + aia_critical, nc_permitted, nc_excluded, nc_critical,