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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
@@ -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-----
|
||||
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-----
|
||||
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-----
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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' : ',')
|
||||
Reference in new issue
Block a user