Skip to content

Commit e618fb0

Browse files
committed
Sun Dec 14 21:03:35 PST 2025
1 parent da88ee4 commit e618fb0

11 files changed

Lines changed: 306 additions & 76 deletions

File tree

.github/workflows/ci.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,38 @@ jobs:
5757
name: pdf-sign-${{ matrix.name }}
5858
path: dist/*
5959

60+
shell:
61+
name: Dev Shell (${{ matrix.name }})
62+
runs-on: ${{ matrix.runs_on }}
63+
strategy:
64+
fail-fast: false
65+
matrix:
66+
include:
67+
- name: linux-amd64
68+
runs_on: ubuntu-24.04
69+
- name: linux-arm64
70+
runs_on: ubuntu-24.04-arm
71+
- name: macos-arm64
72+
runs_on: macos-26
73+
74+
steps:
75+
- name: Checkout
76+
uses: actions/checkout@v6
77+
78+
- name: Install Nix
79+
uses: cachix/install-nix-action@v31
80+
with:
81+
extra_nix_config: |
82+
accept-flake-config = true
83+
84+
- name: Cachix
85+
uses: cachix/cachix-action@v16
86+
with:
87+
name: pdf-sign
88+
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
89+
skipPush: ${{ github.event_name == 'pull_request' }}
90+
91+
- name: Build dev shell
92+
run: |
93+
nix develop --command true
6094

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
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.
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.
4+
5+
[![asciicast](https://asciinema.org/a/JXR1crpqtcbMT1DIhD3dzXFB9.svg)](https://asciinema.org/a/JXR1crpqtcbMT1DIhD3dzXFB9)
46

57
## Why pdf-sign?
68

9+
With `pdf-sign`, anyone can sign a PDF using their existing Google, Microsoft, or GitHub account – no cryptographic keys to generate, store, or manage. For power users and security-conscious workflows, it also supports GPG with full hardware key (YubiKey/smartcard) integration. Whether you're a huge company automating signatures, or just need to sign a contract, `pdf-sign` gets out of your way.
10+
711
Many "enterprise PDF signing" solutions require a full **CMS/PKCS#7** / **X.509 PKI** toolchain (certificate chains, policy constraints, CRL/OCSP revocation, time-stamping/TSAs) plus PDF-form machinery to produce **PAdES** signatures. Those stacks are powerful, but complex to configure, audit, and automate.
812

913
`pdf-sign` intentionally stays minimal and scriptable:

crates/cli/src/commands.rs

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -117,14 +117,6 @@ pub fn verify_pdf(
117117
|| certificate_oidc_issuer.is_some()
118118
|| certificate_oidc_issuer_regexp.is_some();
119119

120-
if !has_sigstore_constraints {
121-
bail!(
122-
"Sigstore signatures found, but no verification policy provided.\n\
123-
Please specify --certificate-identity and --certificate-oidc-issuer\n\
124-
(or their regexp variants) to verify Sigstore signatures."
125-
);
126-
}
127-
128120
let cert_identity_matcher = match (certificate_identity, certificate_identity_regexp) {
129121
(Some(exact), None) => Some(CertificateIdentityMatcher::Exact(exact)),
130122
(None, Some(re)) => Some(CertificateIdentityMatcher::Regexp(re)),
@@ -142,11 +134,23 @@ pub fn verify_pdf(
142134
certificate_oidc_issuer: cert_issuer_matcher,
143135
};
144136

145-
let options = SigstoreVerifyOptions { policy, offline };
146-
147137
let rt = tokio::runtime::Runtime::new()?;
148138

149139
for bundle_block in &sigstore_blocks {
140+
// If the user didn't provide an explicit policy, use the embedded
141+
// identity/issuer from the Sigstore bundle itself.
142+
let options = if has_sigstore_constraints {
143+
let policy = policy.clone();
144+
SigstoreVerifyOptions { policy, offline }
145+
} else {
146+
let (id, iss) = pdf_sign_sigstore::verify::extract_identity_from_block(bundle_block)?;
147+
let policy = VerifyPolicy {
148+
certificate_identity: Some(CertificateIdentityMatcher::Exact(id)),
149+
certificate_oidc_issuer: Some(OidcIssuerMatcher::Exact(iss)),
150+
};
151+
SigstoreVerifyOptions { policy, offline }
152+
};
153+
150154
let result = rt.block_on(verify_blob(pdf_data, bundle_block, &options))?;
151155
sigstore_verified.push(result);
152156
}

crates/sigstore/src/verify.rs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ pub struct VerifyOptions {
3333
pub offline: bool,
3434
}
3535

36+
/// Extract identity + issuer from the embedded Sigstore bundle block.
37+
///
38+
/// This can be used to show users what values to pass as
39+
/// `--certificate-identity` and `--certificate-oidc-issuer` for pinned
40+
/// verification.
41+
pub fn extract_identity_from_block(bundle_block: &SigstoreBundleBlock) -> Result<(String, String)> {
42+
let bundle: sigstore::bundle::Bundle = serde_json::from_slice(&bundle_block.bundle_json)
43+
.context("Failed to parse Sigstore bundle JSON")?;
44+
extract_identity_from_bundle(&bundle)
45+
}
46+
3647
/// Verify a Sigstore bundle block against the given data.
3748
///
3849
/// Returns verified signature information on success.
@@ -238,10 +249,19 @@ fn extract_identity_from_bundle(bundle: &sigstore::bundle::Bundle) -> Result<(St
238249
.extensions
239250
.as_ref()
240251
.and_then(|exts| exts.iter().find(|ext| ext.extn_id == issuer_oid))
241-
.and_then(|ext| String::from_utf8(ext.extn_value.clone().into_bytes()).ok())
252+
.and_then(|ext| {
253+
let s = String::from_utf8(ext.extn_value.clone().into_bytes()).ok()?;
254+
Some(
255+
s.trim_matches(|c: char| c.is_whitespace() || c == '\0')
256+
.to_string(),
257+
)
258+
})
242259
.unwrap_or_else(|| "unknown".to_string());
243260

244-
Ok((cert_identity, cert_issuer))
261+
Ok((
262+
cert_identity.trim().to_string(),
263+
cert_issuer.trim().to_string(),
264+
))
245265
}
246266

247267
/// Extract display-friendly bundle info (for output).
@@ -285,10 +305,15 @@ fn extract_bundle_info(bundle: &sigstore::bundle::Bundle) -> Result<(String, Str
285305
let san = SubjectAltName::from_der(san_ext.extn_value.as_bytes())
286306
.context("Failed to parse SAN extension")?;
287307

308+
use x509_cert::ext::pkix::name::GeneralName;
288309
let cert_identity = san
289310
.0
290-
.first()
291-
.map(|name| format!("{:?}", name))
311+
.iter()
312+
.find_map(|name| match name {
313+
GeneralName::Rfc822Name(email) => Some(email.to_string()),
314+
GeneralName::UniformResourceIdentifier(uri) => Some(uri.to_string()),
315+
_ => None,
316+
})
292317
.unwrap_or_else(|| "unknown".to_string());
293318

294319
// Extract issuer from OIDC Issuer extension (OID 1.3.6.1.4.1.57264.1.1)
@@ -303,5 +328,10 @@ fn extract_bundle_info(bundle: &sigstore::bundle::Bundle) -> Result<(String, Str
303328
.and_then(|ext| String::from_utf8(ext.extn_value.clone().into_bytes()).ok())
304329
.unwrap_or_else(|| "unknown".to_string());
305330

306-
Ok((cert_identity, cert_issuer))
331+
Ok((
332+
cert_identity.trim().to_string(),
333+
cert_issuer
334+
.trim_matches(|c: char| c.is_whitespace() || c == '\0')
335+
.to_string(),
336+
))
307337
}

flake.nix

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,19 +51,26 @@
5151
;
5252
};
5353

54-
packages = {
55-
default = package.pdfSign;
56-
pdf-sign = package.pdfSign;
57-
pdf-sign-wasm =
58-
(import ./nix/wasm.nix {
54+
packages =
55+
let
56+
autocast = import ./nix/demo.nix {
5957
inherit pkgs craneLib;
6058
lib = pkgs.lib;
61-
}).pdf-sign-wasm;
62-
};
59+
};
60+
in
61+
{
62+
default = package.pdfSign;
63+
pdf-sign = package.pdfSign;
64+
inherit autocast;
65+
};
6366

6467
devShells.default = import ./nix/shell.nix {
6568
inherit pkgs;
6669
pdfSign = package.pdfSign;
70+
autocast = import ./nix/demo.nix {
71+
inherit pkgs craneLib;
72+
lib = pkgs.lib;
73+
};
6774
pre-commit-check = import ./nix/git-hooks.nix {
6875
inherit git-hooks system pkgs;
6976
src = ./.;

nix/demo.nix

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
pkgs,
3+
craneLib,
4+
lib,
5+
}:
6+
let
7+
autocastSrc = pkgs.fetchFromGitHub {
8+
owner = "k9withabone";
9+
repo = "autocast";
10+
rev = "v0.1.0";
11+
hash = "sha256-F8RTXcBe3eqzwR48CcU1DpqRzhMBztGIXJrJsQdjgks=";
12+
};
13+
in
14+
craneLib.buildPackage {
15+
pname = "autocast";
16+
src = autocastSrc;
17+
strictDeps = true;
18+
19+
meta = with lib; {
20+
description = "Automate terminal demos";
21+
homepage = "https://github.com/k9withabone/autocast";
22+
mainProgram = "autocast";
23+
};
24+
}

nix/shell.nix

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
pkgs,
33
pdfSign,
4+
autocast,
45
pre-commit-check,
56
}:
67
pkgs.mkShell {
@@ -27,6 +28,10 @@ pkgs.mkShell {
2728
# Web development
2829
bun
2930
nodejs_24
31+
32+
# Demo tools
33+
asciinema
34+
autocast
3035
]
3136
++ pre-commit-check.enabledPackages;
3237
}

nix/wasm.nix

Lines changed: 0 additions & 48 deletions
This file was deleted.

scripts/autocast.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
# Preparation + runner for tools/autocast.yaml.
3+
#
4+
# Run this script (e.g. from fish) to generate a recording without
5+
# polluting the demo with setup/teardown “time segments”.
6+
7+
set -Eeuo pipefail
8+
9+
_pdf_sign_demo_mktemp_dir() {
10+
mktemp -d 2>/dev/null || mktemp -d -t pdf-sign-demo
11+
}
12+
13+
_pdf_sign_demo_cleanup() {
14+
{
15+
if [[ -n "${_PDF_SIGN_DEMO_DIR:-}" ]]; then
16+
rm -rf "${_PDF_SIGN_DEMO_DIR}" 2>/dev/null || true
17+
fi
18+
} >/dev/null 2>&1 || true
19+
}
20+
21+
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
22+
ROOT_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)"
23+
24+
YAML="${ROOT_DIR}/scripts/autocast.yaml"
25+
OUT="${1:-${ROOT_DIR}/demo.rec}"
26+
27+
_PDF_SIGN_DEMO_DIR="$(_pdf_sign_demo_mktemp_dir)"
28+
29+
# Isolated keychain.
30+
export GNUPGHOME="${_PDF_SIGN_DEMO_DIR}/gnupg"
31+
mkdir -p "$GNUPGHOME"
32+
chmod 700 "$GNUPGHOME"
33+
34+
# Make gpg-agent happy / non-interactive.
35+
export GPG_TTY=/dev/null
36+
export LANG=C
37+
export TERM=xterm-256color
38+
39+
# Start agent (best-effort).
40+
gpgconf --launch gpg-agent >/dev/null 2>&1 || true
41+
42+
# Ephemeral demo key in the isolated keybox.
43+
gpg --batch --pinentry-mode loopback --passphrase "" \
44+
--quick-generate-key "Demo <demo@example.invalid>" default default never \
45+
>/dev/null 2>&1
46+
47+
# Sample PDF.
48+
curl -fsSL -o "${_PDF_SIGN_DEMO_DIR}/bitcoin.pdf" https://bitcoin.org/bitcoin.pdf >/dev/null 2>&1
49+
50+
trap _pdf_sign_demo_cleanup EXIT
51+
52+
# Run autocast from inside the demo directory so relative filenames match.
53+
cd "${_PDF_SIGN_DEMO_DIR}"
54+
55+
autocast "${YAML}" "${OUT}" -d 88ms --overwrite
56+

0 commit comments

Comments
 (0)