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
|
# host-specific compose overlay
|
||||||
compose.override.yml
|
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",
|
"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]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -772,6 +811,18 @@ version = "0.5.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
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]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.8"
|
version = "4.6.8"
|
||||||
@@ -1186,10 +1237,43 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid 0.10.2",
|
"const-oid 0.10.2",
|
||||||
|
"der_derive",
|
||||||
|
"flagset",
|
||||||
"pem-rfc7468 1.0.0",
|
"pem-rfc7468 1.0.0",
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "derive_arbitrary"
|
name = "derive_arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -1662,6 +1746,12 @@ version = "0.1.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "flagset"
|
||||||
|
version = "0.4.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flate2"
|
name = "flate2"
|
||||||
version = "1.1.10"
|
version = "1.1.10"
|
||||||
@@ -2520,7 +2610,7 @@ dependencies = [
|
|||||||
"httpdate",
|
"httpdate",
|
||||||
"idna",
|
"idna",
|
||||||
"mime",
|
"mime",
|
||||||
"nom",
|
"nom 8.0.0",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"quoted_printable",
|
"quoted_printable",
|
||||||
"rustls",
|
"rustls",
|
||||||
@@ -2685,6 +2775,12 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minimal-lexical"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -2766,6 +2862,8 @@ dependencies = [
|
|||||||
"keyring",
|
"keyring",
|
||||||
"lettre",
|
"lettre",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"p12-keystore",
|
||||||
|
"pkcs8 0.11.0",
|
||||||
"proton-drive-rs",
|
"proton-drive-rs",
|
||||||
"proton-sdk",
|
"proton-sdk",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
@@ -2775,8 +2873,10 @@ dependencies = [
|
|||||||
"russh",
|
"russh",
|
||||||
"russh-sftp",
|
"russh-sftp",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
|
"sec1 0.8.1",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha1 0.10.7",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -2788,6 +2888,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"x509-parser",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
@@ -2817,6 +2918,16 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "8.0.0"
|
version = "8.0.0"
|
||||||
@@ -2897,6 +3008,12 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-conv"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-integer"
|
name = "num-integer"
|
||||||
version = "0.1.47"
|
version = "0.1.47"
|
||||||
@@ -2970,6 +3087,15 @@ dependencies = [
|
|||||||
"subtle",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -3004,6 +3130,29 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "p256"
|
name = "p256"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3231,7 +3380,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"md-5",
|
"md-5",
|
||||||
"memchr",
|
"memchr",
|
||||||
"nom",
|
"nom 8.0.0",
|
||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"num_enum",
|
"num_enum",
|
||||||
@@ -3322,6 +3471,21 @@ dependencies = [
|
|||||||
"spki 0.8.0",
|
"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]]
|
[[package]]
|
||||||
name = "pkcs5"
|
name = "pkcs5"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -3332,6 +3496,7 @@ dependencies = [
|
|||||||
"aes-gcm 0.11.1",
|
"aes-gcm 0.11.1",
|
||||||
"cbc 0.2.1",
|
"cbc 0.2.1",
|
||||||
"der 0.8.2",
|
"der 0.8.2",
|
||||||
|
"des 0.9.0",
|
||||||
"pbkdf2",
|
"pbkdf2",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
"scrypt",
|
"scrypt",
|
||||||
@@ -3431,6 +3596,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "powerfmt"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -3695,6 +3866,15 @@ dependencies = [
|
|||||||
"rand_core 0.10.1",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -4070,6 +4250,15 @@ dependencies = [
|
|||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -5034,6 +5223,36 @@ dependencies = [
|
|||||||
"cfg-if",
|
"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]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.4"
|
version = "0.8.4"
|
||||||
@@ -5953,6 +6172,34 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "xdg-home"
|
name = "xdg-home"
|
||||||
version = "1.3.0"
|
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.
|
# speaks rand_core 0.10, one major version past the `rand` used everywhere else.
|
||||||
rand10 = { package = "rand", version = "0.10" }
|
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]
|
[dev-dependencies]
|
||||||
# A paused clock, so tests of timed loops do not wait in real time.
|
# A paused clock, so tests of timed loops do not wait in real time.
|
||||||
tokio = { version = "1", features = ["test-util"] }
|
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.
|
- 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.
|
- 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
|
- 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.
|
- 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,
|
- A vault: files sealed with AES-256-GCM under a password that is never stored,
|
||||||
readable only while it is unlocked.
|
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
|
## Files
|
||||||
|
|
||||||
A file is a node like a document, only with bytes instead of text: it sits in
|
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,
|
a folder, shows its size in the list, and `Enter` opens it. What is shown in
|
||||||
audio and plain text are shown in place, anything else is a download. `:name`
|
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`
|
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
|
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
|
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`/`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 |
|
| `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}/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 |
|
| `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 |
|
| `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it |
|
||||||
| `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault |
|
| `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.
|
/// runtime; scripts stay confined to the bundled files.
|
||||||
const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \
|
const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \
|
||||||
style-src 'self' 'unsafe-inline'; script-src 'self'; connect-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>;
|
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"
|
"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) {
|
let key = match (encrypted, private) {
|
||||||
(false, _) => None,
|
(false, _) => None,
|
||||||
(true, false) => return Err(AppError::NotFound),
|
(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,
|
// Belt and braces: never let the browser sniff past the type above,
|
||||||
// and strip the ambient authority of the origin from the response.
|
// and strip the ambient authority of the origin from the response.
|
||||||
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
|
(header::X_CONTENT_TYPE_OPTIONS, "nosniff".to_string()),
|
||||||
(
|
(header::CONTENT_SECURITY_POLICY, policy.to_string()),
|
||||||
header::CONTENT_SECURITY_POLICY,
|
(header::X_FRAME_OPTIONS, frames.to_string()),
|
||||||
"sandbox; default-src 'none'".to_string(),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
body,
|
body,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod backup;
|
|||||||
pub mod boards;
|
pub mod boards;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
pub mod inspect;
|
||||||
pub mod manifest;
|
pub mod manifest;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
pub mod nodes;
|
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}/files", post(media::upload_into).layer(uploads))
|
||||||
.route("/nodes/{id}/encrypt", post(seal::encrypt))
|
.route("/nodes/{id}/encrypt", post(seal::encrypt))
|
||||||
.route("/nodes/{id}/decrypt", post(seal::decrypt))
|
.route("/nodes/{id}/decrypt", post(seal::decrypt))
|
||||||
|
.route("/nodes/{id}/inspect", post(inspect::inspect))
|
||||||
.route(
|
.route(
|
||||||
"/vault",
|
"/vault",
|
||||||
get(vault::status)
|
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">
|
<script lang="ts">
|
||||||
|
import CertView from './CertView.svelte'
|
||||||
import { hooks, runCommand, type ScrollAmount } from './commands'
|
import { hooks, runCommand, type ScrollAmount } from './commands'
|
||||||
import Icon from './Icon.svelte'
|
import Icon from './Icon.svelte'
|
||||||
|
import { renderMarkdown } from './markdown'
|
||||||
|
import { looksLikeText, parseTable, previewKind, separatorOf } from './preview'
|
||||||
import { workspace } from './store.svelte'
|
import { workspace } from './store.svelte'
|
||||||
import { formatBytes, formatStamp, relative, scrollPage } from './util'
|
import { formatBytes, formatStamp, relative, scrollPage } from './util'
|
||||||
|
|
||||||
@@ -8,27 +11,47 @@
|
|||||||
const file = $derived(node.file!)
|
const file = $derived(node.file!)
|
||||||
/* A sealed file cannot be fetched while the vault is shut. */
|
/* A sealed file cannot be fetched while the vault is shut. */
|
||||||
const locked = $derived(file.encrypted && !workspace.vault?.unlocked)
|
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. */
|
/* What the browser can show by itself; everything else is a download. */
|
||||||
const viewable = $derived(file.mime !== 'application/octet-stream')
|
const viewable = $derived(file.mime !== 'application/octet-stream')
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Plain text is shown inline, but only its beginning: a log file can run to
|
* Text is shown inline, but only its beginning: a log file can run to
|
||||||
* gigabytes, and the download has the rest.
|
* 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 TEXT_PREVIEW = 256 * 1024
|
||||||
|
const SNIFF = 8 * 1024
|
||||||
let text = $state<string | null>(null)
|
let text = $state<string | null>(null)
|
||||||
|
/* Markdown is read rendered, unless asked for its source. */
|
||||||
|
let source = $state(false)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
text = null
|
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
|
const url = file.url
|
||||||
void head(url, TEXT_PREVIEW).then((body) => {
|
const sniff = kind === 'none'
|
||||||
if (body !== null && url === file.url) text = body
|
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. */
|
const lines = $derived(text === null ? [] : text.replace(/\n$/, '').split('\n'))
|
||||||
async function head(url: string, limit: number): Promise<string | null> {
|
|
||||||
|
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)
|
const response = await fetch(url)
|
||||||
if (!response.ok || !response.body) return null
|
if (!response.ok || !response.body) return null
|
||||||
const reader = response.body.getReader()
|
const reader = response.body.getReader()
|
||||||
@@ -42,7 +65,7 @@
|
|||||||
size += take.length
|
size += take.length
|
||||||
}
|
}
|
||||||
void reader.cancel()
|
void reader.cancel()
|
||||||
return new TextDecoder().decode(bytes.subarray(0, size))
|
return bytes.subarray(0, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
let reader = $state<HTMLElement>()
|
let reader = $state<HTMLElement>()
|
||||||
@@ -89,6 +112,9 @@
|
|||||||
{#if locked}
|
{#if locked}
|
||||||
<button onclick={() => runCommand('unlock')}>unlock</button>
|
<button onclick={() => runCommand('unlock')}>unlock</button>
|
||||||
{:else}
|
{:else}
|
||||||
|
{#if kind === 'markdown' && text !== null}
|
||||||
|
<button onclick={() => (source = !source)}>{source ? 'rendered' : 'source'}</button>
|
||||||
|
{/if}
|
||||||
{#if viewable}
|
{#if viewable}
|
||||||
<a class="button" href={file.url} target="_blank" rel="noopener">open</a>
|
<a class="button" href={file.url} target="_blank" rel="noopener">open</a>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -118,8 +144,38 @@
|
|||||||
<video src={file.url} controls playsinline preload="metadata"></video>
|
<video src={file.url} controls playsinline preload="metadata"></video>
|
||||||
{:else if kind === 'audio'}
|
{:else if kind === 'audio'}
|
||||||
<audio src={file.url} controls preload="metadata"></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}
|
{: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}
|
{#if file.size > TEXT_PREVIEW}
|
||||||
<p class="muted">the first {formatBytes(TEXT_PREVIEW)} of {formatBytes(file.size)} — download it for the rest.</p>
|
<p class="muted">the first {formatBytes(TEXT_PREVIEW)} of {formatBytes(file.size)} — download it for the rest.</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -215,6 +271,36 @@
|
|||||||
overflow-wrap: anywhere;
|
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); }
|
.light { color: var(--light); }
|
||||||
.sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); }
|
.sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); }
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,8 @@
|
|||||||
<footer>
|
<footer>
|
||||||
inside the editor the full vim keymap is live — hjkl, w/b, dd, ciw, visual mode, macros, and
|
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
|
<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 —
|
deletes are copied to the system clipboard —
|
||||||
<span class="key">:set noclipboard</span> keeps them in vim's registers.
|
<span class="key">:set noclipboard</span> keeps them in vim's registers.
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -141,6 +141,42 @@ export interface MediaFile {
|
|||||||
file_node: number | null
|
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. */
|
/** The vault, as `/api/vault` reports it. */
|
||||||
export interface VaultStatus {
|
export interface VaultStatus {
|
||||||
configured: boolean
|
configured: boolean
|
||||||
@@ -403,6 +439,10 @@ export const api = {
|
|||||||
method: 'POST',
|
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'),
|
backupStatus: () => request<BackupStatus>('/backup'),
|
||||||
|
|
||||||
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
|
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