mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
9749c500cf
commit
1ae0665206
21 files changed
+1571
-22
No files matched your search
+1
-1
@@ -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>;
|
||||
|
||||
|
||||
@@ -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
@@ -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,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)
|
||||
|
||||
Vendored
+48
@@ -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-----
|
||||
Vendored
BIN
Binary file not shown.
Vendored
+20
@@ -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-----
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6QYjjpOkpU68jWi0
|
||||
wJshV+16QQ/6nNExwkuP6zYz7QehRANCAASEsy/3jV1m2Qq+5iHAMZdOj7Nj3J0P
|
||||
HbiYE9jtt6ZCE53DLAbFYGsYKXNRYugJ5bhl9heh7BNrm6NmiSKgs/P+
|
||||
-----END PRIVATE KEY-----
|
||||
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
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
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
+15
@@ -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-----
|
||||
Reference in new issue
Block a user