files: PDFs, text, code, tables and certificates open in place

The file pane decided what to show by MIME type, and uploads keep a
type only when it cannot execute, so a .md, a .json or a .pem arrived
as application/octet-stream and got "no preview". It now goes by name
first (preview.ts), and a file with a name it does not know has its
first 8 KiB looked at: no NUL bytes and valid UTF-8 means text.

- text and code: the first 256 KiB with line numbers, as before
- markdown: rendered through the same sanitiser as documents, with a
  source toggle
- CSV/TSV: a table of the first 500 rows, quotes respected
- PDF: the browser's own viewer in a frame. Only a PDF is served
  without the sandbox CSP (Chrome's viewer will not start under it),
  with frame-ancestors 'self' and X-Frame-Options SAMEORIGIN; the app
  CSP allows same-origin frames.
- certificates, keys, keystores: POST /api/nodes/{id}/inspect reads
  PEM (chains, keys, requests, public keys), DER and PKCS#12 on the
  server, in pure Rust, including the RC2/3DES keystores that
  openssl -legacy and older keychains write. It answers with subject,
  issuer, validity, key, names, usage and fingerprints, and pairs each
  private key with its certificate by public key. A password is asked
  for only when needed, used for that one answer and never stored;
  no key material is ever sent back. Sealed files are read through the
  vault, and only while it is open.

The tests read throwaway fixtures under src/routes/testdata, made with
the openssl commands listed beside them; .gitignore lets their .pem
files past a global *.pem rule.
This commit is contained in:
nvrl committed 2026-10-20 09:57:09 +02:00
1 parent 9749c500cf
commit 1ae0665206
21 files changed
+1571 -22

No files matched your search

+1 -1
View File
@@ -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<HeaderValue>;
+775
View File
@@ -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<Password>,
}
#[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<String>,
/// Key usage and extended key usage, together.
usage: Vec<String>,
ca: bool,
sha256: String,
sha1: String,
/// The friendly name a keystore gave it.
label: Option<String>,
},
Request {
subject: String,
key: String,
names: Vec<String>,
},
PrivateKey {
/// None while the key is still encrypted.
key: Option<String>,
/// Whether it came under a password.
encrypted: bool,
/// The index of the certificate in `items` with the same public key.
matches: Option<usize>,
label: Option<String>,
},
PublicKey {
key: String,
},
}
#[derive(Serialize, Debug, Default)]
pub struct Inspection {
pub items: Vec<Item>,
/// 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<AppState>,
Path(id): Path<i64>,
Json(input): Json<InspectInput>,
) -> AppResult<Json<Inspection>> {
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<Inspection, Failure> {
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<Item>,
identities: Vec<Option<Vec<u8>>>,
needs_password: bool,
}
impl Found {
fn push(&mut self, item: Item, identity: Option<Vec<u8>>) {
self.items.push(item);
self.identities.push(identity);
}
fn finish(mut self) -> Inspection {
let certificates: Vec<(usize, Vec<u8>)> = 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<String>) -> 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<String> = cert
.key_usage()
.ok()
.flatten()
.map(|ku| ku.value.to_string())
.into_iter()
.flat_map(|list| list.split(", ").map(str::to_string).collect::<Vec<_>>())
.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<String>) -> 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::<pkcs8::ObjectIdentifier>().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<Vec<u8>>) {
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>) -> 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::<Vec<_>>()
.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
);
}
}
+11 -4
View File
@@ -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,
)
+2
View File
@@ -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<AppState> {
.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)
+48
View File
@@ -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-----
BIN
View File
Binary file not shown.
+20
View File
@@ -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-----
+5
View File
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6QYjjpOkpU68jWi0
wJshV+16QQ/6nNExwkuP6zYz7QehRANCAASEsy/3jV1m2Qq+5iHAMZdOj7Nj3J0P
HbiYE9jtt6ZCE53DLAbFYGsYKXNRYugJ5bhl9heh7BNrm6NmiSKgs/P+
-----END PRIVATE KEY-----
+30
View File
@@ -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-----
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+15
View File
@@ -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-----