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

+4
View File
@@ -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
Generated
+249 -2
View File
@@ -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"
+9
View File
@@ -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"] }
+20 -3
View File
@@ -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 |
+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-----
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts">
/*
* A certificate, key or keystore, as the server reads it. A password is
* asked for only when the file needs one, and goes with that one request.
*/
import { api, type CertItem, type Inspection } from './api'
import { workspace } from './store.svelte'
import { formatDay } from './util'
let { id, name }: { id: number; name: string } = $props()
let inspection = $state<Inspection | null>(null)
let error = $state<string | null>(null)
async function load(password?: string) {
try {
inspection = await api.inspect(id, password)
error = null
} catch (e) {
error = e instanceof Error ? e.message : String(e)
}
}
$effect(() => {
void load()
})
async function unlock() {
const password = await workspace.askSecret(`password for ${name}`)
if (password === null) return
await load(password)
if (error) workspace.say(error, 'error')
}
function expiry(seconds: number): string {
const days = Math.round((seconds - Date.now() / 1000) / 86400)
if (days < 0) return `${formatDay(seconds)} — expired`
return `${formatDay(seconds)} — ${days} day${days === 1 ? '' : 's'} left`
}
const expired = (item: CertItem) => item.kind === 'certificate' && item.not_after < Date.now() / 1000
const title = (item: CertItem) =>
({ certificate: 'certificate', request: 'signing request', private_key: 'private key', public_key: 'public key' })[
item.kind
]
</script>
{#if inspection}
{#if inspection.needs_password}
<p class="locked">
protected by a password — <button onclick={unlock}>enter it</button> to read {inspection.items.length ? 'the rest' : 'it'}.
</p>
{/if}
{#each inspection.items as item, i (i)}
<section class="card" class:expired={expired(item)}>
<h2 class="label">
<span class="index">{i + 1}</span>
{title(item)}
{#if item.kind === 'certificate' || item.kind === 'private_key'}
{#if item.label}<span class="muted">{item.label}</span>{/if}
{/if}
</h2>
<dl>
{#if item.kind === 'certificate'}
<dt>subject</dt>
<dd class="light">{item.subject}</dd>
<dt>issuer</dt>
<dd>{item.issuer === item.subject ? 'self-signed' : item.issuer}</dd>
<dt>valid from</dt>
<dd>{formatDay(item.not_before)}</dd>
<dt>valid until</dt>
<dd class:alarm={expired(item)}>{expiry(item.not_after)}</dd>
<dt>key</dt>
<dd>{item.key}</dd>
{#if item.names.length}
<dt>names</dt>
<dd>{item.names.join(', ')}</dd>
{/if}
{#if item.usage.length}
<dt>usage</dt>
<dd>{item.usage.join(', ')}</dd>
{/if}
{#if item.ca}
<dt>authority</dt>
<dd>can sign other certificates</dd>
{/if}
<dt>serial</dt>
<dd class="mono">{item.serial}</dd>
<dt>sha-256</dt>
<dd class="mono">{item.sha256}</dd>
<dt>sha-1</dt>
<dd class="mono">{item.sha1}</dd>
{:else if item.kind === 'request'}
<dt>subject</dt>
<dd class="light">{item.subject}</dd>
<dt>key</dt>
<dd>{item.key}</dd>
{#if item.names.length}
<dt>names</dt>
<dd>{item.names.join(', ')}</dd>
{/if}
{:else if item.kind === 'private_key'}
<dt>key</dt>
<dd>{item.key ?? 'encrypted'}</dd>
{#if item.key}
<dt>certificate</dt>
<dd>{item.matches === null ? 'none here matches it' : `belongs to ${item.matches + 1}`}</dd>
{/if}
{:else}
<dt>key</dt>
<dd>{item.key}</dd>
{/if}
</dl>
</section>
{/each}
{:else if error}
<p class="muted">{error}</p>
{:else}
<p class="muted">reading…</p>
{/if}
<style>
.card {
max-width: 52rem;
margin: 0 0 14px;
padding: 12px 16px 14px;
border: 1px solid var(--rim);
border-radius: var(--radius-m);
background: var(--surface);
}
.card.expired { border-color: var(--alarm); }
h2 {
display: flex;
gap: 10px;
align-items: baseline;
margin: 0 0 10px;
font-size: 11px;
}
.index { color: var(--muted); }
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 4px 16px;
margin: 0;
font-size: 12px;
}
dt { color: var(--muted); letter-spacing: 0.18em; text-transform: uppercase; font-size: 11px; }
dd { margin: 0; color: var(--fg); overflow-wrap: anywhere; }
.light { color: var(--light); }
.alarm { color: var(--alarm); }
.mono { font-family: var(--font-mono); font-size: 11px; }
.locked { color: var(--subtle); }
@media (max-width: 767px) {
dl { grid-template-columns: 1fr; gap: 2px; }
dd { margin-bottom: 6px; }
}
</style>
+97 -11
View File
@@ -1,6 +1,9 @@
<script lang="ts">
import CertView from './CertView.svelte'
import { hooks, runCommand, type ScrollAmount } from './commands'
import Icon from './Icon.svelte'
import { renderMarkdown } from './markdown'
import { looksLikeText, parseTable, previewKind, separatorOf } from './preview'
import { workspace } from './store.svelte'
import { formatBytes, formatStamp, relative, scrollPage } from './util'
@@ -8,27 +11,47 @@
const file = $derived(node.file!)
/* A sealed file cannot be fetched while the vault is shut. */
const locked = $derived(file.encrypted && !workspace.vault?.unlocked)
const kind = $derived(file.mime.split('/')[0])
const kind = $derived(previewKind(node.name, file.mime))
/* What the browser can show by itself; everything else is a download. */
const viewable = $derived(file.mime !== 'application/octet-stream')
/*
* Plain text is shown inline, but only its beginning: a log file can run to
* gigabytes, and the download has the rest.
* Text is shown inline, but only its beginning: a log file can run to
* gigabytes, and the download has the rest. A file nothing is known about
* gets its first few kilobytes looked at, and is shown as text if that is
* what they are.
*/
const TEXT_PREVIEW = 256 * 1024
const SNIFF = 8 * 1024
let text = $state<string | null>(null)
/* Markdown is read rendered, unless asked for its source. */
let source = $state(false)
$effect(() => {
text = null
if (file.mime !== 'text/plain' || locked) return
source = false
if (locked || file.size === 0) return
if (kind !== 'text' && kind !== 'markdown' && kind !== 'table' && kind !== 'none') return
const url = file.url
void head(url, TEXT_PREVIEW).then((body) => {
if (body !== null && url === file.url) text = body
})
const sniff = kind === 'none'
void (async () => {
if (sniff) {
const start = await head(url, SNIFF)
if (!start || !looksLikeText(start)) return
}
const bytes = await head(url, TEXT_PREVIEW)
if (bytes !== null && url === file.url) text = new TextDecoder().decode(bytes)
})()
})
/** The first `limit` bytes of a download, as text; the rest is never fetched. */
async function head(url: string, limit: number): Promise<string | null> {
const lines = $derived(text === null ? [] : text.replace(/\n$/, '').split('\n'))
const html = $derived(kind === 'markdown' && text !== null ? renderMarkdown(text) : '')
const TABLE_ROWS = 500
const table = $derived(kind === 'table' && text !== null ? parseTable(text, separatorOf(node.name), TABLE_ROWS) : [])
/** The first `limit` bytes of a download; the rest is never fetched. */
async function head(url: string, limit: number): Promise<Uint8Array | null> {
const response = await fetch(url)
if (!response.ok || !response.body) return null
const reader = response.body.getReader()
@@ -42,7 +65,7 @@
size += take.length
}
void reader.cancel()
return new TextDecoder().decode(bytes.subarray(0, size))
return bytes.subarray(0, size)
}
let reader = $state<HTMLElement>()
@@ -89,6 +112,9 @@
{#if locked}
<button onclick={() => runCommand('unlock')}>unlock</button>
{:else}
{#if kind === 'markdown' && text !== null}
<button onclick={() => (source = !source)}>{source ? 'rendered' : 'source'}</button>
{/if}
{#if viewable}
<a class="button" href={file.url} target="_blank" rel="noopener">open</a>
{/if}
@@ -118,8 +144,38 @@
<video src={file.url} controls playsinline preload="metadata"></video>
{:else if kind === 'audio'}
<audio src={file.url} controls preload="metadata"></audio>
{:else if kind === 'pdf'}
<iframe class="pdf" src={file.url} title={node.name}></iframe>
{:else if kind === 'cert'}
<CertView id={node.id} name={node.name} />
{:else if text !== null}
<pre>{text}</pre>
{#if kind === 'markdown' && !source}
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitised in renderMarkdown -->
<article class="md">{@html html}</article>
{:else if kind === 'table'}
<div class="table">
<table>
<thead>
<tr>
{#each table[0] ?? [] as cell, j (j)}<th>{cell}</th>{/each}
</tr>
</thead>
<tbody>
{#each table.slice(1) as row, i (i)}
<tr>
{#each row as cell, j (j)}<td>{cell}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</div>
{#if table.length === TABLE_ROWS}
<p class="muted">the first {TABLE_ROWS} rows — download it for the rest.</p>
{/if}
{:else}
<pre class="lines">{#each lines as line, i (i)}<span>{line}
</span>{/each}</pre>
{/if}
{#if file.size > TEXT_PREVIEW}
<p class="muted">the first {formatBytes(TEXT_PREVIEW)} of {formatBytes(file.size)} — download it for the rest.</p>
{/if}
@@ -215,6 +271,36 @@
overflow-wrap: anywhere;
}
.lines { counter-reset: line; }
.lines span::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: 4ch;
margin-right: 1.5ch;
color: var(--muted);
text-align: right;
user-select: none;
}
.pdf {
display: block;
width: 100%;
height: 80vh;
border: 1px solid var(--rim);
border-radius: var(--radius-m);
background: var(--surface);
}
.table { overflow-x: auto; }
table { border-collapse: collapse; font-size: 12px; }
th, td {
padding: 4px 10px;
border: 1px solid var(--rim);
text-align: left;
vertical-align: top;
white-space: pre-wrap;
}
th { color: var(--light); background: var(--surface); }
.light { color: var(--light); }
.sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); }
+2 -1
View File
@@ -141,7 +141,8 @@
<footer>
inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and
<span class="key">:w</span> to write. drop or paste an image, video or audio file into the
editor to attach it. <span class="key">:12</span> jumps straight to entry 12. yanks and
editor to attach it. a file opens in place — pdf, text and code, markdown, csv, and
certificates and keystores, which ask for their password. <span class="key">:12</span> jumps straight to entry 12. yanks and
deletes are copied to the system clipboard —
<span class="key">:set noclipboard</span> keeps them in vim's registers.
</footer>
+40
View File
@@ -141,6 +141,42 @@ export interface MediaFile {
file_node: number | null
}
/** One thing a certificate, key or keystore file holds, as `/inspect` describes it. */
export type CertItem =
| {
kind: 'certificate'
subject: string
issuer: string
serial: string
not_before: number
not_after: number
/** `RSA 2048`, `EC P-256`, `Ed25519`. */
key: string
names: string[]
usage: string[]
ca: boolean
sha256: string
sha1: string
label: string | null
}
| { kind: 'request'; subject: string; key: string; names: string[] }
| {
kind: 'private_key'
/** Null while it is still encrypted. */
key: string | null
encrypted: boolean
/** The index of its certificate among the items. */
matches: number | null
label: string | null
}
| { kind: 'public_key'; key: string }
export interface Inspection {
items: CertItem[]
/** Something in the file is behind a password that was not given. */
needs_password: boolean
}
/** The vault, as `/api/vault` reports it. */
export interface VaultStatus {
configured: boolean
@@ -403,6 +439,10 @@ export const api = {
method: 'POST',
}),
/** What a certificate, key or keystore holds; the password opens it for this answer only. */
inspect: (id: number, password?: string) =>
request<Inspection>(`/nodes/${id}/inspect`, { method: 'POST', body: body({ password }) }),
backupStatus: () => request<BackupStatus>('/backup'),
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
+84
View File
@@ -0,0 +1,84 @@
/** What the file pane shows a file as. */
export type PreviewKind = 'image' | 'video' | 'audio' | 'pdf' | 'markdown' | 'table' | 'text' | 'cert' | 'none'
/*
* Uploads keep a MIME type only when it cannot execute, so a `.json` or a
* `.pem` is stored as `application/octet-stream`. The name says more.
*/
const TEXT = new Set(
(
'txt text log out err diff patch json jsonl ndjson yaml yml toml ini cfg conf env properties ' +
'xml html htm css scss js mjs cjs ts tsx jsx svelte vue rs go py rb php java kt swift c h cc cpp hpp cs ' +
'sh bash zsh fish ps1 bat sql graphql proto lua pl r tex bib srt vtt gitignore dockerfile makefile pub'
).split(' '),
)
const CERT = new Set(['pem', 'crt', 'cer', 'der', 'csr', 'key', 'p8', 'p12', 'pfx'])
/** `notes.md` → `md`; `Makefile` → `makefile`; a dotfile is its own name. */
function extension(name: string): string {
const lower = name.toLowerCase()
const dot = lower.lastIndexOf('.')
return dot === -1 ? lower : lower.slice(dot + 1)
}
/**
* By name first: `.ts` is TypeScript here, whatever the MIME tables say
* about MPEG transport streams. `none` may still turn out to be text — see
* `looksLikeText`.
*/
export function previewKind(name: string, mime: string): PreviewKind {
const ext = extension(name)
if (CERT.has(ext)) return 'cert'
if (ext === 'md' || ext === 'markdown') return 'markdown'
if (ext === 'csv' || ext === 'tsv') return 'table'
if (TEXT.has(ext)) return 'text'
const [type] = mime.split('/')
if (type === 'image' || type === 'video' || type === 'audio') return type
if (mime === 'application/pdf') return 'pdf'
if (mime === 'text/plain') return 'text'
return 'none'
}
/** No NUL bytes and valid UTF-8, a character cut off at the end aside. */
export function looksLikeText(bytes: Uint8Array): boolean {
if (bytes.includes(0)) return false
try {
new TextDecoder('utf-8', { fatal: true }).decode(bytes, { stream: true })
return true
} catch {
return false
}
}
/** CSV or TSV into rows, quotes respected, at most `limit` of them. */
export function parseTable(text: string, separator: string, limit: number): string[][] {
const rows: string[][] = []
let row: string[] = []
let cell = ''
let quoted = false
for (let i = 0; i < text.length && rows.length < limit; i++) {
const c = text[i]
if (quoted) {
if (c === '"' && text[i + 1] === '"') {
cell += '"'
i++
} else if (c === '"') quoted = false
else cell += c
} else if (c === '"' && cell === '') quoted = true
else if (c === separator) {
row.push(cell)
cell = ''
} else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++
row.push(cell)
rows.push(row)
row = []
cell = ''
} else cell += c
}
if ((cell !== '' || row.length > 0) && rows.length < limit) rows.push([...row, cell])
return rows
}
/** The separator of a table file, by its name. */
export const separatorOf = (name: string) => (extension(name) === 'tsv' ? '\t' : ',')