diff --git a/.gitignore b/.gitignore index 8f6102f..3538b1e 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ # host-specific compose overlay compose.override.yml + +# Throwaway certificates the inspect tests read; a global *.pem rule must not +# keep them out. +!/src/routes/testdata/*.pem diff --git a/Cargo.lock b/Cargo.lock index 80c555a..d997b32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,6 +154,45 @@ dependencies = [ "password-hash 0.6.1", ] +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom 7.1.3", + "num-traits", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -772,6 +811,18 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cms" +version = "0.3.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758d5272932ff167e96ec9641a04d96ab1901cccc5ea9f47c15b4cbaa6f9ea53" +dependencies = [ + "const-oid 0.10.2", + "der 0.8.2", + "spki 0.8.0", + "x509-cert", +] + [[package]] name = "combine" version = "4.6.8" @@ -1186,10 +1237,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" dependencies = [ "const-oid 0.10.2", + "der_derive", + "flagset", "pem-rfc7468 1.0.0", "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom 7.1.3", + "num-bigint 0.4.8", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "der_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59600e2c2d636fde9b65e99cc6445ac770c63d3628195ff39932b8d6d7409903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1662,6 +1746,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.10" @@ -2520,7 +2610,7 @@ dependencies = [ "httpdate", "idna", "mime", - "nom", + "nom 8.0.0", "percent-encoding", "quoted_printable", "rustls", @@ -2685,6 +2775,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -2766,6 +2862,8 @@ dependencies = [ "keyring", "lettre", "mime_guess", + "p12-keystore", + "pkcs8 0.11.0", "proton-drive-rs", "proton-sdk", "rand 0.10.2", @@ -2775,8 +2873,10 @@ dependencies = [ "russh", "russh-sftp", "rust-embed", + "sec1 0.8.1", "serde", "serde_json", + "sha1 0.10.7", "sha2 0.10.9", "sqlx", "subtle", @@ -2788,6 +2888,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "x509-parser", "zeroize", "zip", ] @@ -2817,6 +2918,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -2897,6 +3008,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.47" @@ -2970,6 +3087,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -3004,6 +3130,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p12-keystore" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2ed4fa0ec7ec58b657d4184680c57da9e203e97771d29a566028869a5193e0" +dependencies = [ + "cbc 0.2.1", + "cms", + "der 0.8.2", + "des 0.9.0", + "hex", + "hmac 0.13.0", + "pkcs12", + "pkcs5", + "pkcs8 0.11.0", + "rand 0.10.2", + "rc2", + "sha1 0.11.0", + "sha2 0.11.0", + "thiserror 2.0.20", + "x509-parser", +] + [[package]] name = "p256" version = "0.13.2" @@ -3231,7 +3380,7 @@ dependencies = [ "log", "md-5", "memchr", - "nom", + "nom 8.0.0", "num-bigint-dig", "num-traits", "num_enum", @@ -3322,6 +3471,21 @@ dependencies = [ "spki 0.8.0", ] +[[package]] +name = "pkcs12" +version = "0.2.0-pre.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8059bd79345d1d1c125656025814704bab5cdd204b1cefd5fab59b0fdd7476d2" +dependencies = [ + "cms", + "const-oid 0.10.2", + "der 0.8.2", + "digest 0.11.3", + "spki 0.8.0", + "x509-cert", + "zeroize", +] + [[package]] name = "pkcs5" version = "0.8.1" @@ -3332,6 +3496,7 @@ dependencies = [ "aes-gcm 0.11.1", "cbc 0.2.1", "der 0.8.2", + "des 0.9.0", "pbkdf2", "rand_core 0.10.1", "scrypt", @@ -3431,6 +3596,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3695,6 +3866,15 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rc2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ceda21af1ae61033b63175653a1af86cae399d79cd03ca80ba347eb3a6c4a7fe" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4070,6 +4250,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "rustix" version = "1.1.4" @@ -5034,6 +5223,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -5953,6 +6172,34 @@ dependencies = [ "zeroize", ] +[[package]] +name = "x509-cert" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "105ef4642d9cb137ef83d623d0e4bf08b8adf69e9918ca904a174adb6d3d038b" +dependencies = [ + "const-oid 0.10.2", + "der 0.8.2", + "spki 0.8.0", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom 7.1.3", + "oid-registry", + "rusticata-macros", + "thiserror 2.0.20", + "time", +] + [[package]] name = "xdg-home" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 451c839..f638898 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,15 @@ russh-sftp = "3" # speaks rand_core 0.10, one major version past the `rand` used everywhere else. rand10 = { package = "rand", version = "0.10" } +# Reading certificates, keys and keystores for the file view, in pure Rust — +# there is no OpenSSL to lean on. p12-keystore brings RC2 and 3DES for the +# keystores older tools still export; pkcs8 opens encrypted PKCS#8 keys. +x509-parser = "0.18" +p12-keystore = "0.4" +pkcs8 = { version = "0.11", features = ["encryption", "3des", "std"] } +sec1 = { version = "0.8", features = ["der"] } +sha1 = "0.10" + [dev-dependencies] # A paused clock, so tests of timed loops do not wait in real time. tokio = { version = "1", features = ["test-util"] } diff --git a/README.md b/README.md index 15032c2..60567fd 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ one binary. It was called `narl-diary` until it grew everything but the diary. - A kanban board per space, so what is open at work is not mixed up with uni. - Drag, drop or paste images, video and audio straight into an entry. - Files of any kind — PDFs, archives, recordings — live in the tree beside the - notes about them, streamed to disk however large. + notes about them, streamed to disk however large — and PDFs, text, code, + tables and certificates open in place. - Share any document behind an unguessable link — revocable at any time. - A vault: files sealed with AES-256-GCM under a password that is never stored, readable only while it is unlocked. @@ -237,8 +238,23 @@ the tree is never one of them: it is deleted from its folder, not from ## Files A file is a node like a document, only with bytes instead of text: it sits in -a folder, shows its size in the list, and `Enter` opens it — images, video, -audio and plain text are shown in place, anything else is a download. `:name` +a folder, shows its size in the list, and `Enter` opens it. What is shown in +place: + +- images, video and audio; +- PDFs, in the browser's own viewer; +- text and source code of any kind, recognised by its name or, failing that, by + its first few kilobytes, with line numbers and the first 256 KiB of it; +- markdown, rendered, with `source` to see it as written; +- CSV and TSV, as a table of the first 500 rows; +- certificates, keys and keystores — `.pem`, `.crt`, `.cer`, `.der`, `.csr`, + `.key`, `.p8`, `.p12`, `.pfx` — read on the server: subject, issuer, + validity, key, names, usage and fingerprints, and which key belongs to which + certificate. A `.p12` or an encrypted key asks for its password, which opens + it for that one look and is never stored. Nothing of a private key is ever + sent back but its algorithm and size. + +Anything else is a download. `:name` renames it, and the download is called whatever it is called now; `:mv`, `m` and `p` move it; `x` deletes it. `:upload` picks files for the folder being browsed, or drop them onto the list. Each upload is written to disk as it @@ -532,6 +548,7 @@ sealed or not. | `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns | | `GET` | `/api/spaces/overview` | every space with what was last written in it | | `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) | +| `POST` | `/api/nodes/{id}/inspect` | what a certificate, key or keystore file holds (`{password}` opens a protected one) | | `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or every file under a folder, or take it out | | `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it | | `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault | diff --git a/src/main.rs b/src/main.rs index abac4f7..b0316da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -371,7 +371,7 @@ fn compressible( /// runtime; scripts stay confined to the bundled files. const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \ style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; \ - object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; + frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; type HeaderLayer = SetResponseHeaderLayer; diff --git a/src/routes/inspect.rs b/src/routes/inspect.rs new file mode 100644 index 0000000..e2a9313 --- /dev/null +++ b/src/routes/inspect.rs @@ -0,0 +1,775 @@ +//! `POST /api/nodes/{id}/inspect`: what a certificate, key or keystore file +//! holds, read on the server so the browser needs no ASN.1 of its own. +//! +//! PEM with any mix of blocks (a chain, a certificate with its key), DER +//! certificates, requests and keys, and PKCS#12 keystores — the legacy RC2 and +//! 3DES ones a keychain exports as well as AES. A password opens a keystore or +//! an encrypted key for this one answer; it is never stored and never logged. +//! +//! The answer describes keys — algorithm, size, which certificate each belongs +//! to — and never carries key material itself. + +use std::io::Read; + +use axum::{ + extract::{Path, State}, + Json, +}; +use p12_keystore::{error::Error as P12Error, Pkcs12Archive}; +use serde::{Deserialize, Serialize}; +use sha1::Sha1; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use x509_parser::{ + certification_request::X509CertificationRequest, + der_parser::der::parse_der_sequence, + extensions::{GeneralName, ParsedExtension}, + pem::Pem, + prelude::{FromDer, X509Certificate}, + public_key::{PublicKey, RSAPublicKey}, + x509::SubjectPublicKeyInfo, +}; +use zeroize::Zeroizing; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + state::AppState, + vault::{self, Password}, +}; + +/// Certificates and keys are kilobytes. Anything past this is not one, and is +/// not worth reading into memory to find that out. +const MAX_INSPECT: u64 = 1024 * 1024; + +#[derive(Deserialize, Default)] +pub struct InspectInput { + #[serde(default)] + pub password: Option, +} + +#[derive(Serialize, Debug, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Item { + Certificate { + subject: String, + issuer: String, + serial: String, + /// Unix seconds. + not_before: i64, + not_after: i64, + /// `RSA 2048`, `EC P-256`, `Ed25519`. + key: String, + /// Subject alternative names. + names: Vec, + /// Key usage and extended key usage, together. + usage: Vec, + ca: bool, + sha256: String, + sha1: String, + /// The friendly name a keystore gave it. + label: Option, + }, + Request { + subject: String, + key: String, + names: Vec, + }, + PrivateKey { + /// None while the key is still encrypted. + key: Option, + /// Whether it came under a password. + encrypted: bool, + /// The index of the certificate in `items` with the same public key. + matches: Option, + label: Option, + }, + PublicKey { + key: String, + }, +} + +#[derive(Serialize, Debug, Default)] +pub struct Inspection { + pub items: Vec, + /// Something here is behind a password that was not given. + pub needs_password: bool, +} + +#[derive(Debug, PartialEq)] +pub enum Failure { + Unreadable, + WrongPassword, +} + +pub async fn inspect( + _: Session, + State(state): State, + Path(id): Path, + Json(input): Json, +) -> AppResult> { + let row = sqlx::query( + "SELECT m.id, m.size, m.encrypted FROM nodes n JOIN media m ON m.id = n.media_id + WHERE n.id = ?1 AND n.kind = 'file'", + ) + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + let media_id: String = row.get("id"); + let size: i64 = row.get("size"); + let encrypted: bool = row.get("encrypted"); + + if size.max(0) as u64 > MAX_INSPECT { + return Err(AppError::BadRequest( + "too large to be a certificate or a key".into(), + )); + } + let key = match encrypted { + true => Some(state.vault.key().ok_or(AppError::Locked)?), + false => None, + }; + let path = state.config.uploads_dir().join(&media_id); + + let outcome = tokio::task::spawn_blocking(move || -> anyhow::Result<_> { + let mut bytes = Zeroizing::new(Vec::new()); + match key { + Some(key) => { + vault::Reader::open(&key, &media_id, &path)? + .take(MAX_INSPECT) + .read_to_end(&mut bytes)?; + } + None => { + std::fs::File::open(&path)? + .take(MAX_INSPECT) + .read_to_end(&mut bytes)?; + } + } + Ok(inspect_bytes( + &bytes, + input.password.as_ref().map(|p| p.as_str()), + )) + }) + .await + .map_err(|e| AppError::Internal(e.into()))??; + + match outcome { + Ok(inspection) => Ok(Json(inspection)), + Err(Failure::WrongPassword) => Err(AppError::BadRequest("wrong password".into())), + Err(Failure::Unreadable) => Err(AppError::BadRequest( + "not a certificate, key or keystore this can read".into(), + )), + } +} + +/// Everything the file holds, in the order it holds it. +pub fn inspect_bytes(bytes: &[u8], password: Option<&str>) -> Result { + let mut found = Found::default(); + if bytes.windows(11).any(|w| w == b"-----BEGIN ") { + for pem in Pem::iter_from_buffer(bytes).map_while(Result::ok) { + found.pem(&pem, password)?; + } + } else { + found.der(bytes, password)?; + } + if found.items.is_empty() && !found.needs_password { + return Err(Failure::Unreadable); + } + Ok(found.finish()) +} + +/// The items so far, each with the public key it stands for where one is +/// known, so a private key can be paired with its certificate at the end. +#[derive(Default)] +struct Found { + items: Vec, + identities: Vec>>, + needs_password: bool, +} + +impl Found { + fn push(&mut self, item: Item, identity: Option>) { + self.items.push(item); + self.identities.push(identity); + } + + fn finish(mut self) -> Inspection { + let certificates: Vec<(usize, Vec)> = self + .items + .iter() + .zip(&self.identities) + .enumerate() + .filter_map(|(at, (item, identity))| match (item, identity) { + (Item::Certificate { .. }, Some(identity)) => Some((at, identity.clone())), + _ => None, + }) + .collect(); + for (item, identity) in self.items.iter_mut().zip(&self.identities) { + if let (Item::PrivateKey { matches, .. }, Some(identity)) = (item, identity) { + *matches = certificates + .iter() + .find(|(_, other)| other == identity) + .map(|(at, _)| *at); + } + } + Inspection { + items: self.items, + needs_password: self.needs_password, + } + } + + fn pem(&mut self, pem: &Pem, password: Option<&str>) -> Result<(), Failure> { + let der = &pem.contents; + match pem.label.as_str() { + "CERTIFICATE" | "X509 CERTIFICATE" | "TRUSTED CERTIFICATE" => { + self.certificate(der, None); + } + "CERTIFICATE REQUEST" | "NEW CERTIFICATE REQUEST" => { + self.request(der); + } + "PRIVATE KEY" => { + self.private_key(der, false, None); + } + "ENCRYPTED PRIVATE KEY" => self.encrypted_key(der, password)?, + "RSA PRIVATE KEY" => { + // An old OpenSSL key under a password is this label with + // headers; its body is then not an RSAPrivateKey at all. + let (key, identity) = match rsa_modulus(der) { + Some(modulus) => (Some(rsa_name(modulus)), Some(trim(modulus).to_vec())), + None => (None, None), + }; + let encrypted = key.is_none(); + self.push( + Item::PrivateKey { + key, + encrypted, + matches: None, + label: None, + }, + identity, + ); + } + "EC PRIVATE KEY" => { + if let Ok(ec) = sec1::EcPrivateKey::try_from(der.as_slice()) { + let curve = ec + .parameters + .and_then(|p| p.named_curve()) + .map(|oid| curve_name(&oid.to_string())); + self.push( + Item::PrivateKey { + key: Some(ec_name(curve)), + encrypted: false, + matches: None, + label: None, + }, + ec.public_key.map(<[u8]>::to_vec), + ); + } + } + "PUBLIC KEY" => { + if let Ok((_, spki)) = SubjectPublicKeyInfo::from_der(der) { + let (key, identity) = describe(&spki); + self.push(Item::PublicKey { key }, identity); + } + } + "RSA PUBLIC KEY" => { + if let Ok((_, rsa)) = RSAPublicKey::from_der(der) { + self.push( + Item::PublicKey { + key: format!("RSA {}", rsa.key_size()), + }, + Some(trim(rsa.modulus).to_vec()), + ); + } + } + _ => {} + } + Ok(()) + } + + /// A DER file says nothing about what it is, so each reading is tried in + /// turn, the strictest first. + fn der(&mut self, der: &[u8], password: Option<&str>) -> Result<(), Failure> { + if self.certificate(der, None) || self.request(der) { + return Ok(()); + } + if looks_like_pkcs12(der) { + return self.pkcs12(der, password); + } + if self.private_key(der, false, None) { + return Ok(()); + } + if pkcs8::EncryptedPrivateKeyInfoRef::try_from(der).is_ok() { + return self.encrypted_key(der, password); + } + if let Ok((_, spki)) = SubjectPublicKeyInfo::from_der(der) { + let (key, identity) = describe(&spki); + self.push(Item::PublicKey { key }, identity); + } + Ok(()) + } + + fn certificate(&mut self, der: &[u8], label: Option) -> bool { + let Ok((_, cert)) = X509Certificate::from_der(der) else { + return false; + }; + let (key, identity) = describe(cert.public_key()); + let names = cert + .subject_alternative_name() + .ok() + .flatten() + .map(|san| san.value.general_names.iter().map(name).collect()) + .unwrap_or_default(); + let mut usage: Vec = cert + .key_usage() + .ok() + .flatten() + .map(|ku| ku.value.to_string()) + .into_iter() + .flat_map(|list| list.split(", ").map(str::to_string).collect::>()) + .filter(|s| !s.is_empty()) + .collect(); + if let Ok(Some(eku)) = cert.extended_key_usage() { + let eku = eku.value; + for (on, what) in [ + (eku.any, "Any Purpose"), + (eku.server_auth, "Server Authentication"), + (eku.client_auth, "Client Authentication"), + (eku.code_signing, "Code Signing"), + (eku.email_protection, "Email Protection"), + (eku.time_stamping, "Time Stamping"), + (eku.ocsp_signing, "OCSP Signing"), + ] { + if on { + usage.push(what.into()); + } + } + usage.extend(eku.other.iter().map(|oid| oid.to_id_string())); + } + let ca = cert + .basic_constraints() + .ok() + .flatten() + .is_some_and(|bc| bc.value.ca); + self.push( + Item::Certificate { + subject: cert.subject().to_string(), + issuer: cert.issuer().to_string(), + serial: cert.raw_serial_as_string(), + not_before: cert.validity().not_before.timestamp(), + not_after: cert.validity().not_after.timestamp(), + key, + names, + usage, + ca, + sha256: fingerprint(&Sha256::digest(der)), + sha1: fingerprint(&Sha1::digest(der)), + label, + }, + identity, + ); + true + } + + fn request(&mut self, der: &[u8]) -> bool { + let Ok((_, csr)) = X509CertificationRequest::from_der(der) else { + return false; + }; + let info = &csr.certification_request_info; + let (key, _) = describe(&info.subject_pki); + let names = csr + .requested_extensions() + .into_iter() + .flatten() + .filter_map(|ext| match ext { + ParsedExtension::SubjectAlternativeName(san) => Some(san), + _ => None, + }) + .flat_map(|san| san.general_names.iter().map(name)) + .collect(); + self.push( + Item::Request { + subject: info.subject.to_string(), + key, + names, + }, + None, + ); + true + } + + /// A PKCS#8 key, in the clear by now. + fn private_key(&mut self, der: &[u8], encrypted: bool, label: Option) -> bool { + let Ok(info) = pkcs8::PrivateKeyInfoRef::try_from(der) else { + return false; + }; + let body = info.private_key.as_bytes(); + let (key, identity) = match info.algorithm.oid.to_string().as_str() { + RSA => match rsa_modulus(body) { + Some(modulus) => (rsa_name(modulus), Some(trim(modulus).to_vec())), + None => ("RSA".into(), None), + }, + EC => { + let curve = info + .algorithm + .parameters + .and_then(|p| p.decode_as::().ok()) + .map(|oid| curve_name(&oid.to_string())); + let public = sec1::EcPrivateKey::try_from(body) + .ok() + .and_then(|ec| ec.public_key.map(<[u8]>::to_vec)); + (ec_name(curve), public) + } + ED25519 => ( + "Ed25519".into(), + info.public_key + .and_then(|k| k.as_bytes().map(<[u8]>::to_vec)), + ), + ED448 => ("Ed448".into(), None), + other => (other.to_string(), None), + }; + self.push( + Item::PrivateKey { + key: Some(key), + encrypted, + matches: None, + label, + }, + identity, + ); + true + } + + fn encrypted_key(&mut self, der: &[u8], password: Option<&str>) -> Result<(), Failure> { + let Ok(info) = pkcs8::EncryptedPrivateKeyInfoRef::try_from(der) else { + return Ok(()); + }; + let Some(password) = password else { + self.needs_password = true; + self.push( + Item::PrivateKey { + key: None, + encrypted: true, + matches: None, + label: None, + }, + None, + ); + return Ok(()); + }; + let plain = info.decrypt(password).map_err(|_| Failure::WrongPassword)?; + if !self.private_key(plain.as_bytes(), true, None) { + return Err(Failure::WrongPassword); + } + Ok(()) + } + + /// A keystore is tried with no password first, as plenty are exported + /// with an empty one. + fn pkcs12(&mut self, der: &[u8], password: Option<&str>) -> Result<(), Failure> { + let archive = match Pkcs12Archive::from_pkcs12(der, password.unwrap_or("")) { + Ok(archive) => archive, + Err(e) if password_problem(&e) => { + if password.is_some() { + return Err(Failure::WrongPassword); + } + self.needs_password = true; + return Ok(()); + } + Err(e) => { + tracing::debug!("unreadable keystore: {e}"); + return Err(Failure::Unreadable); + } + }; + for bag in &archive.certs { + self.certificate(bag.cert.as_der(), bag.friendly_name.clone()); + } + for bag in &archive.keys { + let der = Zeroizing::new(bag.key.as_der().to_vec()); + self.private_key(&der, true, bag.friendly_name.clone()); + } + Ok(()) + } +} + +/// Whatever goes wrong once the structure has been recognised: a bad MAC, or +/// bags that decrypt to garbage. +fn password_problem(e: &P12Error) -> bool { + !matches!( + e, + P12Error::InvalidVersion | P12Error::UnsupportedContentType | P12Error::IoError(_) + ) +} + +/// `PFX ::= SEQUENCE { version INTEGER (3), authSafe ContentInfo, … }`, with +/// the ContentInfo of type `data` — enough to tell a keystore from any other +/// DER without its password. +fn looks_like_pkcs12(der: &[u8]) -> bool { + let Ok((_, outer)) = parse_der_sequence(der) else { + return false; + }; + let Ok(fields) = outer.as_sequence() else { + return false; + }; + let version = fields.first().and_then(|v| v.as_u32().ok()); + let content_type = fields + .get(1) + .and_then(|info| info.as_sequence().ok()) + .and_then(|info| info.first()) + .and_then(|oid| oid.as_oid().ok()) + .map(|oid| oid.to_id_string()); + version == Some(3) && content_type.as_deref() == Some("1.2.840.113549.1.7.1") +} + +const RSA: &str = "1.2.840.113549.1.1.1"; +const EC: &str = "1.2.840.10045.2.1"; +const ED25519: &str = "1.3.101.112"; +const ED448: &str = "1.3.101.113"; + +/// What a public key is, and the bytes that make it that key. +fn describe(spki: &SubjectPublicKeyInfo) -> (String, Option>) { + match spki.parsed() { + Ok(PublicKey::RSA(rsa)) => (rsa_name(rsa.modulus), Some(trim(rsa.modulus).to_vec())), + Ok(PublicKey::EC(point)) => { + let curve = spki + .algorithm + .parameters + .as_ref() + .and_then(|p| p.as_oid().ok()) + .map(|oid| curve_name(&oid.to_id_string())); + (ec_name(curve), Some(point.data().to_vec())) + } + _ => match spki.algorithm.algorithm.to_id_string().as_str() { + ED25519 => ( + "Ed25519".into(), + Some(spki.subject_public_key.data.to_vec()), + ), + ED448 => ("Ed448".into(), Some(spki.subject_public_key.data.to_vec())), + other => (other.to_string(), None), + }, + } +} + +/// `RSAPrivateKey ::= SEQUENCE { version, modulus, … }`. +fn rsa_modulus(der: &[u8]) -> Option<&[u8]> { + let (_, key) = parse_der_sequence(der).ok()?; + let fields = key.as_sequence().ok()?; + fields.get(1)?.as_slice().ok() +} + +fn rsa_name(modulus: &[u8]) -> String { + let modulus = trim(modulus); + let bits = match modulus.first() { + Some(top) => modulus.len() * 8 - top.leading_zeros() as usize, + None => 0, + }; + format!("RSA {bits}") +} + +/// An unsigned integer as DER writes it may start with a zero byte. +fn trim(integer: &[u8]) -> &[u8] { + let zeros = integer.iter().take_while(|b| **b == 0).count(); + &integer[zeros..] +} + +fn curve_name(oid: &str) -> String { + match oid { + "1.2.840.10045.3.1.7" => "P-256".into(), + "1.3.132.0.34" => "P-384".into(), + "1.3.132.0.35" => "P-521".into(), + "1.3.132.0.10" => "secp256k1".into(), + other => other.into(), + } +} + +fn ec_name(curve: Option) -> String { + match curve { + Some(curve) => format!("EC {curve}"), + None => "EC".into(), + } +} + +fn name(general: &GeneralName) -> String { + match general { + GeneralName::DNSName(dns) => dns.to_string(), + GeneralName::RFC822Name(mail) => mail.to_string(), + GeneralName::URI(uri) => uri.to_string(), + GeneralName::IPAddress(ip) => { + if let Ok(v4) = <[u8; 4]>::try_from(*ip) { + std::net::Ipv4Addr::from(v4).to_string() + } else if let Ok(v6) = <[u8; 16]>::try_from(*ip) { + std::net::Ipv6Addr::from(v6).to_string() + } else { + hex::encode(ip) + } + } + other => other.to_string(), + } +} + +/// `AB:CD:…`, as every other tool prints one. +fn fingerprint(digest: &[u8]) -> String { + digest + .iter() + .map(|b| format!("{b:02X}")) + .collect::>() + .join(":") +} + +#[cfg(test)] +mod tests { + use super::*; + + // Throwaway material, made once with OpenSSL 3 and nothing else signed by it: + // openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem \ + // -days 3650 -subj "/CN=narl test/O=narl" -addext "subjectAltName=DNS:example.test" + // openssl x509 -in cert.pem -outform DER -out cert.der + // openssl req -new -key key.pem -out req.csr -subj "/CN=narl request" + // openssl pkcs8 -topk8 -in key.pem -out key.enc.pem -v2 aes-256-cbc -passout pass:test + // openssl pkcs12 -export -inkey key.pem -in cert.pem -out modern.p12 -passout pass:test + // openssl pkcs12 -export -legacy -inkey key.pem -in cert.pem -out legacy.p12 -passout pass:test + // openssl ecparam -name prime256v1 -genkey -noout | openssl pkcs8 -topk8 -nocrypt -out ec.pem + // cat cert.pem key.pem > bundle.pem + const CERT_PEM: &[u8] = include_bytes!("testdata/cert.pem"); + const CERT_DER: &[u8] = include_bytes!("testdata/cert.der"); + const BUNDLE: &[u8] = include_bytes!("testdata/bundle.pem"); + const REQUEST: &[u8] = include_bytes!("testdata/req.csr"); + const ENCRYPTED_KEY: &[u8] = include_bytes!("testdata/key.enc.pem"); + const EC_KEY: &[u8] = include_bytes!("testdata/ec.pem"); + const MODERN: &[u8] = include_bytes!("testdata/modern.p12"); + const LEGACY: &[u8] = include_bytes!("testdata/legacy.p12"); + + const SHA256: &str = "EA:C1:0E:4D:3C:41:C2:73:84:5E:7F:E0:7A:C2:39:9C:\ + 0E:6A:D1:41:7D:BE:F6:DC:36:C7:56:66:E4:88:BD:75"; + + fn certificate(item: &Item) -> (&str, &str, &[String], &str) { + match item { + Item::Certificate { + subject, + key, + names, + sha256, + .. + } => (subject, key, names, sha256), + other => panic!("not a certificate: {other:?}"), + } + } + + #[test] + fn reads_a_certificate_as_pem_and_as_der() { + for bytes in [CERT_PEM, CERT_DER] { + let found = inspect_bytes(bytes, None).unwrap(); + assert_eq!(found.items.len(), 1); + let (subject, key, names, sha256) = certificate(&found.items[0]); + assert!(subject.contains("CN=narl test"), "{subject}"); + assert_eq!(key, "RSA 2048"); + assert_eq!(names, ["example.test"]); + assert_eq!(sha256, SHA256); + } + } + + #[test] + fn pairs_a_key_with_its_certificate() { + let found = inspect_bytes(BUNDLE, None).unwrap(); + assert_eq!(found.items.len(), 2); + assert_eq!( + found.items[1], + Item::PrivateKey { + key: Some("RSA 2048".into()), + encrypted: false, + matches: Some(0), + label: None, + } + ); + } + + #[test] + fn reads_a_request() { + let found = inspect_bytes(REQUEST, None).unwrap(); + assert!(matches!( + &found.items[..], + [Item::Request { subject, key, .. }] if subject.contains("narl request") && key == "RSA 2048" + )); + } + + #[test] + fn reads_an_ec_key() { + let found = inspect_bytes(EC_KEY, None).unwrap(); + assert!(matches!( + &found.items[..], + [Item::PrivateKey { key: Some(key), encrypted: false, .. }] if key == "EC P-256" + )); + } + + #[test] + fn an_encrypted_key_asks_for_its_password() { + let found = inspect_bytes(ENCRYPTED_KEY, None).unwrap(); + assert!(found.needs_password); + assert!(matches!( + &found.items[..], + [Item::PrivateKey { + key: None, + encrypted: true, + .. + }] + )); + + assert_eq!( + inspect_bytes(ENCRYPTED_KEY, Some("nope")).unwrap_err(), + Failure::WrongPassword + ); + + let found = inspect_bytes(ENCRYPTED_KEY, Some("test")).unwrap(); + assert!(!found.needs_password); + assert!(matches!( + &found.items[..], + [Item::PrivateKey { key: Some(key), encrypted: true, .. }] if key == "RSA 2048" + )); + } + + #[test] + fn opens_modern_and_legacy_keystores() { + for bytes in [MODERN, LEGACY] { + let locked = inspect_bytes(bytes, None).unwrap(); + assert!(locked.needs_password); + assert!(locked.items.is_empty()); + + assert_eq!( + inspect_bytes(bytes, Some("nope")).unwrap_err(), + Failure::WrongPassword + ); + + let found = inspect_bytes(bytes, Some("test")).unwrap(); + assert_eq!(found.items.len(), 2, "{:?}", found.items); + assert_eq!(certificate(&found.items[0]).3, SHA256); + assert!(matches!( + &found.items[1], + Item::PrivateKey { key: Some(key), encrypted: true, matches: Some(0), .. } if key == "RSA 2048" + )); + } + } + + #[test] + fn never_answers_with_key_material() { + let found = inspect_bytes(MODERN, Some("test")).unwrap(); + let json = serde_json::to_string(&found).unwrap(); + // The longest field anywhere is a fingerprint; a key would be kilobytes. + assert!(json.len() < 2048, "{json}"); + } + + #[test] + fn refuses_what_it_cannot_read() { + assert_eq!( + inspect_bytes(b"hello, world", None).unwrap_err(), + Failure::Unreadable + ); + assert_eq!( + inspect_bytes( + b"-----BEGIN NOTHING-----\naGk=\n-----END NOTHING-----\n", + None + ) + .unwrap_err(), + Failure::Unreadable + ); + } +} diff --git a/src/routes/media.rs b/src/routes/media.rs index f56804e..4f2bdb2 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -500,6 +500,15 @@ pub async fn stream_media( "attachment" }; + // A PDF is shown in a frame of the file pane, by the browser's own viewer, + // and that viewer will not start inside a sandbox. It runs no script of + // the origin's either way, and nothing but the app may frame it. + let (policy, frames) = if mime == "application/pdf" { + ("default-src 'none'; frame-ancestors 'self'", "SAMEORIGIN") + } else { + ("sandbox; default-src 'none'", "DENY") + }; + let key = match (encrypted, private) { (false, _) => None, (true, false) => return Err(AppError::NotFound), @@ -559,10 +568,8 @@ pub async fn stream_media( // Belt and braces: never let the browser sniff past the type above, // and strip the ambient authority of the origin from the response. (header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()), - ( - header::CONTENT_SECURITY_POLICY, - "sandbox; default-src 'none'".to_string(), - ), + (header::CONTENT_SECURITY_POLICY, policy.to_string()), + (header::X_FRAME_OPTIONS, frames.to_string()), ], body, ) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index d141a8e..a9ede1c 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -2,6 +2,7 @@ pub mod backup; pub mod boards; pub mod export; pub mod import; +pub mod inspect; pub mod manifest; pub mod media; pub mod nodes; @@ -68,6 +69,7 @@ pub fn api_router(max_upload: usize) -> Router { .route("/nodes/{id}/files", post(media::upload_into).layer(uploads)) .route("/nodes/{id}/encrypt", post(seal::encrypt)) .route("/nodes/{id}/decrypt", post(seal::decrypt)) + .route("/nodes/{id}/inspect", post(inspect::inspect)) .route( "/vault", get(vault::status) diff --git a/src/routes/testdata/bundle.pem b/src/routes/testdata/bundle.pem new file mode 100644 index 0000000..0d52b3d --- /dev/null +++ b/src/routes/testdata/bundle.pem @@ -0,0 +1,48 @@ +-----BEGIN CERTIFICATE----- +MIIDQDCCAiigAwIBAgIUNstWxYdASmgHY27nUxFIRmYp56QwDQYJKoZIhvcNAQEL +BQAwIzESMBAGA1UEAwwJbmFybCB0ZXN0MQ0wCwYDVQQKDARuYXJsMB4XDTI2MTAy +MDA3NDEyNVoXDTM2MTAxNzA3NDEyNVowIzESMBAGA1UEAwwJbmFybCB0ZXN0MQ0w +CwYDVQQKDARuYXJsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApdmT +uYCy0Gn6x0N4wbZ00fKQpce1cxEHyZv1RWsSNwXptGjX3dh6g7bVQAYnuG3mJ+ML +ygW9Og68LPM4L3Irim303MqQ7TqYnXJcO1TxnAS6gNPHfnxSEvJ81IcBCg1TCK1q +FE4Zr+55cYMpmn+WUWRo/bUoEzKvo6XHxcWpWx8m5nwG/qDUqtEr7T4zW2gs8n9M +wnumXlokHIqImJXy/JykpK7c27vPpyRnUpdqva8ZTYzDKl6O83s53ihrSeog75g6 +pgIGAo05FF4R2As6RkD3QaffnO3qZ0nO4L0NtEbSAs/bm6VBZ3JvJxd7vXaL9kbT +sG00yqVGQ8QZZmx8fwIDAQABo2wwajAdBgNVHQ4EFgQUGV5Pi/qBOPhZd8H1YdNk ++nQwHecwHwYDVR0jBBgwFoAUGV5Pi/qBOPhZd8H1YdNk+nQwHecwDwYDVR0TAQH/ +BAUwAwEB/zAXBgNVHREEEDAOggxleGFtcGxlLnRlc3QwDQYJKoZIhvcNAQELBQAD +ggEBAHWFOl7tK5p4hV1wq4NBmttb0XUjbbR3SJapR7FXMzqjlisQ2L1udqe32nCw +aTZsm4e+R/25RWSPffb1boMMCy3t0QIEuUL7TPKzjNsdXTGvj9RUJ/isXHS7/Yms +FlT8xQWYWcoDjkmp2CS9IX/N0pkyd0oIrzN+KvkSS9596R6AKT+x7VYa0vPyp3Nt +o4PZK7189fyfIrktepyjnB0nULSbzPiHtogs85NBohOoXlZOdFweJKTFcxutpLd4 +J0+zjWCWZnrCUTKBTCtCm2GjG1X2fuJ29vv6IqCB8NKCXZcdkpyilnhx8PjL4koh +0BoItRF2mYQgmqh45xTw6aa+PZg= +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCl2ZO5gLLQafrH +Q3jBtnTR8pClx7VzEQfJm/VFaxI3Bem0aNfd2HqDttVABie4beYn4wvKBb06Drws +8zgvciuKbfTcypDtOpidclw7VPGcBLqA08d+fFIS8nzUhwEKDVMIrWoUThmv7nlx +gymaf5ZRZGj9tSgTMq+jpcfFxalbHybmfAb+oNSq0SvtPjNbaCzyf0zCe6ZeWiQc +ioiYlfL8nKSkrtzbu8+nJGdSl2q9rxlNjMMqXo7zezneKGtJ6iDvmDqmAgYCjTkU +XhHYCzpGQPdBp9+c7epnSc7gvQ20RtICz9ubpUFncm8nF3u9dov2RtOwbTTKpUZD +xBlmbHx/AgMBAAECggEAENVwxRdnDvhy197lVuQqH1U7kVgf9rn3zGLkFlOz2Znk +CsfJgV8DsHLSWllFltaw0cOd4reKaWYte3KEdWxlNvvKKs70vt9/thtxeSQ6Edf5 +BjJ4wUHlIr8yJHA/jsGjd/ICHmV+/fh6oybpBiGFJGXIsle85ZXzdezRxiU192Wc +O7dAdgJv6EjG75kZX84/Nt/Rs2FS3KPEzOtLKR5T+FMxCIrYgIbPDG4VtTNi25S7 +spfE8r2swzfaGNXJna/XMzEUmbmA5jcdhwggm1GnbMt6Pa3eMZ/VFC+e+nPM1Ldn +pphe+1xJ/gLN42j9Dr/3m/yH8M8ilaDC3aQYTMG9QQKBgQDRJfeS2gmpuOGzxkc8 +R1bDGQICZ4rMmTqp1WzXX56Zg/bLAkSHqXbTZVgVk9nrYWYfFgGILroWHvQGz8uY +hcaqGQLUxrpIpeO/epXyS3T5VoczZGFqIiowRmOya7Nw23oMBip54AnnU3DhCE+n ++AyY6HqzVWF3n0r+WwdESdlpBwKBgQDLAJNhAYjufxIis6nN8SN2RoPjP0T4BTbS +Jno00wBj1kZbG2pyUJ81tJe+dAmx5GHxd+kmkQQWRmtJMmOAlUj9uclIhBFMYb3O +B8jAvoethdurMI1GjDxPmiz0gdyJRttiKF0ftBeHP2LZKDbMi80mKc8N1NjO/zKL +tCFvBHtKyQKBgBP9hQRMQtAuE6STNwvT5b8QDNkd/G5B1xGUjKqpQQd7TYNp3BL7 +Z5gc/6ZQU2vkC1a9S7gwfyjOHJTqF5OkdbCcY2Di/bsj3/jVRq+59pw+YP3Qgl8a +lN24XJ8ZHDWVRL0Rec3cVBYAK7HWkYaEdu/GiLPLXV2JpClbmf408MrRAoGBALLC +CQWNAnVxtBiWuNo+k4mdQpojpqn4fXPEqixF+bvwfffQRoXvdKMoKc9uAvOBNrYZ +b1ekr0KmM+c61d/ulV3H8fVHRqxKDYeV1zPTE0q76tnuYRlUd+9oxllw2TCRErJ2 +9LnsLiPMr1CmVsr7+GS4wDlfyqNMmcfnkOFfwunxAoGAUo1EvhJOkVDIqDGqViGo +dbUs584FnF4Ip1wcUGCBKGCMfGrIBtO8tCBW7sbOkI9tYI9usGlQx/1nQDr9Da5p +jgDJxrd/1pCyraEvcDLE6RQLPbZTFDYMPM5VvlK/gf6HABj1r/QoUcAjs50Llk0I +6TlAKjNTMX/i8lgdNCjFrx0= +-----END PRIVATE KEY----- diff --git a/src/routes/testdata/cert.der b/src/routes/testdata/cert.der new file mode 100644 index 0000000..95ea319 Binary files /dev/null and b/src/routes/testdata/cert.der differ diff --git a/src/routes/testdata/cert.pem b/src/routes/testdata/cert.pem new file mode 100644 index 0000000..e17aebf --- /dev/null +++ b/src/routes/testdata/cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQDCCAiigAwIBAgIUNstWxYdASmgHY27nUxFIRmYp56QwDQYJKoZIhvcNAQEL +BQAwIzESMBAGA1UEAwwJbmFybCB0ZXN0MQ0wCwYDVQQKDARuYXJsMB4XDTI2MTAy +MDA3NDEyNVoXDTM2MTAxNzA3NDEyNVowIzESMBAGA1UEAwwJbmFybCB0ZXN0MQ0w +CwYDVQQKDARuYXJsMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApdmT +uYCy0Gn6x0N4wbZ00fKQpce1cxEHyZv1RWsSNwXptGjX3dh6g7bVQAYnuG3mJ+ML +ygW9Og68LPM4L3Irim303MqQ7TqYnXJcO1TxnAS6gNPHfnxSEvJ81IcBCg1TCK1q +FE4Zr+55cYMpmn+WUWRo/bUoEzKvo6XHxcWpWx8m5nwG/qDUqtEr7T4zW2gs8n9M +wnumXlokHIqImJXy/JykpK7c27vPpyRnUpdqva8ZTYzDKl6O83s53ihrSeog75g6 +pgIGAo05FF4R2As6RkD3QaffnO3qZ0nO4L0NtEbSAs/bm6VBZ3JvJxd7vXaL9kbT +sG00yqVGQ8QZZmx8fwIDAQABo2wwajAdBgNVHQ4EFgQUGV5Pi/qBOPhZd8H1YdNk ++nQwHecwHwYDVR0jBBgwFoAUGV5Pi/qBOPhZd8H1YdNk+nQwHecwDwYDVR0TAQH/ +BAUwAwEB/zAXBgNVHREEEDAOggxleGFtcGxlLnRlc3QwDQYJKoZIhvcNAQELBQAD +ggEBAHWFOl7tK5p4hV1wq4NBmttb0XUjbbR3SJapR7FXMzqjlisQ2L1udqe32nCw +aTZsm4e+R/25RWSPffb1boMMCy3t0QIEuUL7TPKzjNsdXTGvj9RUJ/isXHS7/Yms +FlT8xQWYWcoDjkmp2CS9IX/N0pkyd0oIrzN+KvkSS9596R6AKT+x7VYa0vPyp3Nt +o4PZK7189fyfIrktepyjnB0nULSbzPiHtogs85NBohOoXlZOdFweJKTFcxutpLd4 +J0+zjWCWZnrCUTKBTCtCm2GjG1X2fuJ29vv6IqCB8NKCXZcdkpyilnhx8PjL4koh +0BoItRF2mYQgmqh45xTw6aa+PZg= +-----END CERTIFICATE----- diff --git a/src/routes/testdata/ec.pem b/src/routes/testdata/ec.pem new file mode 100644 index 0000000..84120a7 --- /dev/null +++ b/src/routes/testdata/ec.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6QYjjpOkpU68jWi0 +wJshV+16QQ/6nNExwkuP6zYz7QehRANCAASEsy/3jV1m2Qq+5iHAMZdOj7Nj3J0P +HbiYE9jtt6ZCE53DLAbFYGsYKXNRYugJ5bhl9heh7BNrm6NmiSKgs/P+ +-----END PRIVATE KEY----- diff --git a/src/routes/testdata/key.enc.pem b/src/routes/testdata/key.enc.pem new file mode 100644 index 0000000..123f0ca --- /dev/null +++ b/src/routes/testdata/key.enc.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQL+9PzyyhW8kQwpiy +IrK/igICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEENqTMbj5l63Lxc72 +QyJiWHYEggTQqiYQPexKaZ6LjXd8T0z2O2PDJpF4+f9GfYbWLvo7vuwtNxgzlga9 +u8qVnqeNb8D9UvevaAMI28TU0agxFZ+QD3jgE9GZaEXw09Vcuis1DkUYtHBDs1V+ ++KgHIbhy+8WF99oRfDXYbWfXLLe5tlmv+LWu4koge9QACjRhuLKzRH2IErhlhMfA +DC9pZYaAK9o+noI1oNLMFVL1UinN7jEkBtFVsLfm5IKh68btBFfMHVV97LZRApFe +ve7/UOovx8vsDrAu4LtVcvLeAX0B2JmPiqMNUHMbKkiTyPMDMOQWn1YOzDaJ6NnR +YVp73PIq/SL25c3BOfspNmh/piWl1yIED8ljd0HA2XROC0YiMjY0rixYDckg1xyo +foMqXU7/7sLgMfUs8mc7gD4C+SVgdfwmifwgdsauzMUH32ikiheMxMjuru5Y/YVK +LabTeOk0fTefUwHbc0UqugHUGa3flMHKDaw89Y8adfiw5MhFJujR0Q4WD6daHyP5 +kxzzkWnmfF5/B8jDRCN0lltQ91L1O0AKmAfTTbeWlbDVxrngma3x71EwKTY8dpNO +NseyhGHl0fgd8mMCaSEFi7QQ9mKmxaWM6uk8JcE2x1XNSULF5UanXswFNwKaXz98 +bt53EOPcpVp60hY+tOvVxftEMOZyVcBl6RxbcvIp4fjqwXpHhbs3UXMAcWfTMdn/ +YxY5Z670KwTHMieJf6i4DeVGoV4hJFjebnS9uMP8YDw8Va/aGEUyLfbqC6FeRbis +tsaemX4e3w2Q3F9YVrYiTq8EbCur1Gass88JGp6hPjNZg/O5gG69LLxXJOPLOid0 +9/O1kpOC2aEra2GPWOQCsv1tLSf+p9ArdmflFLqlcTICd3dMMnfeNM6MKCvAksZD +eAJVsv3YoDEQZnF/1q86lN8GulOPFWjz0UCOE/1cANqauJ1zzJhxuHFRThcfg1H7 +ojIPd0n3Fu4vnoJ/w7aDdYtaJvBHhgEa/u+mK95gALgznrUtTDCk4wwMu5whsVJ5 +PpVs3VEyg3Npwq+kTWPEX+CBQYlxJCIX5Bm42I9hBo7l0vN0Ai+G8Rus2SQVP3kt +4yx4FI1i+PEwo9LX7SRdbjqPS1SUkC6WehQ3diCCQ/P+glgtEAhNqYflaV8FsiSk +YvNtfQoeMFMkDLH1NZUh3/VulvOoIO8Lm6W+k8o+UmctHppNYOa8MfoyyNDcnPJ5 +etbRdeACrTk4o6RL4dN8PQWcsKvZuVyBDe4WKRxSVx99ADMtk2jGnsvP+f31h06t +39Qc2DY4nceZJybHGljQHTiolzxtsqIeXy+T69rBush4dWGfXDqWuxlU9+gf/aFH +RKMTHlokn+BYwqZMDLK0aV++MpJiYfO1URmD8Os7COZBxLxrOZVV9GOQMCXYEhOr +bchvfTegrZkmLvP2iNvfHN0wvI9XQM8HEBLY9YDi27HNBvtMbjX1KKfyhIVDbjmU ++TuZYIwUklTMMUrui/f2gmUDw4vtreJmEmYhhMgE9NSUYew9tXtTCQl/1fvmotIC +4lZ+p/mo3AugnquU8tvV5LghUSUW9Rjg/0qWWHmXQET5cahDdKbaLJYSDD2leYUw +5+fiXZetvyGo73Q8yKyh3yaMSJnV5B4JscSCWi3S87OQGb9gCE6wNPk= +-----END ENCRYPTED PRIVATE KEY----- diff --git a/src/routes/testdata/legacy.p12 b/src/routes/testdata/legacy.p12 new file mode 100644 index 0000000..be048ae Binary files /dev/null and b/src/routes/testdata/legacy.p12 differ diff --git a/src/routes/testdata/modern.p12 b/src/routes/testdata/modern.p12 new file mode 100644 index 0000000..879b3ac Binary files /dev/null and b/src/routes/testdata/modern.p12 differ diff --git a/src/routes/testdata/req.csr b/src/routes/testdata/req.csr new file mode 100644 index 0000000..d3dcf2b --- /dev/null +++ b/src/routes/testdata/req.csr @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICXDCCAUQCAQAwFzEVMBMGA1UEAwwMbmFybCByZXF1ZXN0MIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEApdmTuYCy0Gn6x0N4wbZ00fKQpce1cxEHyZv1 +RWsSNwXptGjX3dh6g7bVQAYnuG3mJ+MLygW9Og68LPM4L3Irim303MqQ7TqYnXJc +O1TxnAS6gNPHfnxSEvJ81IcBCg1TCK1qFE4Zr+55cYMpmn+WUWRo/bUoEzKvo6XH +xcWpWx8m5nwG/qDUqtEr7T4zW2gs8n9MwnumXlokHIqImJXy/JykpK7c27vPpyRn +Updqva8ZTYzDKl6O83s53ihrSeog75g6pgIGAo05FF4R2As6RkD3QaffnO3qZ0nO +4L0NtEbSAs/bm6VBZ3JvJxd7vXaL9kbTsG00yqVGQ8QZZmx8fwIDAQABoAAwDQYJ +KoZIhvcNAQELBQADggEBAJkqFdTGJdCXRlfk8RcxxOygNuluqSG/dnYa6Xz87XxQ +6CbHzWiXJ97AAlh49N6nFH/qdSF2tLgtCQbFQOpVBk8wf/rukRvS8t4g633nQGpc +Y0il0mQf/pN1fEfZW2Y/8GjTWCK4FQHQK0jQPAc7jA1OiFn4tP77dNyJRUK951gy +MgAy+FHZpNO2PrVrjA1fNGh3X6ePzX6/cARiA7SiYVWXDB85WyBjFENJoTsZ5Rlf +QAjDf3iCnFI1yb1c0HqGdf1vZ4P9u41BB728Gdh3bV6U7LKuX3p8yaSNxZwu7Xj6 +OR/o4vgje16xLOlzUnXTBebW9JOK08yzi+Jf2IRKQD4= +-----END CERTIFICATE REQUEST----- diff --git a/web/src/lib/CertView.svelte b/web/src/lib/CertView.svelte new file mode 100644 index 0000000..2320b90 --- /dev/null +++ b/web/src/lib/CertView.svelte @@ -0,0 +1,159 @@ + + +{#if inspection} + {#if inspection.needs_password} +

+ protected by a password — to read {inspection.items.length ? 'the rest' : 'it'}. +

+ {/if} + {#each inspection.items as item, i (i)} +
+

+ {i + 1} + {title(item)} + {#if item.kind === 'certificate' || item.kind === 'private_key'} + {#if item.label}{item.label}{/if} + {/if} +

+
+ {#if item.kind === 'certificate'} +
subject
+
{item.subject}
+
issuer
+
{item.issuer === item.subject ? 'self-signed' : item.issuer}
+
valid from
+
{formatDay(item.not_before)}
+
valid until
+
{expiry(item.not_after)}
+
key
+
{item.key}
+ {#if item.names.length} +
names
+
{item.names.join(', ')}
+ {/if} + {#if item.usage.length} +
usage
+
{item.usage.join(', ')}
+ {/if} + {#if item.ca} +
authority
+
can sign other certificates
+ {/if} +
serial
+
{item.serial}
+
sha-256
+
{item.sha256}
+
sha-1
+
{item.sha1}
+ {:else if item.kind === 'request'} +
subject
+
{item.subject}
+
key
+
{item.key}
+ {#if item.names.length} +
names
+
{item.names.join(', ')}
+ {/if} + {:else if item.kind === 'private_key'} +
key
+
{item.key ?? 'encrypted'}
+ {#if item.key} +
certificate
+
{item.matches === null ? 'none here matches it' : `belongs to ${item.matches + 1}`}
+ {/if} + {:else} +
key
+
{item.key}
+ {/if} +
+
+ {/each} +{:else if error} +

{error}

+{:else} +

reading…

+{/if} + + diff --git a/web/src/lib/FilePane.svelte b/web/src/lib/FilePane.svelte index 4b05342..f1b7f63 100644 --- a/web/src/lib/FilePane.svelte +++ b/web/src/lib/FilePane.svelte @@ -1,6 +1,9 @@