diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index afe30062..3e54a560 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (257) +## In this repository (258) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -265,6 +265,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) | | `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py | | `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | +| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 99cbbfb0..9af55652 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3208,3 +3208,14 @@ licence = "OFL-1.1" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" kind = "font" usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); the web build embeds only BIZ UDPGothic Regular" + +[[asset]] +path = "crates/sign/tests/data/x509-rsa-sha1.pdf" +title = "One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key" +author = "PdfCraft contributors" +source = "Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md)" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "document" +usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format" +sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b" diff --git a/Cargo.lock b/Cargo.lock index 05523a30..e5da3f9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,30 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "bp256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f98c2d9e8e0796b24f84b7ae14159b91ae6ae4b85f74f7655a678a56ad5e1e" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", +] + +[[package]] +name = "bp384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc61f3a96b44a33fbb971e9917b81f4207d6dae9a6b0715845c785a48e73d9c2" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", +] + [[package]] name = "brotli" version = "8.0.4" @@ -1160,6 +1184,33 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -1338,6 +1389,28 @@ dependencies = [ "serde", ] +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "eframe" version = "0.36.2" @@ -2614,6 +2687,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3400,6 +3483,20 @@ dependencies = [ "sha2", ] +[[package]] +name = "p521" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3770,22 +3867,30 @@ dependencies = [ "aes", "aws-lc-rs", "base64 0.22.1", + "bp256", + "bp384", "cbc", + "crypto-bigint", "des", + "ecdsa", + "ed25519-dalek", "getrandom 0.4.3", "hmac", "p256", "p384", + "p521", "pbkdf2", "pdfcraft-annot", "pdfcraft-cos", "pdfcraft-fonts", "rc2", + "ripemd", "rsa", "rustls-cng", "security-framework", "sha1", "sha2", + "sha3", "thiserror 2.0.21", ] @@ -4556,6 +4661,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ripemd" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd4211456b4172d7e44261920c25acf07367c4f04bb5f5d54fc21b090d9b159" +dependencies = [ + "digest", +] + [[package]] name = "ron" version = "0.12.2" @@ -5006,6 +5120,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest", + "keccak", + "sponge-cursor", +] + [[package]] name = "shlex" version = "2.0.1" @@ -5223,6 +5348,12 @@ dependencies = [ "der", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/ROADMAP.md b/ROADMAP.md index d9d99326..fffb0e32 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -174,6 +174,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-09 (M9, signatures as DocuSign and Documenso write them, #144):** A real DocuSign + Telekom qualified-signature PDF showed "DER signature wrong" although Acrobat validates it: both CMS blobs use BER indefinite lengths. The CMS is read with the BER reader that #453 added (`Tlv::parse_ber`: indefinite lengths, segmented OCTET STRINGs, depth-bounded) while certificates and signed attributes are still verified over their own exact bytes, and the details say the signature was BER; tests build the nesting bombs a crafted /Contents could hold. Behind that, later revisions that add DSS data (indirect `/Certs`, `/VRI`) or rewrite the catalog's XMP were classified as disallowed changes; they are permitted now, narrowly (review by echelon on #144): only objects reached through `/DSS` `/Certs|CRLs|OCSPs|VRI` with the shape of their role, and new or only grown since signing, count as the store, and only the catalog's `/Metadata` is the document's XMP. A `/DSS` entry naming a page's contents, or a `/Type /DSS` or `/Type /Metadata` label on an existing dictionary or stream, makes nothing permitted. Six of the new tests fail on the previous code, among them a Form XObject labelled `/Type /Metadata` before signing that could be rewritten under a Valid signature. Negative tests at every DocMDP level. Cross-checked with pdfsig (both signatures valid). Still missing: AATL/EUTL trust lists (a separate change: they need CA-constraint checks in chain building first). Overall estimate unchanged (about 30–35%). +- **2026-10-09 (M9, signature algorithms, #386):** Validation reads what real signers write. Digests SHA-224, SHA-512/224, SHA-512/256, SHA3-224/256/384/512 and RIPEMD-160 (beside SHA-1/2); RSA PKCS#1 with or without the DigestInfo's NULL (checked by building the one expected encoding and comparing it byte for byte, in `rsa_pad`, for every digest; a bare digest without any DigestInfo is refused, it doesn't say which hash it uses); RSA-PSS with the hash, MGF1 hash and salt length the signature declares; ECDSA as DER, non-minimal DER or raw r‖s on P-256, P-384, P-521 and brainpoolP256r1/P384r1/P512r1 (the last hand-written on `crypto-bigint`, no RustCrypto crate exists); Ed25519 (RFC 8419); the legacy `adbe.x509.rsa_sha1` format. Algorithms PdfCraft can't check give Unknown instead of Invalid. Review of #386 (echelon): an RSA signature value must be below the modulus (RFC 8017 §5.2.2; `s` and `s + n` both verified before), and SHA-1 and RIPEMD-160 signatures still validate but the signature's details now say the algorithm is weak (wording pinned by tests). Found on the way, each with a regression test that failed first: a brainpoolP512r1 key with a coordinate not reduced modulo p panicked inside the bigint arithmetic (the certificate comes from the PDF), now refused, and the point at infinity is an invalid signature rather than an error. Tolerated encodings are listed in the signature's details. Vectors made with OpenSSL; cross-checked with pdfsig. Still missing: Ed448/DSA, signing with the new digests. Overall estimate unchanged (about 30–35%). + - **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%. - **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%). diff --git a/crates/sign/Cargo.toml b/crates/sign/Cargo.toml index ccd75f7b..73210cff 100644 --- a/crates/sign/Cargo.toml +++ b/crates/sign/Cargo.toml @@ -20,14 +20,23 @@ sha1 = "0.11" sha2 = "0.11" p256 = { version = "0.14", default-features = false, features = ["ecdsa", "std"] } p384 = { version = "0.14", default-features = false, features = ["ecdsa", "std"] } +# Verification only: P-521 and the Brainpool curves German qualified-signature cards use. +p521 = { version = "0.14", default-features = false, features = ["ecdsa", "std"] } +bp256 = { version = "0.14", default-features = false, features = ["arithmetic", "ecdsa", "std"] } +bp384 = { version = "0.14", default-features = false, features = ["arithmetic", "ecdsa", "std"] } +ecdsa = { version = "0.17", default-features = false, features = ["algorithm", "der"] } # Verification only on native targets (RSA private-key operations use aws-lc-rs, ADR-0009). -rsa = { version = "=0.10.0-rc.18", default-features = false, features = ["std"] } +crypto-bigint = { version = "0.7", default-features = false, features = ["alloc"] } +rsa = { version = "=0.10.0-rc.18", default-features = false, features = ["std", "hazmat"] } pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] } hmac = "0.13" aes = "0.9" cbc = { version = "0.2", features = ["alloc", "block-padding"] } des = "0.9" rc2 = "0.9" +sha3 = "0.12.0" +ripemd = "0.2.0" +ed25519-dalek = "3.0.0" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] aws-lc-rs = { version = "1.18", default-features = false, features = ["aws-lc-sys", "prebuilt-nasm"] } diff --git a/crates/sign/README.md b/crates/sign/README.md index cb7455f8..1d1f895d 100644 --- a/crates/sign/README.md +++ b/crates/sign/README.md @@ -72,6 +72,26 @@ ECDSA P-256/P-384; the store integration is tested with software-backed keys. document's XMP, and only objects reached through the `/DSS` keys above are the security store. A `/Type /Metadata` or `/Type /DSS` *label* on any other dictionary or stream (a page's Form XObject, say) makes nothing permitted, at every DocMDP level. +- **Strict in what it checks, tolerant where signers differ.** RSA PKCS #1 signatures are checked + against the one encoding the digest should have, built here and compared byte for byte; the + `DigestInfo` may omit its NULL parameter, as older signers write it (RFC 8017 App. B.1), but a + signature over the bare digest, which doesn't say which hash it uses, is refused. A signature + value must be below the modulus (RFC 8017 §5.2.2). RSA-PSS uses the salt length it declares. + ECDSA is read as DER, non-minimal DER or raw `r ‖ s`. `adbe.x509.rsa_sha1` is validated. What the + check tolerated is listed in the signature's details: a DigestInfo without its NULL, an RSA value + that isn't modulus-length, ECDSA that isn't canonical DER, a SignerInfo whose digest algorithm + differs from the one the signature algorithm names. What PdfCraft can't check (an unknown + algorithm or curve) is *unknown*, never *invalid*. +- **Weak algorithms still validate, and say so.** SHA-1 and RIPEMD-160 signatures are common in + documents signed years ago, so they validate (new signatures never use SHA-1), but the signature's + details say that the algorithm is weak and shouldn't be relied on. +- **Validation algorithms:** digests SHA-1, SHA-224/256/384/512, SHA-512/224, SHA-512/256, + SHA3-224/256/384/512 and RIPEMD-160. RSA PKCS #1 v1.5 and RSASSA-PSS (the hash, MGF1 hash and + salt length the signature declares), ECDSA on P-256, P-384, P-521, brainpoolP256r1, P384r1 and + P512r1 (also BSI "plain" ECDSA), and Ed25519 (RFC 8419, over the signed attributes). + Signing: RSA, P-256, P-384 with SHA-256/384/512. The RSA padding checks and brainpoolP512r1 are + written here (`rsa_pad`, `ec512`) on `crypto-bigint`, because the typed APIs of the `rsa` crate + and RustCrypto don't cover them. Oracles: poppler's `pdfsig` reports our signatures valid; OpenSSL reads our `.p12` files and verifies our CMS; `tests/data/openssl-signed.pdf` is a signature OpenSSL made, which we validate. diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index 7627c44e..f1f17e4d 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -124,14 +124,51 @@ impl SignedData { /// Check the signature value with `cert`'s key. `content_digest` is the digest of the /// signed content (the document byte ranges) when there are no signed attributes. - pub fn verify_signature(&self, cert: &Certificate, content_digest: &[u8]) -> bool { + /// `Err(Unsupported)`: the signer's key algorithm can't be checked here. + pub fn verify_signature(&self, cert: &Certificate, content_digest: &[u8]) -> Result { + self.verify_signature_noting(cert, content_digest, &mut Vec::new()) + } + + /// [`SignedData::verify_signature`], noting in `notes` each encoding irregularity or + /// disagreement it tolerated. Only meaningful when it returns `Ok(true)`. + pub fn verify_signature_noting(&self, cert: &Certificate, content_digest: &[u8], notes: &mut Vec<&'static str>) -> Result { let s = &self.signer; - let alg = s.scheme_digest.unwrap_or(s.digest); - let digest = match &s.signed_attrs { - Some(attrs) => alg.digest(&[attrs]), - None => content_digest.to_vec(), - }; - cert.public_key.verify(s.scheme, alg, &digest, &s.signature).unwrap_or(false) + // EdDSA signs the message itself: with signed attributes, their DER encoding. + if s.scheme == Scheme::Ed25519 { + let Some(attrs) = &s.signed_attrs else { + return Err(SignError::Unsupported("Ed25519 signature without signed attributes".into())); + }; + return cert.public_key.verify_message(s.scheme, attrs, &s.signature); + } + // The signature algorithm names the digest it used; a bare `rsaEncryption` / `ecPublicKey` + // uses the SignerInfo's digestAlgorithm. Some signers disagree, so try both. + let named = s.scheme_digest.unwrap_or(s.digest); + let mut algs = vec![named]; + if !algs.contains(&s.digest) { + algs.push(s.digest); + } + for alg in algs { + let digest = match &s.signed_attrs { + Some(attrs) => alg.digest(&[attrs]), + None => content_digest.to_vec(), + }; + // Notes from an attempt that doesn't verify say nothing about the signature. + let mut attempt = Vec::new(); + if cert.public_key.verify_noting(s.scheme, alg, &digest, &s.signature, &mut attempt)? { + notes.extend(attempt); + // The hash of the content and the hash the signature is made with. + for weak in [s.digest, alg].into_iter().filter_map(DigestAlg::weakness) { + if !notes.contains(&weak) { + notes.push(weak); + } + } + if alg != named { + notes.push("The signature algorithm and the SignerInfo's digest algorithm name different hashes; the signature was made with the SignerInfo's."); + } + return Ok(true); + } + } + Ok(false) } } diff --git a/crates/sign/src/ec512.rs b/crates/sign/src/ec512.rs new file mode 100644 index 00000000..53c73e54 --- /dev/null +++ b/crates/sign/src/ec512.rs @@ -0,0 +1,180 @@ +//! ECDSA verification on brainpoolP512r1 (RFC 5639), for which no RustCrypto curve crate exists. +//! +//! Verification only, on public data, so plain affine arithmetic over `crypto-bigint` is enough: +//! no secret is involved, and a verification takes a few milliseconds. Checked against +//! signatures made by OpenSSL (`tests/crypto.rs`). + +use rsa::BoxedUint; + +const P: &str = "aadd9db8dbe9c48b3fd4e6ae33c9fc07cb308db3b3c9d20ed6639cca703308717d4d9b009bc66842aecda12ae6a380e62881ff2f2d82c68528aa6056583a48f3"; +const A: &str = "7830a3318b603b89e2327145ac234cc594cbdd8d3df91610a83441caea9863bc2ded5d5aa8253aa10a2ef1c98b9ac8b57f1117a72bf2c7b9e7c1ac4d77fc94ca"; +const B: &str = "3df91610a83441caea9863bc2ded5d5aa8253aa10a2ef1c98b9ac8b57f1117a72bf2c7b9e7c1ac4d77fc94cadc083e67984050b75ebae5dd2809bd638016f723"; +const GX: &str = "81aee4bdd82ed9645a21322e9c4c6a9385ed9f70b5d916c1b43b62eef4d0098eff3b1f78e2d0d48d50d1687b93b97d5f7c6d5047406a5e688b352209bcb9f822"; +const GY: &str = "7dde385d566332ecc0eabfa9cf7822fdf209f70024a57b1aa000c55b881f8111b2dcde494a5f485e5bca4bd88a2763aed1ca2b2fa8f0540678cd1e0f3ad80892"; +const N: &str = "aadd9db8dbe9c48b3fd4e6ae33c9fc07cb308db3b3c9d20ed6639cca70330870553e5c414ca92619418661197fac10471db1d381085ddaddb58796829ca90069"; + +const BITS: u32 = 512; +const BYTES: usize = 64; + +type Point = Option<(BoxedUint, BoxedUint)>; + +fn big(bytes: &[u8]) -> Option { + let pad = BYTES.checked_sub(bytes.len())?; + let mut v = vec![0u8; pad]; + v.extend_from_slice(bytes); + BoxedUint::from_be_slice(&v, BITS).ok() +} + +fn hex(s: &str) -> Option { + let bytes: Option> = (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect(); + big(&bytes?) +} + +/// Arithmetic modulo a prime. +struct Field { + nz: crypto_bigint::NonZero, +} + +impl Field { + fn new(p: BoxedUint) -> Option { + let nz = Option::from(crypto_bigint::NonZero::new(p))?; + Some(Field { nz }) + } + fn add(&self, a: &BoxedUint, b: &BoxedUint) -> BoxedUint { + a.add_mod(b, &self.nz) + } + fn sub(&self, a: &BoxedUint, b: &BoxedUint) -> BoxedUint { + a.sub_mod(b, &self.nz) + } + fn mul(&self, a: &BoxedUint, b: &BoxedUint) -> BoxedUint { + a.mul_mod(b, &self.nz) + } + fn inv(&self, a: &BoxedUint) -> Option { + Option::from(a.invert_mod(&self.nz)) + } + /// Whether `v` is reduced (`v < p`). `add_mod`, `sub_mod` and friends assert it, and panic on + /// anything else, so nothing from outside may reach them without this check. + fn is_reduced(&self, v: &BoxedUint) -> bool { + v < self.nz.as_ref() + } +} + +struct Curve { + f: Field, + a: BoxedUint, + b: BoxedUint, + g: (BoxedUint, BoxedUint), + n: BoxedUint, +} + +impl Curve { + fn brainpool_p512r1() -> Option { + Some(Curve { f: Field::new(hex(P)?)?, a: hex(A)?, b: hex(B)?, g: (hex(GX)?, hex(GY)?), n: hex(N)? }) + } + + fn on_curve(&self, x: &BoxedUint, y: &BoxedUint) -> bool { + let f = &self.f; + let lhs = f.mul(y, y); + let rhs = f.add(&f.add(&f.mul(&f.mul(x, x), x), &f.mul(&self.a, x)), &self.b); + lhs == rhs + } + + fn double(&self, p: &Point) -> Point { + let (x, y) = p.as_ref()?; + let f = &self.f; + if bool::from(y.is_zero()) { + return None; + } + let three = big(&[3])?; + let two = big(&[2])?; + let num = f.add(&f.mul(&three, &f.mul(x, x)), &self.a); + let lambda = f.mul(&num, &f.inv(&f.mul(&two, y))?); + let x3 = f.sub(&f.sub(&f.mul(&lambda, &lambda), x), x); + let y3 = f.sub(&f.mul(&lambda, &f.sub(x, &x3)), y); + Some((x3, y3)) + } + + fn add(&self, p: &Point, q: &Point) -> Point { + let (Some((x1, y1)), Some((x2, y2))) = (p, q) else { return p.clone().or_else(|| q.clone()) }; + let f = &self.f; + if x1 == x2 { + return if y1 == y2 { self.double(p) } else { None }; + } + let lambda = f.mul(&f.sub(y2, y1), &f.inv(&f.sub(x2, x1))?); + let x3 = f.sub(&f.sub(&f.mul(&lambda, &lambda), x1), x2); + let y3 = f.sub(&f.mul(&lambda, &f.sub(x1, &x3)), y1); + Some((x3, y3)) + } + + /// `k · p`, double-and-add from the top bit. + fn mul(&self, k: &BoxedUint, p: &Point) -> Point { + let mut acc: Point = None; + for i in (0..k.bits_vartime()).rev() { + acc = self.double(&acc); + if bool::from(k.bit(i)) { + acc = self.add(&acc, p); + } + } + acc + } +} + +/// Check the ECDSA signature `r ‖ s` (64 bytes each) of `digest` with the SEC1 uncompressed +/// point `public` (`04 ‖ x ‖ y`). `None`: the key is not a valid point or the input is malformed. +pub fn verify(public: &[u8], digest: &[u8], rs: &[u8]) -> Option { + let c = Curve::brainpool_p512r1()?; + let [0x04, xy @ ..] = public else { return None }; + if xy.len() != 2 * BYTES || rs.len() != 2 * BYTES { + return None; + } + let (qx, qy) = (big(xy.get(..BYTES)?)?, big(xy.get(BYTES..)?)?); + // SEC 1 §2.3.4: coordinates are field elements. The field arithmetic panics on unreduced ones. + if !c.f.is_reduced(&qx) || !c.f.is_reduced(&qy) || !c.on_curve(&qx, &qy) { + return None; + } + let (r, s) = (big(rs.get(..BYTES)?)?, big(rs.get(BYTES..)?)?); + let n = crypto_bigint::NonZero::new(c.n.clone()); + let n: crypto_bigint::NonZero = Option::from(n)?; + let zero = |v: &BoxedUint| bool::from(v.is_zero()); + if zero(&r) || zero(&s) || r >= c.n || s >= c.n { + return Some(false); + } + // The leftmost 512 bits of the digest (all of it, for digests up to SHA-512). + let e = big(digest.get(..digest.len().min(BYTES))?)?.rem_vartime(&n); + let w: BoxedUint = Option::from(s.invert_mod(&n))?; + let u1 = e.mul_mod(&w, &n); + let u2 = r.mul_mod(&w, &n); + let point = c.add(&c.mul(&u1, &Some(c.g.clone())), &c.mul(&u2, &Some((qx, qy)))); + // The point at infinity is no valid result (X9.62 §7.4.1 step 5): the signature is wrong. + let Some((x, _)) = point else { return Some(false) }; + Some(x.rem_vartime(&n) == r) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unhex(s: &str) -> [u8; 64] { + let v: Vec = (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2).unwrap_or("00"), 16).unwrap_or(0)).collect(); + let mut out = [0u8; 64]; + out[64 - v.len()..].copy_from_slice(&v); + out + } + + /// Never-crash: public keys and signature values of any size are refused, not panicked on. + #[test] + fn hostile_keys_and_signatures_are_refused() { + let digest = [7u8; 64]; + let key = |x: &[u8; 64], y: &[u8; 64]| [&[4u8][..], x, y].concat(); + let (p, n, gx, gy) = (unhex(P), unhex(N), unhex(GX), unhex(GY)); + // Coordinates that are not field elements, or not on the curve. + for (x, y) in [([0xFF; 64], [0xFF; 64]), (p, [0; 64]), ([0; 64], p), ([0; 64], [0; 64]), (p, p)] { + assert_eq!(verify(&key(&x, &y), &digest, &[1u8; 128]), None); + } + // The generator, a real point, with an r or s that is zero, n, or all ones. + for bad in [[0u8; 64], n, [0xFF; 64]] { + assert_eq!(verify(&key(&gx, &gy), &digest, &[bad.to_vec(), vec![1; 64]].concat()), Some(false)); + assert_eq!(verify(&key(&gx, &gy), &digest, &[vec![1; 64], bad.to_vec()].concat()), Some(false)); + } + } +} diff --git a/crates/sign/src/keys.rs b/crates/sign/src/keys.rs index 84558578..c9ea6cb2 100644 --- a/crates/sign/src/keys.rs +++ b/crates/sign/src/keys.rs @@ -9,66 +9,112 @@ use crate::der::{self, Tlv, tag}; pub mod oid { pub const SHA1: &str = "1.3.14.3.2.26"; + pub const SHA224: &str = "2.16.840.1.101.3.4.2.4"; pub const SHA256: &str = "2.16.840.1.101.3.4.2.1"; pub const SHA384: &str = "2.16.840.1.101.3.4.2.2"; pub const SHA512: &str = "2.16.840.1.101.3.4.2.3"; + pub const SHA512_224: &str = "2.16.840.1.101.3.4.2.5"; + pub const SHA512_256: &str = "2.16.840.1.101.3.4.2.6"; + pub const SHA3_224: &str = "2.16.840.1.101.3.4.2.7"; + pub const SHA3_256: &str = "2.16.840.1.101.3.4.2.8"; + pub const SHA3_384: &str = "2.16.840.1.101.3.4.2.9"; + pub const SHA3_512: &str = "2.16.840.1.101.3.4.2.10"; + pub const RIPEMD160: &str = "1.3.36.3.2.1"; pub const RSA: &str = "1.2.840.113549.1.1.1"; - pub const RSA_SHA1: &str = "1.2.840.113549.1.1.5"; pub const RSA_PSS: &str = "1.2.840.113549.1.1.10"; - pub const RSA_SHA256: &str = "1.2.840.113549.1.1.11"; - pub const RSA_SHA384: &str = "1.2.840.113549.1.1.12"; - pub const RSA_SHA512: &str = "1.2.840.113549.1.1.13"; pub const MGF1: &str = "1.2.840.113549.1.1.8"; pub const EC: &str = "1.2.840.10045.2.1"; pub const P256: &str = "1.2.840.10045.3.1.7"; pub const P384: &str = "1.3.132.0.34"; - pub const ECDSA_SHA1: &str = "1.2.840.10045.4.1"; - pub const ECDSA_SHA256: &str = "1.2.840.10045.4.3.2"; - pub const ECDSA_SHA384: &str = "1.2.840.10045.4.3.3"; - pub const ECDSA_SHA512: &str = "1.2.840.10045.4.3.4"; + pub const P521: &str = "1.3.132.0.35"; + pub const BRAINPOOL_P256: &str = "1.3.36.3.3.2.8.1.1.7"; + pub const BRAINPOOL_P384: &str = "1.3.36.3.3.2.8.1.1.11"; + pub const BRAINPOOL_P512: &str = "1.3.36.3.3.2.8.1.1.13"; + pub const ED25519: &str = "1.3.101.112"; } /// Message digests. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum DigestAlg { Sha1, + Sha224, Sha256, Sha384, Sha512, + Sha512_224, + Sha512_256, + Sha3_224, + Sha3_256, + Sha3_384, + Sha3_512, + Ripemd160, } impl DigestAlg { + /// Every digest, for tables. + pub const ALL: [DigestAlg; 12] = [ + DigestAlg::Sha1, + DigestAlg::Sha224, + DigestAlg::Sha256, + DigestAlg::Sha384, + DigestAlg::Sha512, + DigestAlg::Sha512_224, + DigestAlg::Sha512_256, + DigestAlg::Sha3_224, + DigestAlg::Sha3_256, + DigestAlg::Sha3_384, + DigestAlg::Sha3_512, + DigestAlg::Ripemd160, + ]; + pub fn from_oid(o: &str) -> Option { - Some(match o { - oid::SHA1 => DigestAlg::Sha1, - oid::SHA256 => DigestAlg::Sha256, - oid::SHA384 => DigestAlg::Sha384, - oid::SHA512 => DigestAlg::Sha512, - _ => return None, - }) + DigestAlg::ALL.into_iter().find(|d| d.oid() == o) } pub fn oid(self) -> &'static str { match self { DigestAlg::Sha1 => oid::SHA1, + DigestAlg::Sha224 => oid::SHA224, DigestAlg::Sha256 => oid::SHA256, DigestAlg::Sha384 => oid::SHA384, DigestAlg::Sha512 => oid::SHA512, + DigestAlg::Sha512_224 => oid::SHA512_224, + DigestAlg::Sha512_256 => oid::SHA512_256, + DigestAlg::Sha3_224 => oid::SHA3_224, + DigestAlg::Sha3_256 => oid::SHA3_256, + DigestAlg::Sha3_384 => oid::SHA3_384, + DigestAlg::Sha3_512 => oid::SHA3_512, + DigestAlg::Ripemd160 => oid::RIPEMD160, } } pub fn name(self) -> &'static str { match self { DigestAlg::Sha1 => "SHA-1", + DigestAlg::Sha224 => "SHA-224", DigestAlg::Sha256 => "SHA-256", DigestAlg::Sha384 => "SHA-384", DigestAlg::Sha512 => "SHA-512", + DigestAlg::Sha512_224 => "SHA-512/224", + DigestAlg::Sha512_256 => "SHA-512/256", + DigestAlg::Sha3_224 => "SHA3-224", + DigestAlg::Sha3_256 => "SHA3-256", + DigestAlg::Sha3_384 => "SHA3-384", + DigestAlg::Sha3_512 => "SHA3-512", + DigestAlg::Ripemd160 => "RIPEMD-160", } } /// The digest of the concatenation of `parts`. pub fn digest(self, parts: &[&[u8]]) -> Vec { - fn run(parts: &[&[u8]]) -> Vec { + fn sha2_run(parts: &[&[u8]]) -> Vec { + let mut h = D::new(); + for p in parts { + h.update(p); + } + h.finalize().to_vec() + } + fn sha3_run(parts: &[&[u8]]) -> Vec { let mut h = D::new(); for p in parts { h.update(p); @@ -76,10 +122,51 @@ impl DigestAlg { h.finalize().to_vec() } match self { - DigestAlg::Sha1 => run::(parts), - DigestAlg::Sha256 => run::(parts), - DigestAlg::Sha384 => run::(parts), - DigestAlg::Sha512 => run::(parts), + DigestAlg::Sha1 => sha2_run::(parts), + DigestAlg::Sha224 => sha2_run::(parts), + DigestAlg::Sha256 => sha2_run::(parts), + DigestAlg::Sha384 => sha2_run::(parts), + DigestAlg::Sha512 => sha2_run::(parts), + DigestAlg::Sha512_224 => sha2_run::(parts), + DigestAlg::Sha512_256 => sha2_run::(parts), + DigestAlg::Sha3_224 => sha3_run::(parts), + DigestAlg::Sha3_256 => sha3_run::(parts), + DigestAlg::Sha3_384 => sha3_run::(parts), + DigestAlg::Sha3_512 => sha3_run::(parts), + DigestAlg::Ripemd160 => { + use ripemd::Digest; + let mut h = ripemd::Ripemd160::new(); + for p in parts { + h.update(p); + } + h.finalize().to_vec() + } + } + } + + /// What to tell the user when a signature that still validates uses this digest, or `None` + /// for one that is fine. Old documents carry SHA-1 and RIPEMD-160, so they validate, but a + /// signature panel that showed them like a SHA-256 one would overstate what they prove. + pub fn weakness(self) -> Option<&'static str> { + match self { + DigestAlg::Sha1 => Some( + "This signature uses SHA-1, a weak hash algorithm: collisions in it have been demonstrated. It still validates because many documents signed years ago use it, but it should not be relied on.", + ), + DigestAlg::Ripemd160 => Some( + "This signature uses RIPEMD-160, a legacy 160-bit hash algorithm that is no longer recommended for signatures. It still validates, but it should not be relied on.", + ), + _ => None, + } + } + + /// The digest length in bytes. + pub fn output_len(self) -> usize { + match self { + DigestAlg::Sha1 | DigestAlg::Ripemd160 => 20, + DigestAlg::Sha224 | DigestAlg::Sha512_224 | DigestAlg::Sha3_224 => 28, + DigestAlg::Sha256 | DigestAlg::Sha512_256 | DigestAlg::Sha3_256 => 32, + DigestAlg::Sha384 | DigestAlg::Sha3_384 => 48, + DigestAlg::Sha512 | DigestAlg::Sha3_512 => 64, } } @@ -89,52 +176,130 @@ impl DigestAlg { } } +/// RSASSA-PSS parameters (RFC 4055 §3.1) besides the message digest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PssParams { + /// The MGF1 hash. + pub mgf: DigestAlg, + /// The declared salt length; `None` recovers it from the signature. + pub salt_len: Option, +} + /// How a signature value is computed. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Scheme { RsaPkcs1, - RsaPss, + RsaPss(PssParams), + /// ECDSA over a digest, the signature DER or plain `r ‖ s`. Ecdsa, + /// EdDSA over the message itself (RFC 8419), not over a digest. + Ed25519, } +/// Signature algorithm OIDs that name both the scheme and the digest. +const SIGNATURE_OIDS: &[(&str, Scheme, DigestAlg)] = &[ + ("1.2.840.113549.1.1.5", Scheme::RsaPkcs1, DigestAlg::Sha1), + ("1.2.840.113549.1.1.14", Scheme::RsaPkcs1, DigestAlg::Sha224), + ("1.2.840.113549.1.1.11", Scheme::RsaPkcs1, DigestAlg::Sha256), + ("1.2.840.113549.1.1.12", Scheme::RsaPkcs1, DigestAlg::Sha384), + ("1.2.840.113549.1.1.13", Scheme::RsaPkcs1, DigestAlg::Sha512), + ("1.2.840.113549.1.1.15", Scheme::RsaPkcs1, DigestAlg::Sha512_224), + ("1.2.840.113549.1.1.16", Scheme::RsaPkcs1, DigestAlg::Sha512_256), + ("2.16.840.1.101.3.4.3.13", Scheme::RsaPkcs1, DigestAlg::Sha3_224), + ("2.16.840.1.101.3.4.3.14", Scheme::RsaPkcs1, DigestAlg::Sha3_256), + ("2.16.840.1.101.3.4.3.15", Scheme::RsaPkcs1, DigestAlg::Sha3_384), + ("2.16.840.1.101.3.4.3.16", Scheme::RsaPkcs1, DigestAlg::Sha3_512), + ("1.3.36.3.3.1.2", Scheme::RsaPkcs1, DigestAlg::Ripemd160), + ("1.2.840.10045.4.1", Scheme::Ecdsa, DigestAlg::Sha1), + ("1.2.840.10045.4.3.1", Scheme::Ecdsa, DigestAlg::Sha224), + ("1.2.840.10045.4.3.2", Scheme::Ecdsa, DigestAlg::Sha256), + ("1.2.840.10045.4.3.3", Scheme::Ecdsa, DigestAlg::Sha384), + ("1.2.840.10045.4.3.4", Scheme::Ecdsa, DigestAlg::Sha512), + ("2.16.840.1.101.3.4.3.9", Scheme::Ecdsa, DigestAlg::Sha3_224), + ("2.16.840.1.101.3.4.3.10", Scheme::Ecdsa, DigestAlg::Sha3_256), + ("2.16.840.1.101.3.4.3.11", Scheme::Ecdsa, DigestAlg::Sha3_384), + ("2.16.840.1.101.3.4.3.12", Scheme::Ecdsa, DigestAlg::Sha3_512), + // BSI TR-03111 "plain" ECDSA (r ‖ s), as German signature cards write it. + ("0.4.0.127.0.7.1.1.4.1.1", Scheme::Ecdsa, DigestAlg::Sha1), + ("0.4.0.127.0.7.1.1.4.1.2", Scheme::Ecdsa, DigestAlg::Sha224), + ("0.4.0.127.0.7.1.1.4.1.3", Scheme::Ecdsa, DigestAlg::Sha256), + ("0.4.0.127.0.7.1.1.4.1.4", Scheme::Ecdsa, DigestAlg::Sha384), + ("0.4.0.127.0.7.1.1.4.1.5", Scheme::Ecdsa, DigestAlg::Sha512), + ("0.4.0.127.0.7.1.1.4.1.6", Scheme::Ecdsa, DigestAlg::Ripemd160), +]; + /// A signature `AlgorithmIdentifier`: the scheme and, when the identifier names one, the digest. pub fn signature_algorithm(alg: &Tlv<'_>) -> Result<(Scheme, Option), SignError> { let parts = alg.children()?; let o = parts.first().ok_or_else(|| SignError::Malformed("empty algorithm".into()))?.oid()?; + if let Some((_, scheme, digest)) = SIGNATURE_OIDS.iter().find(|(k, _, _)| *k == o) { + return Ok((*scheme, Some(*digest))); + } Ok(match o.as_str() { oid::RSA => (Scheme::RsaPkcs1, None), - oid::RSA_SHA1 => (Scheme::RsaPkcs1, Some(DigestAlg::Sha1)), - oid::RSA_SHA256 => (Scheme::RsaPkcs1, Some(DigestAlg::Sha256)), - oid::RSA_SHA384 => (Scheme::RsaPkcs1, Some(DigestAlg::Sha384)), - oid::RSA_SHA512 => (Scheme::RsaPkcs1, Some(DigestAlg::Sha512)), - oid::RSA_PSS => { - // RSASSA-PSS-params: [0] hashAlgorithm (default SHA-1). - let hash = parts - .get(1) - .and_then(|p| p.children().ok()) - .and_then(|c| c.into_iter().find(|t| t.tag == tag::ctx(0))) - .and_then(|h| h.inner().ok()) - .and_then(|a| a.children().ok()) - .and_then(|a| a.first().and_then(|o| o.oid().ok())) - .and_then(|o| DigestAlg::from_oid(&o)) - .unwrap_or(DigestAlg::Sha1); - (Scheme::RsaPss, Some(hash)) - } - oid::ECDSA_SHA1 => (Scheme::Ecdsa, Some(DigestAlg::Sha1)), - oid::ECDSA_SHA256 => (Scheme::Ecdsa, Some(DigestAlg::Sha256)), - oid::ECDSA_SHA384 => (Scheme::Ecdsa, Some(DigestAlg::Sha384)), - oid::ECDSA_SHA512 => (Scheme::Ecdsa, Some(DigestAlg::Sha512)), oid::EC => (Scheme::Ecdsa, None), + oid::ED25519 => (Scheme::Ed25519, None), + oid::RSA_PSS => { + let (hash, params) = pss_params(parts.get(1))?; + (Scheme::RsaPss(params), Some(hash)) + } other => return Err(SignError::Unsupported(format!("signature algorithm {other}"))), }) } +/// `RSASSA-PSS-params` (RFC 4055): `[0]` hash (default SHA-1), `[1]` mask generation function +/// (default MGF1 with SHA-1), `[2]` salt length (default 20). Absent parameters mean the defaults. +fn pss_params(params: Option<&Tlv<'_>>) -> Result<(DigestAlg, PssParams), SignError> { + let hash_of = |alg: &Tlv<'_>| -> Result { + let o = alg.children()?.first().ok_or_else(|| SignError::Malformed("PSS hash".into()))?.oid()?; + DigestAlg::from_oid(&o).ok_or_else(|| SignError::Unsupported(format!("digest {o}"))) + }; + let mut hash = DigestAlg::Sha1; + let mut mgf = DigestAlg::Sha1; + let mut salt_len = 20usize; + if let Some(p) = params.filter(|p| p.tag == tag::SEQUENCE) { + for field in p.children()? { + match field.tag { + t if t == tag::ctx(0) => hash = hash_of(&field.inner()?)?, + t if t == tag::ctx(1) => { + let m = field.inner()?.children()?; + if m.first().map(|o| o.oid()).transpose()?.as_deref() != Some(oid::MGF1) { + return Err(SignError::Unsupported("PSS mask generation function other than MGF1".into())); + } + mgf = hash_of(m.get(1).ok_or_else(|| SignError::Malformed("MGF1 hash".into()))?)?; + } + t if t == tag::ctx(2) => { + salt_len = usize::try_from(field.inner()?.u64()?).map_err(|_| SignError::Malformed("PSS salt length".into()))? + } + _ => {} + } + } + } + Ok((hash, PssParams { mgf, salt_len: Some(salt_len) })) +} + /// A public key from a `SubjectPublicKeyInfo`. #[derive(Clone, Debug, PartialEq, Eq)] pub enum PublicKey { - Rsa { n: Vec, e: Vec }, + Rsa { + n: Vec, + e: Vec, + }, P256(Vec), P384(Vec), + /// Verification only (no signing). + P521(Vec), + BrainpoolP256(Vec), + BrainpoolP384(Vec), + BrainpoolP512(Vec), + /// The 32-byte public key (RFC 8032). Signs the message itself, see [`Scheme::Ed25519`]. + Ed25519(Vec), + /// A key algorithm or curve PdfCraft can't check signatures with. The certificate still + /// parses (so its name and dates show); verifying with it gives [`SignError::Unsupported`]. + Unsupported { + what: String, + spki: Vec, + }, } impl PublicKey { @@ -143,7 +308,19 @@ impl PublicKey { pub fn key_bits(&self) -> Vec { match self { PublicKey::Rsa { n, e } => der::seq(&[&der::uint(n), &der::uint(e)]), - PublicKey::P256(bits) | PublicKey::P384(bits) => bits.clone(), + PublicKey::P256(bits) + | PublicKey::P384(bits) + | PublicKey::P521(bits) + | PublicKey::BrainpoolP256(bits) + | PublicKey::BrainpoolP384(bits) + | PublicKey::BrainpoolP512(bits) + | PublicKey::Ed25519(bits) => bits.clone(), + // The BIT STRING of the stored SubjectPublicKeyInfo (empty if it doesn't parse). + PublicKey::Unsupported { spki, .. } => Tlv::parse(spki) + .ok() + .and_then(|(t, _)| t.children().ok()) + .and_then(|c| c.get(1).and_then(|k| k.bits().ok().map(<[u8]>::to_vec))) + .unwrap_or_default(), } } @@ -154,6 +331,7 @@ impl PublicKey { let o = alg.first().ok_or_else(|| SignError::Malformed("key algorithm".into()))?.oid()?; let bits = key.bits()?; match o.as_str() { + oid::ED25519 => Ok(PublicKey::Ed25519(bits.to_vec())), oid::RSA => { let k = Tlv::parse_all(bits)?.children()?; let [n, e] = k.as_slice() else { return Err(SignError::Malformed("RSAPublicKey".into())) }; @@ -162,9 +340,13 @@ impl PublicKey { oid::EC => match alg.get(1).map(|c| c.oid()).transpose()?.as_deref() { Some(oid::P256) => Ok(PublicKey::P256(bits.to_vec())), Some(oid::P384) => Ok(PublicKey::P384(bits.to_vec())), - other => Err(SignError::Unsupported(format!("elliptic curve {}", other.unwrap_or("?")))), + Some(oid::P521) => Ok(PublicKey::P521(bits.to_vec())), + Some(oid::BRAINPOOL_P256) => Ok(PublicKey::BrainpoolP256(bits.to_vec())), + Some(oid::BRAINPOOL_P384) => Ok(PublicKey::BrainpoolP384(bits.to_vec())), + Some(oid::BRAINPOOL_P512) => Ok(PublicKey::BrainpoolP512(bits.to_vec())), + other => Ok(PublicKey::Unsupported { what: format!("elliptic curve {}", other.unwrap_or("?")), spki: spki.raw.to_vec() }), }, - other => Err(SignError::Unsupported(format!("public key algorithm {other}"))), + other => Ok(PublicKey::Unsupported { what: format!("public key algorithm {other}"), spki: spki.raw.to_vec() }), } } @@ -177,6 +359,12 @@ impl PublicKey { } PublicKey::P256(p) => der::seq(&[&der::seq(&[&der::oid(oid::EC), &der::oid(oid::P256)]), &der::bit_string(p)]), PublicKey::P384(p) => der::seq(&[&der::seq(&[&der::oid(oid::EC), &der::oid(oid::P384)]), &der::bit_string(p)]), + PublicKey::P521(p) => der::seq(&[&der::seq(&[&der::oid(oid::EC), &der::oid(oid::P521)]), &der::bit_string(p)]), + PublicKey::BrainpoolP256(p) => der::seq(&[&der::seq(&[&der::oid(oid::EC), &der::oid(oid::BRAINPOOL_P256)]), &der::bit_string(p)]), + PublicKey::BrainpoolP384(p) => der::seq(&[&der::seq(&[&der::oid(oid::EC), &der::oid(oid::BRAINPOOL_P384)]), &der::bit_string(p)]), + PublicKey::BrainpoolP512(p) => der::seq(&[&der::seq(&[&der::oid(oid::EC), &der::oid(oid::BRAINPOOL_P512)]), &der::bit_string(p)]), + PublicKey::Ed25519(p) => der::seq(&[&der::seq(&[&der::oid(oid::ED25519)]), &der::bit_string(p)]), + PublicKey::Unsupported { spki, .. } => spki.clone(), } } @@ -186,43 +374,118 @@ impl PublicKey { PublicKey::Rsa { n, .. } => format!("RSA {}-bit", n.len() * 8), PublicKey::P256(_) => "ECDSA P-256".into(), PublicKey::P384(_) => "ECDSA P-384".into(), + PublicKey::P521(_) => "ECDSA P-521".into(), + PublicKey::BrainpoolP256(_) => "ECDSA brainpoolP256r1".into(), + PublicKey::BrainpoolP384(_) => "ECDSA brainpoolP384r1".into(), + PublicKey::BrainpoolP512(_) => "ECDSA brainpoolP512r1".into(), + PublicKey::Ed25519(_) => "Ed25519".into(), + PublicKey::Unsupported { what, .. } => format!("unsupported ({what})"), } } - /// Check `sig` over a message whose digest (with `alg`) is `digest`. + /// Check `sig` over a message whose digest (with `alg`) is `digest`. Encodings are read + /// leniently (see [`crate::rsa_pad`], [`ecdsa_rs`]); the cryptographic check never is. + /// `Err(Unsupported)` means "can't tell", as opposed to `Ok(false)`: "doesn't match". pub fn verify(&self, scheme: Scheme, alg: DigestAlg, digest: &[u8], sig: &[u8]) -> Result { + self.verify_noting(scheme, alg, digest, sig, &mut Vec::new()) + } + + /// [`PublicKey::verify`], noting in `notes` each encoding irregularity it tolerated. Only + /// meaningful when it returns `Ok(true)`: a signature that doesn't verify may leave notes + /// about how far it got. + pub fn verify_noting(&self, scheme: Scheme, alg: DigestAlg, digest: &[u8], sig: &[u8], notes: &mut Vec<&'static str>) -> Result { match (self, scheme) { - (PublicKey::Rsa { n, e }, Scheme::RsaPkcs1 | Scheme::RsaPss) => { - use rsa::{BoxedUint, Pkcs1v15Sign, RsaPublicKey, pss::Pss}; - let key = RsaPublicKey::new(BoxedUint::from_be_slice_vartime(n), BoxedUint::from_be_slice_vartime(e)) - .map_err(|e| SignError::Malformed(format!("RSA key: {e}")))?; - let ok = match (scheme, alg) { - (Scheme::RsaPkcs1, DigestAlg::Sha1) => key.verify(Pkcs1v15Sign::new::(), digest, sig), - (Scheme::RsaPkcs1, DigestAlg::Sha256) => key.verify(Pkcs1v15Sign::new::(), digest, sig), - (Scheme::RsaPkcs1, DigestAlg::Sha384) => key.verify(Pkcs1v15Sign::new::(), digest, sig), - (Scheme::RsaPkcs1, DigestAlg::Sha512) => key.verify(Pkcs1v15Sign::new::(), digest, sig), - (_, DigestAlg::Sha1) => key.verify(Pss::::new(), digest, sig), - (_, DigestAlg::Sha256) => key.verify(Pss::::new(), digest, sig), - (_, DigestAlg::Sha384) => key.verify(Pss::::new(), digest, sig), - (_, DigestAlg::Sha512) => key.verify(Pss::::new(), digest, sig), - }; - Ok(ok.is_ok()) - } + (PublicKey::Unsupported { what, .. }, _) => Err(SignError::Unsupported(what.clone())), + (PublicKey::Rsa { n, e }, Scheme::RsaPkcs1) => crate::rsa_pad::pkcs1_verify(n, e, alg, digest, sig, notes), + (PublicKey::Rsa { n, e }, Scheme::RsaPss(params)) => crate::rsa_pad::pss_verify(n, e, alg, params, digest, sig, notes), + (PublicKey::Ed25519(_), _) => Err(SignError::Unsupported("Ed25519 signs the message, not a digest".into())), (PublicKey::P256(p), Scheme::Ecdsa) => { use p256::ecdsa::signature::hazmat::PrehashVerifier; let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(p).map_err(|_| SignError::Malformed("P-256 key".into()))?; - let Ok(s) = p256::ecdsa::Signature::from_der(sig) else { return Ok(false) }; + let Some(s) = ecdsa_rs(sig, 32, notes).and_then(|rs| p256::ecdsa::Signature::from_slice(&rs).ok()) else { return Ok(false) }; Ok(key.verify_prehash(digest, &s).is_ok()) } (PublicKey::P384(p), Scheme::Ecdsa) => { use p384::ecdsa::signature::hazmat::PrehashVerifier; let key = p384::ecdsa::VerifyingKey::from_sec1_bytes(p).map_err(|_| SignError::Malformed("P-384 key".into()))?; - let Ok(s) = p384::ecdsa::Signature::from_der(sig) else { return Ok(false) }; + let Some(s) = ecdsa_rs(sig, 48, notes).and_then(|rs| p384::ecdsa::Signature::from_slice(&rs).ok()) else { return Ok(false) }; Ok(key.verify_prehash(digest, &s).is_ok()) } + (PublicKey::P521(p), Scheme::Ecdsa) => { + use p521::ecdsa::signature::hazmat::PrehashVerifier; + let key = p521::ecdsa::VerifyingKey::from_sec1_bytes(p).map_err(|_| SignError::Malformed("P-521 key".into()))?; + let Some(s) = ecdsa_rs(sig, 66, notes).and_then(|rs| p521::ecdsa::Signature::from_slice(&rs).ok()) else { return Ok(false) }; + Ok(key.verify_prehash(digest, &s).is_ok()) + } + (PublicKey::BrainpoolP256(p), Scheme::Ecdsa) => { + use ecdsa::signature::hazmat::PrehashVerifier; + type Curve = bp256::BrainpoolP256r1; + let key = ecdsa::VerifyingKey::::from_sec1_bytes(p).map_err(|_| SignError::Malformed("brainpoolP256r1 key".into()))?; + let Some(s) = ecdsa_rs(sig, 32, notes).and_then(|rs| ecdsa::Signature::::from_slice(&rs).ok()) else { return Ok(false) }; + Ok(key.verify_prehash(digest, &s).is_ok()) + } + (PublicKey::BrainpoolP384(p), Scheme::Ecdsa) => { + use ecdsa::signature::hazmat::PrehashVerifier; + type Curve = bp384::BrainpoolP384r1; + let key = ecdsa::VerifyingKey::::from_sec1_bytes(p).map_err(|_| SignError::Malformed("brainpoolP384r1 key".into()))?; + let Some(s) = ecdsa_rs(sig, 48, notes).and_then(|rs| ecdsa::Signature::::from_slice(&rs).ok()) else { return Ok(false) }; + Ok(key.verify_prehash(digest, &s).is_ok()) + } + (PublicKey::BrainpoolP512(p), Scheme::Ecdsa) => { + let Some(rs) = ecdsa_rs(sig, 64, notes) else { return Ok(false) }; + crate::ec512::verify(p, digest, &rs).ok_or_else(|| SignError::Malformed("brainpoolP512r1 key or signature".into())) + } _ => Ok(false), } } + + /// Check `sig` over `msg` itself, for the schemes that don't sign a digest (Ed25519, + /// RFC 8419: with signed attributes, the message is their DER encoding). + pub fn verify_message(&self, scheme: Scheme, msg: &[u8], sig: &[u8]) -> Result { + match (self, scheme) { + (PublicKey::Unsupported { what, .. }, _) => Err(SignError::Unsupported(what.clone())), + (PublicKey::Ed25519(k), Scheme::Ed25519) => { + let bytes: [u8; 32] = k.as_slice().try_into().map_err(|_| SignError::Malformed("Ed25519 key".into()))?; + let key = ed25519_dalek::VerifyingKey::from_bytes(&bytes).map_err(|_| SignError::Malformed("Ed25519 key".into()))?; + let Ok(sig) = ed25519_dalek::Signature::from_slice(sig) else { return Ok(false) }; + Ok(key.verify_strict(msg, &sig).is_ok()) + } + _ => Ok(false), + } + } +} + +/// An ECDSA signature as the fixed-width `r ‖ s` the curve crates read: from the standard DER +/// `SEQUENCE { INTEGER r, INTEGER s }` (also with integers that have redundant leading zeros or +/// the sign byte missing), or already raw `r ‖ s` as PKCS #11 tokens return it. `notes` gets an +/// entry when the signature was not canonical DER. +fn ecdsa_rs(sig: &[u8], field_len: usize, notes: &mut Vec<&'static str>) -> Option> { + let pad = |int: &[u8]| -> Option> { + let int = int.get(int.iter().position(|b| *b != 0).unwrap_or(int.len())..).unwrap_or_default(); + let zeros = field_len.checked_sub(int.len())?; + Some(std::iter::repeat_n(0u8, zeros).chain(int.iter().copied()).collect()) + }; + if let Ok((t, rest)) = Tlv::parse(sig) + && t.tag == tag::SEQUENCE + && rest.iter().all(|b| *b == 0) + && let Ok(parts) = t.children() + && let [r, s] = parts.as_slice() + && r.tag == tag::INTEGER + && s.tag == tag::INTEGER + { + // Canonical DER: nothing after the sequence, minimal positive integers. + let minimal = |i: &Tlv<'_>| i.value.first().is_some_and(|b| b & 0x80 == 0) && !matches!(i.value, [0, b, ..] if b & 0x80 == 0); + if !rest.is_empty() || !minimal(r) || !minimal(s) { + notes.push("The ECDSA signature is not in canonical DER (trailing bytes, padding or a missing sign byte); it was read anyway."); + } + let mut out = pad(r.value)?; + out.extend(pad(s.value)?); + return Some(out); + } + (sig.len() == field_len * 2).then(|| { + notes.push("The ECDSA signature is the raw r and s values instead of a DER sequence; it was read anyway."); + sig.to_vec() + }) } enum Inner { @@ -370,16 +633,14 @@ impl PrivateKey { /// The signature `AlgorithmIdentifier` this key writes with `alg`. pub fn signature_algorithm(&self, alg: DigestAlg) -> Vec { - match (&self.public, alg) { - (PublicKey::Rsa { .. }, DigestAlg::Sha1) => der::algorithm(oid::RSA_SHA1, Some(&der::null())), - (PublicKey::Rsa { .. }, DigestAlg::Sha256) => der::algorithm(oid::RSA_SHA256, Some(&der::null())), - (PublicKey::Rsa { .. }, DigestAlg::Sha384) => der::algorithm(oid::RSA_SHA384, Some(&der::null())), - (PublicKey::Rsa { .. }, DigestAlg::Sha512) => der::algorithm(oid::RSA_SHA512, Some(&der::null())), - (_, DigestAlg::Sha1) => der::algorithm(oid::ECDSA_SHA1, None), - (_, DigestAlg::Sha256) => der::algorithm(oid::ECDSA_SHA256, None), - (_, DigestAlg::Sha384) => der::algorithm(oid::ECDSA_SHA384, None), - (_, DigestAlg::Sha512) => der::algorithm(oid::ECDSA_SHA512, None), - } + let rsa = matches!(self.public, PublicKey::Rsa { .. }); + let scheme = if rsa { Scheme::RsaPkcs1 } else { Scheme::Ecdsa }; + // `sign` only writes SHA-256/384/512, which the table has for both schemes. + let oid = SIGNATURE_OIDS + .iter() + .find(|(_, s, d)| *s == scheme && *d == alg) + .map_or(if rsa { "1.2.840.113549.1.1.11" } else { "1.2.840.10045.4.3.2" }, |(o, _, _)| *o); + der::algorithm(oid, rsa.then(der::null).as_deref()) } /// The digest a signature with this key should use: SHA-256, or SHA-384 for P-384. @@ -392,8 +653,8 @@ impl PrivateKey { /// Sign `msg` (hashed with `alg`). New signatures never use SHA-1. pub fn sign(&self, alg: DigestAlg, msg: &[u8]) -> Result, SignError> { - if alg == DigestAlg::Sha1 { - return Err(SignError::Unsupported("SHA-1 for new signatures".into())); + if !matches!(alg, DigestAlg::Sha256 | DigestAlg::Sha384 | DigestAlg::Sha512) { + return Err(SignError::Unsupported(format!("{} for new signatures", alg.name()))); } match &self.inner { #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/sign/src/lib.rs b/crates/sign/src/lib.rs index b35f8ae9..9cb57c03 100644 --- a/crates/sign/src/lib.rs +++ b/crates/sign/src/lib.rs @@ -10,12 +10,14 @@ pub mod cms; pub mod der; pub mod dss; +mod ec512; #[cfg(target_os = "macos")] pub mod keychain; pub mod keys; pub mod pdf; pub mod pkcs12; pub mod revocation; +mod rsa_pad; pub mod timestamp; #[cfg(target_os = "windows")] pub mod windows; diff --git a/crates/sign/src/pdf.rs b/crates/sign/src/pdf.rs index d2925baa..ec566c73 100644 --- a/crates/sign/src/pdf.rs +++ b/crates/sign/src/pdf.rs @@ -528,12 +528,18 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf info.signed_len = covered; // The signed revision's number: its cross-reference sections (1 for a reconstructed file). info.revision = cache.revision(bytes, covered).map_or(1, |d| d.revisions().len().max(1)); + // What can't be checked is unknown, not invalid: only data that is wrong or fails its check + // is "invalid". + let unsupported = |info: &mut SignatureInfo, why: &str| { + info.status = Status::Unknown; + info.details.push(format!("PdfCraft can't check this signature yet: {why}.")); + }; if info.sub_filter.as_deref() == Some("adbe.x509.rsa_sha1") { - info.details.push("This signature uses the legacy adbe.x509.rsa_sha1 format, which PdfCraft does not validate yet.".into()); - return; + return validate_x509_rsa_sha1(doc, bytes, trust, v, info, cache, (l0, o1, covered), &contents); } let sd = match SignedData::parse(&contents) { Ok(sd) => sd, + Err(SignError::Unsupported(e)) => return unsupported(info, &e), Err(e) => return invalid(info, &format!("The signature could not be read ({e}).")), }; let s = &sd.signer; @@ -587,12 +593,42 @@ fn validate_into(doc: &Document, bytes: &[u8], trust: &TrustStore, v: &Dict, inf }; info.algorithm = Some(format!("{} with {}", cert.public_key.describe(), s.scheme_digest.unwrap_or(s.digest).name())); info.signer = Some(cert.display_name()); - if !sd.verify_signature(&cert, &content_digest) { - info.certificate = Some(cert); - return invalid(info, "The signature value does not match the signer's certificate: the signature is corrupt."); + let mut notes = Vec::new(); + match sd.verify_signature_noting(&cert, &content_digest, &mut notes) { + Ok(true) => info.details.extend(notes.into_iter().map(String::from)), + Ok(false) => { + info.certificate = Some(cert); + return invalid(info, "The signature value does not match the signer's certificate: the signature is corrupt."); + } + Err(SignError::Unsupported(e)) => { + info.certificate = Some(cert); + return unsupported(info, &e); + } + Err(e) => { + info.certificate = Some(cert); + return invalid(info, &format!("The signature could not be checked ({e}).")); + } } - info.signing_time = s.signing_time.or_else(|| info.date.as_deref().and_then(Time::from_pdf)); - let mut pool = sd.certificates.clone(); + finish_validation(doc, bytes, trust, info, cache, covered, cert, &sd.certificates, s.signing_time, unverified_time); +} + +/// The verdict once the signature value has been checked: chain and trust, the signing time +/// against the certificate's validity, and what later revisions changed. +#[allow(clippy::too_many_arguments)] +fn finish_validation( + doc: &Document, + bytes: &[u8], + trust: &TrustStore, + info: &mut SignatureInfo, + cache: &DigestCache, + covered: usize, + cert: Certificate, + embedded: &[Certificate], + cms_time: Option