Skip to content

Commit 72f88ca

Browse files
committed
Mon Dec 15 00:06:00 PST 2025
1 parent 10eca9b commit 72f88ca

19 files changed

Lines changed: 218 additions & 66 deletions

File tree

.cargo/config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[target.wasm32-unknown-unknown]
2-
# Disable hardening flags that don't work with WASM
2+
# Configure WASM target with appropriate stack size
33
rustflags = [
44
"-C", "link-arg=-zstack-size=4194304",
55
]

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ tracing = "0.1"
2929
# Serialization
3030
serde = { version = "1", features = ["derive"] }
3131
serde_json = "1"
32-
bilrost = "0.1010"
33-
bilrost-derive = "0.1010"
32+
bilrost = "0.1010.2"
33+
bilrost-derive = "0.1010.2"
3434

3535
# Hashing
3636
sha2 = "0.10"

README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# pdf-sign
22

3-
A lightweight, modern PDF signing utility written in Rust that supports both **OpenPGP (GPG)** and **Sigstore (keyless OIDC)** signatures. It appends cryptographic signatures directly to PDFs, making it easy to sign and verify documents without heavyweight PDF signing stacks, making your PDFs authentic and tamper-proof.
3+
PDF signing utility written in Rust that supports both **OpenPGP (GPG)** and **Sigstore (keyless OIDC)** signatures, appending cryptographic signatures directly to PDFs, making it easy to sign and verify documents without heavyweight PDF signing stacks, making your PDFs authentic, tamper-proof, while being fully compatible with regular readers.
44

55
[![asciicast](https://asciinema.org/a/JXR1crpqtcbMT1DIhD3dzXFB9.svg)](https://asciinema.org/a/JXR1crpqtcbMT1DIhD3dzXFB9)
66

@@ -280,11 +280,20 @@ Versioned bilrost-encoded blocks with digest binding:
280280

281281
## Environment Variables
282282

283+
### General
284+
283285
* `GNUPGHOME`: GPG keybox location (default: `~/.gnupg`)
284286
* `RUST_LOG`: Tracing verbosity (e.g., `RUST_LOG=debug`)
285-
* Output channels:
286-
* `stderr`: Progress, status, errors
287-
* `stdout`: Result paths (sign) or "OK" (verify) for pipelines
287+
288+
### Sigstore-Specific
289+
290+
* `SIGSTORE_IDENTITY_TOKEN`: Pre-obtained OIDC identity token (JWT) for CI workflows (bypasses interactive browser flow)
291+
* `OIDC_REDIRECT_PORT`: Local port for OIDC callback listener (default: OS-assigned dynamic port). Set to a fixed port (e.g., `8080`) if you need predictable port forwarding or firewall rules
292+
293+
### Output Channels
294+
295+
* `stderr`: Progress, status, errors
296+
* `stdout`: Result paths (sign) or "OK" (verify) for pipelines
288297

289298
## Examples
290299

crates/cli/src/cli.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ pub enum Commands {
6666
oidc_client_secret: Option<String>,
6767

6868
/// Identity token (JWT) for non-interactive signing (Sigstore backend, CI mode)
69+
/// Can also be set via SIGSTORE_IDENTITY_TOKEN environment variable
6970
#[arg(long)]
7071
identity_token: Option<String>,
7172

crates/cli/src/commands.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,18 @@ pub fn verify_pdf(
120120
let cert_identity_matcher = match (certificate_identity, certificate_identity_regexp) {
121121
(Some(exact), None) => Some(CertificateIdentityMatcher::Exact(exact)),
122122
(None, Some(re)) => Some(CertificateIdentityMatcher::Regexp(re)),
123+
(Some(_), Some(_)) => {
124+
bail!("Cannot specify both --certificate-identity and --certificate-identity-regexp")
125+
}
123126
_ => None,
124127
};
125128

126129
let cert_issuer_matcher = match (certificate_oidc_issuer, certificate_oidc_issuer_regexp) {
127130
(Some(exact), None) => Some(OidcIssuerMatcher::Exact(exact)),
128131
(None, Some(re)) => Some(OidcIssuerMatcher::Regexp(re)),
132+
(Some(_), Some(_)) => {
133+
bail!("Cannot specify both --certificate-oidc-issuer and --certificate-oidc-issuer-regexp")
134+
}
129135
_ => None,
130136
};
131137

crates/cli/src/sign.rs

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,6 @@ pub fn sign_gpg(
5858
let (clean_pdf, suffix) = split_pdf(&pdf_data)?;
5959
let existing_blocks = parse_suffix_blocks(suffix)?;
6060

61-
let existing_pgp_sigs: Vec<_> = existing_blocks
62-
.iter()
63-
.filter_map(|b| match b {
64-
SuffixBlock::OpenPgpSig(data) => Some(data.clone()),
65-
_ => None,
66-
})
67-
.collect();
68-
6961
let cert = pdf_sign_gpg::load_cert(&key_spec)?;
7062

7163
let fingerprint = cert.fingerprint();
@@ -132,11 +124,10 @@ pub fn sign_gpg(
132124
out.write_all(clean_pdf)?;
133125
out.write_all(b"\n")?;
134126

135-
for sig in &existing_pgp_sigs {
136-
out.write_all(sig)?;
137-
if !sig.ends_with(b"\n") {
138-
out.write_all(b"\n")?;
139-
}
127+
// Preserve existing signature blocks (OpenPGP and Sigstore) when adding a new GPG signature.
128+
for block in &existing_blocks {
129+
let encoded = encode_suffix_block(block);
130+
out.write_all(&encoded)?;
140131
}
141132
out.write_all(&sign_result.signature_data)?;
142133
out.flush()?;

crates/cli/src/util.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
pub fn format_bytes(bytes: usize) -> String {
44
const KB: f64 = 1024.0;
55
const MB: f64 = KB * 1024.0;
6+
// Threshold chosen to avoid displaying "1024.0 KB" due to rounding.
7+
// At 1_048_524 bytes (1023.99 KB), we still show KB.
8+
// At 1_048_525 bytes and above, we switch to MB display.
69
const KB_TO_MB_ROUNDING_THRESHOLD: usize = 1_048_525;
710

811
if bytes < 1024 {

crates/core/src/digest.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ mod tests {
101101
let data = b"hello";
102102
let digest = compute_digest(DigestAlgorithm::Sha512, data);
103103
assert_eq!(digest.len(), 64);
104+
// Known SHA-512 of "hello"
105+
let expected_hex = "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043";
106+
let expected: Vec<u8> = (0..64)
107+
.map(|i| u8::from_str_radix(&expected_hex[i * 2..i * 2 + 2], 16).unwrap())
108+
.collect();
109+
assert_eq!(digest, expected);
104110
}
105111

106112
#[test]

crates/core/src/pdf.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,10 @@ mod tests {
4646
assert_eq!(clean, b"%%PDF-1.4\ncontent%%EOF");
4747
assert_eq!(suffix, b"suffix");
4848
}
49+
50+
#[test]
51+
fn errors_when_no_eof_marker() {
52+
let invalid = b"%%PDF-1.4\n...content...";
53+
assert!(find_eof_offset(invalid).is_err());
54+
}
4955
}

crates/gpg/src/challenge.rs

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ pub fn apply_response(
117117
let signature_data = signature_armored.as_bytes().to_vec();
118118

119119
// Validate signature cryptographically
120-
validate_response(challenge, &signature_data)?;
120+
validate_response(challenge, &signature_data, cert)?;
121121

122122
let fingerprint = cert.fingerprint().to_string();
123123
let uids: Vec<String> = cert
@@ -143,7 +143,7 @@ pub fn apply_response(
143143
/// This function cryptographically verifies that the signature was
144144
/// created for the challenge's data.
145145
#[tracing::instrument(skip(challenge, signature))]
146-
pub fn validate_response(challenge: &Challenge, signature: &[u8]) -> Result<()> {
146+
pub fn validate_response(challenge: &Challenge, signature: &[u8], cert: &Cert) -> Result<()> {
147147
// Parse the signature
148148
let signature_reader = armor::Reader::from_bytes(
149149
signature,
@@ -154,22 +154,42 @@ pub fn validate_response(challenge: &Challenge, signature: &[u8]) -> Result<()>
154154
let policy = StandardPolicy::new();
155155

156156
// Helper for verification
157-
struct Helper;
158-
impl VerificationHelper for Helper {
157+
struct Helper<'a> {
158+
cert: &'a Cert,
159+
}
160+
161+
impl VerificationHelper for Helper<'_> {
159162
fn get_certs(&mut self, _ids: &[openpgp::KeyHandle]) -> openpgp::Result<Vec<Cert>> {
160-
// We don't verify the cert here, just check signature structure
161-
Ok(Vec::new())
163+
Ok(vec![self.cert.clone()])
162164
}
163165

164-
fn check(&mut self, _structure: MessageStructure) -> openpgp::Result<()> {
165-
// Basic structure check only
166-
Ok(())
166+
fn check(&mut self, structure: MessageStructure) -> openpgp::Result<()> {
167+
let mut has_valid_signature = false;
168+
for layer in structure {
169+
if let MessageLayer::SignatureGroup { results } = layer {
170+
for result in results {
171+
if result.is_ok() {
172+
has_valid_signature = true;
173+
break;
174+
}
175+
}
176+
}
177+
}
178+
179+
if has_valid_signature {
180+
Ok(())
181+
} else {
182+
Err(openpgp::Error::InvalidOperation("No valid signature".into()).into())
183+
}
167184
}
168185
}
169186

170187
// Try to parse as a detached signature
171-
let mut verifier =
172-
DetachedVerifierBuilder::from_reader(signature_reader)?.with_policy(&policy, None, Helper)?;
188+
let mut verifier = DetachedVerifierBuilder::from_reader(signature_reader)?.with_policy(
189+
&policy,
190+
None,
191+
Helper { cert },
192+
)?;
173193

174194
// Verify against challenge data
175195
verifier

0 commit comments

Comments
 (0)