M9.5: Signatures in the engine and for agents: every document carries its validated signature fields (revalidated on edits and against the session's trusted certificates); Session::sign/mark_signed (signing saves; history before signing is dropped); full rewrites of signed documents are refused; certificates load from PEM/DER; sign_list, sign_id_create, sign_document and sign_trust tools (83 tools)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 02:50:08 -04:00
parent e06a98adaa
commit 64e900f270
12 changed files with 510 additions and 1 deletions

1
Cargo.lock generated
View File

@ -3442,6 +3442,7 @@ dependencies = [
"printcraft-print",
"printcraft-redact",
"printcraft-render",
"printcraft-sign",
"printcraft-xfdf",
"thiserror 2.0.21",
]

View File

@ -56,7 +56,7 @@ fn rect_to_view(p: &PageInfo, r: [f32; 4]) -> [f32; 4] {
}
impl Args<'_> {
fn nums<const N: usize>(&self, key: &str) -> Result<Option<[f64; N]>> {
pub(crate) fn nums<const N: usize>(&self, key: &str) -> Result<Option<[f64; N]>> {
let Some(v) = self.get(key) else { return Ok(None) };
let wrong = || Self::wrong(key, &format!("an array of {N} numbers"));
let arr = v.as_array().ok_or_else(wrong)?;

View File

@ -17,6 +17,7 @@ mod links;
pub mod mcp;
mod printing;
mod redact;
mod signing;
mod tools;
use std::collections::HashMap;
@ -309,6 +310,10 @@ impl Automation {
"comment_add" => self.comment_add(&a)?,
"comment_reply" => self.comment_reply(&a)?,
"comment_set_status" => self.comment_set_status(&a)?,
"sign_list" => self.sign_list(&a)?,
"sign_id_create" => self.sign_id_create(&a)?,
"sign_document" => self.sign_document(&a)?,
"sign_trust" => self.sign_trust(&a)?,
"comment_mark" => self.comment_mark(&a)?,
"comment_lock" => self.comment_lock(&a)?,
"comments_hide" => self.comments_hide(&a)?,

View File

@ -0,0 +1,211 @@
//! Signature tools: list and validate signatures, create a self-signed digital ID, sign a
//! document, and manage trusted certificates. Rectangles are points from the top-left of the
//! displayed page.
use printcraft_engine::sign::{self, Certificate, DigitalId, Modification, Name, PrivateKey};
use printcraft_engine::{SignOptions, SignatureInfo, SignatureStatus};
use serde_json::{Value, json};
use crate::{Args, Automation, Result, ToolError, failed, write_atomic};
fn bad(m: impl Into<String>) -> ToolError {
ToolError::InvalidArgs(m.into())
}
/// Seconds since 1970 → a DER time.
fn time(secs: i64) -> sign::Time {
sign::Time::from_unix(secs)
}
pub(crate) fn cert_json(c: &Certificate) -> Value {
json!({
"name": c.display_name(),
"subject": c.subject.display(),
"issuer": c.issuer.display(),
"email": c.subject.email(),
"serial": c.serial_hex(),
"valid_from": c.not_before.to_string(),
"valid_to": c.not_after.to_string(),
"key": c.public_key.describe(),
"self_signed": c.is_self_signed(),
"sha256": c.fingerprint(),
})
}
impl Automation {
fn sig_json(&self, a: &Args, s: &SignatureInfo) -> Result<Value> {
let doc = self.doc(a)?;
let rect = match (s.page, s.rect) {
(Some(p), Some(r)) if s.visible => doc.info.pages.get(p).map(|pi| {
let (a0, a1) = (pi.user_to_view(r[0] as f32, r[3] as f32), pi.user_to_view(r[2] as f32, r[1] as f32));
json!([a0[0].min(a1[0]), a0[1].min(a1[1]), a0[0].max(a1[0]), a0[1].max(a1[1])])
}),
_ => None,
};
let (modification, changes) = match &s.modification {
Modification::None => ("none", Vec::new()),
Modification::Allowed(c) => ("allowed", c.clone()),
Modification::Disallowed(c) => ("disallowed", c.clone()),
};
Ok(json!({
"field": s.field,
"signed": s.signed,
"status": if !s.signed { "unsigned" } else { match s.status { SignatureStatus::Valid => "valid", SignatureStatus::Unknown => "unknown", SignatureStatus::Invalid => "invalid" } },
"summary": s.summary(),
"signer": s.signer,
"certificate": s.certificate.as_ref().map(cert_json),
"chain": s.chain.iter().skip(1).map(cert_json).collect::<Vec<_>>(),
"date": s.date,
"reason": s.reason,
"location": s.location,
"contact": s.contact,
"certify": s.certify,
"page": s.page.map(|p| p + 1),
"rect": rect,
"visible": s.visible,
"revision": s.revision,
"sub_filter": s.sub_filter,
"algorithm": s.algorithm,
"timestamp": s.timestamp,
"modification": modification,
"changes": changes,
"details": s.details,
}))
}
pub(crate) fn sign_list(&self, a: &Args) -> Result<Value> {
let doc = self.doc(a)?;
let list: Vec<Value> = doc.signatures.iter().map(|s| self.sig_json(a, s)).collect::<Result<_>>()?;
let signed = doc.signatures.iter().filter(|s| s.signed).count();
let all_valid = signed > 0 && doc.signatures.iter().filter(|s| s.signed).all(|s| s.status == SignatureStatus::Valid);
Ok(json!({ "count": list.len(), "signed": signed, "all_valid": all_valid, "signatures": list }))
}
pub(crate) fn sign_id_create(&mut self, a: &Args) -> Result<Value> {
let name = a.str("name")?.trim().to_string();
if name.is_empty() {
return Err(bad("name must not be empty"));
}
let password = a.str("password")?;
if password.chars().count() < 6 {
return Err(bad("the password must have at least 6 characters"));
}
let path = self.resolve(a.str("path")?, true)?;
let key = match a.opt_str("key")?.unwrap_or("rsa2048") {
"rsa2048" => PrivateKey::generate_rsa(2048),
"rsa3072" => PrivateKey::generate_rsa(3072),
"rsa4096" => PrivateKey::generate_rsa(4096),
"p256" => PrivateKey::generate_p256(),
other => return Err(bad(format!("unknown key {other:?} (rsa2048, rsa3072, rsa4096, p256)"))),
}
.map_err(failed)?;
let dn = Name::build(
&name,
a.opt_str("unit")?.unwrap_or(""),
a.opt_str("organization")?.unwrap_or(""),
a.opt_str("email")?.unwrap_or(""),
a.opt_str("country")?.unwrap_or(""),
);
let years = a.opt_int("years")?.unwrap_or(5).clamp(1, 50) as u32;
let now = self.session.now_secs();
let serial = sign::keys::DigestAlg::Sha256.digest(&[name.as_bytes(), &now.to_be_bytes()])[..8].to_vec();
let cert = Certificate::self_signed(&dn, &key, time(now), years, &serial).map_err(failed)?;
let id = DigitalId { key, certificate: cert, chain: Vec::new(), friendly_name: Some(name) };
let p12 = sign::pkcs12::write(&id, password).map_err(failed)?;
write_atomic(&path, &p12)?;
Ok(json!({ "path": path.to_string_lossy(), "certificate": cert_json(&id.certificate) }))
}
fn open_id(&self, a: &Args) -> Result<DigitalId> {
let path = self.resolve(a.str("id")?, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(|e| match e {
sign::SignError::WrongPassword => bad("the digital ID password is incorrect"),
e => failed(e),
})
}
pub(crate) fn sign_document(&mut self, a: &Args) -> Result<Value> {
let id = self.open_id(a)?;
let doc = self.doc(a)?;
let doc_id = doc.id;
let field = a.opt_str("field")?.map(str::to_string);
let page = match a.opt_int("page")? {
Some(p) if p >= 1 && (p as usize) <= doc.info.pages.len() => p as usize - 1,
Some(p) => return Err(bad(format!("page {p} does not exist"))),
None => 0,
};
let rect = match a.nums::<4>("rect")? {
Some(r) => {
let p = &doc.info.pages[page];
let (u0, u1) = (p.view_to_user(r[0] as f32, r[1] as f32), p.view_to_user(r[2] as f32, r[3] as f32));
let u = [u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64];
if u[2] - u[0] < 4.0 || u[3] - u[1] < 4.0 {
return Err(bad("the signature rectangle is too small"));
}
Some(u)
}
None => None,
};
let certify = match a.get("certify") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(match s.as_str() {
"no_changes" => 1,
"form_fill" => 2,
"comments" => 3,
other => return Err(bad(format!("unknown certify level {other:?} (no_changes, form_fill, comments)"))),
}),
Some(v) => Some(v.as_u64().filter(|n| (1..=3).contains(n)).ok_or_else(|| bad("certify must be 1, 2 or 3"))? as u8),
};
let out = self.resolve(a.str("out")?, true)?;
let opts = SignOptions {
field,
page,
rect,
reason: a.opt_str("reason")?.map(str::to_string),
location: a.opt_str("location")?.map(str::to_string),
contact: a.opt_str("contact")?.map(str::to_string),
certify,
..SignOptions::default()
};
let signed = self.session.sign(doc_id, &id, opts).map_err(failed)?;
write_atomic(&out, &signed)?;
let path = out.to_string_lossy().into_owned();
self.session.mark_signed(doc_id, signed.clone(), Some(path.clone())).map_err(failed)?;
let doc = self.doc(a)?;
let newest = doc.signatures.iter().filter(|s| s.signed).max_by_key(|s| s.revision).cloned();
Ok(json!({ "path": path, "bytes": signed.len(), "signature": newest.map(|s| self.sig_json(a, &s)).transpose()? }))
}
pub(crate) fn sign_trust(&mut self, a: &Args) -> Result<Value> {
let mut certs: Vec<Certificate> =
if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() };
let paths: Vec<String> = match a.get("paths") {
None => Vec::new(),
Some(v) => v
.as_array()
.ok_or_else(|| bad("paths must be an array"))?
.iter()
.map(|p| p.as_str().map(str::to_string).ok_or_else(|| bad("paths must be strings")))
.collect::<Result<_>>()?,
};
for p in paths {
let path = self.resolve(&p, false)?;
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
let lower = p.to_ascii_lowercase();
let found = if lower.ends_with(".p12") || lower.ends_with(".pfx") {
let id = sign::pkcs12::open(&bytes, a.opt_str("password")?.unwrap_or("")).map_err(failed)?;
std::iter::once(id.certificate).chain(id.chain).collect()
} else {
sign::x509::load_certificates(&bytes).map_err(failed)?
};
for c in found {
if !certs.iter().any(|t| t.raw == c.raw) {
certs.push(c);
}
}
}
self.session.set_trusted_certificates(certs);
Ok(json!({ "trusted": self.session.trusted_certificates().iter().map(cert_json).collect::<Vec<_>>() }))
}
}

View File

@ -490,6 +490,44 @@ pub fn tools() -> Vec<ToolDef> {
comment_ref(json!({ "status": { "type": "string", "enum": ["none", "accepted", "rejected", "cancelled", "completed"] }, "author": { "type": "string" } })),
&["doc", "status"],
)),
t("sign_list", "List and validate signatures", "Every signature field with its validation: status (valid, unknown = intact but the signer isn't trusted, invalid, unsigned), signer and certificate, date, reason, location, certification level, page and rect, the revision it covers and changes made after signing (none, allowed, disallowed), with Acrobat-style explanations.")
.ro()
.with(schema(json!({ "doc": doc() }), &["doc"])),
t("sign_id_create", "Create a digital ID", "Create a self-signed digital ID and save it as a password-protected .p12 file (Acrobat: Configure a new digital ID ▸ Create a new digital ID ▸ Save to file). Default key: 2048-bit RSA, valid 5 years.").with(schema(
json!({
"name": { "type": "string", "minLength": 1 },
"organization": { "type": "string" },
"unit": { "type": "string" },
"email": { "type": "string" },
"country": { "type": "string", "description": "Two-letter country code." },
"key": { "type": "string", "enum": ["rsa2048", "rsa3072", "rsa4096", "p256"] },
"years": { "type": "integer", "minimum": 1, "maximum": 50 },
"password": { "type": "string", "minLength": 6 },
"path": { "type": "string", "description": "The .p12 file to write." },
}),
&["name", "password", "path"],
)),
t("sign_document", "Sign a document", "Sign with a digital ID (.p12/.pfx) and save the signed file to `out` (signing always saves, as in Acrobat; the document then shows the signed file). Sign an existing empty signature field (`field`), or a new one on `page` at `rect` (omit rect for an invisible signature). certify: no_changes, form_fill or comments makes a certification signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
.with(schema(
json!({
"doc": doc(),
"id": { "type": "string", "description": "Path of the digital ID (.p12 / .pfx)." },
"password": { "type": "string" },
"field": { "type": "string" },
"page": { "type": "integer", "minimum": 1 },
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"reason": { "type": "string" },
"location": { "type": "string" },
"contact": { "type": "string" },
"certify": { "type": "string", "enum": ["no_changes", "form_fill", "comments"] },
"out": { "type": "string", "description": "Where to save the signed document." },
}),
&["doc", "id", "out"],
)),
t("sign_trust", "Trust certificates", "Add certificates (.cer/.crt/.pem/.der, or the certificates in a .p12 with `password`) to the trusted certificates used to validate signatures, or clear the list (clear: true). Every open document is revalidated. Returns the trusted list.").with(schema(
json!({ "paths": { "type": "array", "items": { "type": "string" } }, "password": { "type": "string" }, "clear": { "type": "boolean" } }),
&[],
)),
t("comment_mark", "Mark a comment with a checkmark", "Mark or unmark a comment with a checkmark (Acrobat: Mark with checkmark), recorded as a private reply. Undoable.").with(schema(
comment_ref(json!({ "marked": { "type": "boolean", "description": "Default true." }, "author": { "type": "string" } })),
&["doc"],

View File

@ -939,3 +939,49 @@ fn drawing_comments_through_tools() {
let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
assert!(matches!(png[0], Content::Png { .. }));
}
#[test]
fn digital_ids_signing_and_validation_through_tools() {
let dir = workdir("signing");
let mut a = auto(&dir);
let id = ok(
&mut a,
"sign_id_create",
json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret1", "path": "ada.p12" }),
);
assert_eq!(id["certificate"]["subject"], "C=GB, O=Analytical Engines, CN=Ada Lovelace, E=ada@example.com");
assert_eq!(id["certificate"]["self_signed"], true);
assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_))));
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0);
assert!(matches!(
a.call("sign_document", &json!({ "doc": doc, "id": "ada.p12", "password": "wrong!", "out": "signed.pdf" })),
Err(ToolError::InvalidArgs(_))
));
let r = ok(
&mut a,
"sign_document",
json!({ "doc": doc, "id": "ada.p12", "password": "secret1", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }),
);
assert_eq!(r["signature"]["status"], "unknown");
assert_eq!(r["signature"]["signer"], "Ada Lovelace");
assert_eq!(r["signature"]["page"], 2);
let rect: Vec<f64> = r["signature"]["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!((rect[0] - 20.0).abs() < 0.5 && (rect[3] - 250.0).abs() < 0.5, "{rect:?}");
assert!(dir.join("signed.pdf").exists());
// Trust the ID: valid; comment afterwards: still valid, change allowed.
let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret1" }));
assert_eq!(t["trusted"].as_array().unwrap().len(), 1);
let list = ok(&mut a, "sign_list", json!({ "doc": doc }));
assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid")));
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" }));
let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0];
assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed")));
// A full rewrite is refused for a signed document.
assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_))));
ok(&mut a, "doc_save", json!({ "doc": doc }));
assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0);
assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown");
}

View File

@ -19,6 +19,7 @@ printcraft-edit = { workspace = true }
printcraft-redact = { workspace = true }
printcraft-print = { workspace = true }
printcraft-xfdf = { workspace = true }
printcraft-sign = { workspace = true }
printcraft-create = { workspace = true }
printcraft-model = { workspace = true }
thiserror = { workspace = true }

View File

@ -38,6 +38,8 @@ pub use printcraft_annot::{
pub use printcraft_print as print;
pub use printcraft_redact::patterns::{PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern};
pub use printcraft_redact::sanitize::{HIDDEN, Hidden};
pub use printcraft_sign as sign;
pub use printcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore};
pub use printcraft_xfdf::Format as DataFormat;
pub type SplitPart = (usize, usize, Arc<Vec<u8>>);
@ -135,11 +137,19 @@ pub struct Document {
pub added: Vec<printcraft_edit::Added>,
/// Link annotations, for Edit a PDF ▸ Link.
pub links: Vec<printcraft_annot::links::LinkItem>,
/// Signature fields, validated against the session's trust store (Signatures panel).
pub signatures: Arc<Vec<SignatureInfo>>,
trust: Arc<TrustStore>,
editor: Option<Editor>,
config: RenderConfig,
}
impl Document {
/// Signed: at least one signature field holds a signature.
pub fn is_signed(&self) -> bool {
self.signatures.iter().any(|s| s.signed)
}
/// Whether comments are hidden (Comments ▸ Hide all comments).
pub fn comments_hidden(&self) -> bool {
self.config.hide_comments
@ -1227,6 +1237,16 @@ pub enum EditError {
NothingToUndo,
#[error("nothing to redo")]
NothingToRedo,
#[error("{0}")]
Sign(String),
#[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")]
Signed,
}
impl From<printcraft_sign::SignError> for EditError {
fn from(e: printcraft_sign::SignError) -> Self {
EditError::Sign(e.to_string())
}
}
#[derive(Default)]
@ -1235,6 +1255,13 @@ pub struct Session {
next_id: u64,
/// Seconds since the Unix epoch, injected so saves are deterministic in tests.
clock: Option<fn() -> i64>,
/// Certificates trusted for signing (Acrobat: Trusted Certificates).
trust: Arc<TrustStore>,
}
/// Validate the signature fields of `cos` (written as `bytes`).
fn signatures_of(cos: &printcraft_cos::Document, bytes: &[u8], trust: &TrustStore) -> Arc<Vec<SignatureInfo>> {
Arc::new(printcraft_sign::signatures(cos, bytes, trust))
}
impl Session {
@ -1248,6 +1275,11 @@ impl Session {
self
}
/// Seconds since the Unix epoch from the session clock (0 when unknown).
pub fn now_secs(&self) -> i64 {
self.now().unwrap_or(0)
}
fn now(&self) -> Option<i64> {
if let Some(c) = self.clock {
return Some(c());
@ -1310,6 +1342,7 @@ impl Session {
let marks = editor.as_ref().map(|e| printcraft_edit::marks_present(&e.cos)).unwrap_or_default();
let added = editor.as_ref().map(|e| printcraft_edit::list_added(&e.cos)).unwrap_or_default();
let links = editor.as_ref().map(|e| printcraft_annot::links::list(&e.cos)).unwrap_or_default();
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, &self.trust)).unwrap_or_default();
self.next_id += 1;
let id = DocId(self.next_id);
self.docs.push(Document {
@ -1328,6 +1361,8 @@ impl Session {
marks,
added,
links,
signatures,
trust: self.trust.clone(),
editor,
config,
});
@ -1448,6 +1483,9 @@ impl Session {
}
doc.info.file_size = bytes.len();
doc.form = form;
if !doc.signatures.is_empty() {
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust);
}
doc.bytes = bytes.clone();
doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
Ok(())
@ -1474,6 +1512,7 @@ impl Session {
doc.marks = printcraft_edit::marks_present(&editor.cos);
doc.added = printcraft_edit::list_added(&editor.cos);
doc.links = printcraft_annot::links::list(&editor.cos);
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust);
doc.bytes = bytes.clone();
doc.renderer = RenderPool::new(bytes, render_threads(), doc.config.clone());
Ok(())
@ -1494,6 +1533,9 @@ impl Session {
/// A compact, garbage-collected rewrite (Save As ▸ "Optimized" / Reduce File Size groundwork).
pub fn save_full_bytes(&self, id: DocId) -> Result<Arc<Vec<u8>>, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
if doc.is_signed() {
return Err(EditError::Signed);
}
let editor = doc.editor.as_ref().ok_or_else(|| EditError::ReadOnly(doc.read_only_reason.clone().unwrap_or_default()))?;
let opts = SaveOptions { mod_date: self.now().map(printcraft_cos::pdf_date), ..SaveOptions::default() };
write_full(&editor.cos, &opts).map(Arc::new).map_err(|e| EditError::Write(e.to_string()))
@ -1802,6 +1844,59 @@ impl Session {
self.create_from_text(&format!("Summary of Comments on {}", doc.name), &text)
}
/// The certificates trusted for signing.
pub fn trusted_certificates(&self) -> &[printcraft_sign::Certificate] {
&self.trust.certs
}
/// Replace the trusted certificates and revalidate every open document's signatures.
pub fn set_trusted_certificates(&mut self, certs: Vec<printcraft_sign::Certificate>) {
self.trust = Arc::new(TrustStore { certs });
for doc in &mut self.docs {
doc.trust = self.trust.clone();
if let Some(e) = doc.editor.as_ref() {
doc.signatures = signatures_of(&e.cos, &doc.bytes, &doc.trust);
}
}
}
/// The signing time as a PDF date in local time with its offset (`D:…+02'00'`).
pub fn signing_date(&self) -> String {
let offset = if self.clock.is_some() { 0 } else { local_utc_offset() };
let local = printcraft_cos::pdf_date(self.now().unwrap_or(0) + offset);
let stamp = local.trim_end_matches('Z');
if offset == 0 {
return format!("{stamp}Z");
}
let (sign, m) = if offset < 0 { ('-', -offset / 60) } else { ('+', offset / 60) };
format!("{stamp}{sign}{:02}'{:02}'", m / 60, m % 60)
}
/// Sign the document's current state with `id` (Use a certificate ▸ Digitally sign). Returns
/// the signed file; the caller saves it and then calls [`Session::mark_signed`]. An empty
/// `opts.date` takes the session clock.
pub fn sign(&self, doc: DocId, id: &printcraft_sign::DigitalId, mut opts: SignOptions) -> Result<Arc<Vec<u8>>, EditError> {
let d = self.get(doc).ok_or(EditError::NoDocument)?;
// (Encrypted documents are refused by the signer for now.)
let editor = d.editor.as_ref().ok_or_else(|| EditError::ReadOnly(d.read_only_reason.clone().unwrap_or_default()))?;
if opts.date.is_empty() {
opts.date = self.signing_date();
}
Ok(Arc::new(printcraft_sign::sign(&editor.cos, id, &opts)?))
}
/// Record that the signed file `bytes` was saved (to `path`): like [`Session::mark_saved`],
/// and the edit history before signing is dropped (signing can't be undone).
pub fn mark_signed(&mut self, id: DocId, bytes: Arc<Vec<u8>>, path: Option<String>) -> Result<(), EditError> {
self.mark_saved(id, bytes, path)?;
let doc = self.doc_mut(id)?;
if let Some(e) = doc.editor.as_mut() {
e.undo.clear();
e.redo.clear();
}
Ok(())
}
pub fn close(&mut self, id: DocId) {
self.docs.retain(|d| d.id != id);
}

View File

@ -1045,3 +1045,32 @@ fn comments_lock_take_checkmarks_hide_and_summarize() {
assert!(!s.get(n).unwrap().info.pages.is_empty());
assert_eq!(comment_summary("x", &[], SummarySort::Page), "Summary of Comments on x\n\nThis document has no comments.\n");
}
#[test]
fn signing_saving_trusting_and_commenting_afterwards() {
let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
// After the test certificate's start date (2026-10-02).
let mut s = Session::new().with_clock(|| 1_800_000_000);
let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap();
assert!(s.get(id).unwrap().signatures.is_empty());
let opts = SignOptions { page: 1, rect: Some([20.0, 20.0, 180.0, 60.0]), reason: Some("Approved".into()), ..SignOptions::default() };
let signed = s.sign(id, &digital_id, opts).unwrap();
s.mark_signed(id, signed, Some("/tmp/signed.pdf".into())).unwrap();
let doc = s.get(id).unwrap();
assert_eq!(doc.name, "signed.pdf");
assert!(doc.is_signed() && !doc.dirty && doc.can_undo().is_none(), "signing can't be undone");
let sig = &doc.signatures[0];
assert_eq!((sig.status, sig.page, sig.date.as_deref()), (SignatureStatus::Unknown, Some(1), Some("D:20270115080000Z")));
assert_eq!(s.save_full_bytes(id).unwrap_err(), EditError::Signed);
// Trusting the signer makes it valid; a later comment is permitted.
s.set_trusted_certificates(vec![digital_id.certificate.clone()]);
assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid);
s.apply(id, rect_comment(0, [80.0, 80.0, 120.0, 120.0])).unwrap();
let sig = &s.get(id).unwrap().signatures[0];
assert_eq!((sig.status, sig.modification.clone()), (SignatureStatus::Valid, sign::Modification::Allowed(vec!["comments".into()])));
// Saving keeps the signature (incremental).
let saved = s.save_bytes(id).unwrap();
s.mark_saved(id, saved, None).unwrap();
assert_eq!(s.get(id).unwrap().signatures[0].status, SignatureStatus::Valid);
}

View File

@ -311,6 +311,12 @@ impl PrivateKey {
PrivateKey::from_pkcs8(der.as_ref())
}
/// RSA keys are generated only on native targets (ADR-0009).
#[cfg(target_arch = "wasm32")]
pub fn generate_rsa(_bits: usize) -> Result<PrivateKey, SignError> {
Err(SignError::Unsupported("creating RSA keys in the browser (use a P-256 key)".into()))
}
/// A new P-256 key.
pub fn generate_p256() -> Result<PrivateKey, SignError> {
loop {

View File

@ -278,3 +278,68 @@ pub fn build_chain<'a>(leaf: &'a Certificate, pool: &'a [Certificate]) -> Vec<&'
}
chain
}
/// Certificates from a file: DER, or PEM with one or more `CERTIFICATE` blocks (`.cer`, `.crt`,
/// `.pem`, Acrobat's `.fdf`-free exports).
pub fn load_certificates(bytes: &[u8]) -> Result<Vec<Certificate>, SignError> {
if bytes.first() == Some(&0x30) {
return Ok(vec![Certificate::parse(bytes)?]);
}
let text = String::from_utf8_lossy(bytes);
let mut out = Vec::new();
let mut block: Option<String> = None;
for line in text.lines().map(str::trim) {
if line == "-----BEGIN CERTIFICATE-----" {
block = Some(String::new());
} else if line == "-----END CERTIFICATE-----" {
if let Some(b) = block.take() {
out.push(Certificate::parse(&base64(&b).ok_or_else(|| SignError::Malformed("PEM base64".into()))?)?);
}
} else if let Some(b) = block.as_mut() {
b.push_str(line);
}
}
if out.is_empty() {
return Err(SignError::Malformed("no certificate found (expected DER or PEM)".into()));
}
Ok(out)
}
/// The certificate as PEM text (Export certificate).
pub fn to_pem(cert: &Certificate) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut s = String::new();
for c in cert.raw.chunks(3) {
let n = (c[0] as u32) << 16 | (*c.get(1).unwrap_or(&0) as u32) << 8 | *c.get(2).unwrap_or(&0) as u32;
for i in 0..4 {
s.push(if i <= c.len() { T[(n >> (18 - 6 * i) & 63) as usize] as char } else { '=' });
}
}
let body: Vec<String> = s.as_bytes().chunks(64).map(|l| String::from_utf8_lossy(l).into_owned()).collect();
format!("-----BEGIN CERTIFICATE-----\n{}\n-----END CERTIFICATE-----\n", body.join("\n"))
}
fn base64(s: &str) -> Option<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
Some(match c {
b'A'..=b'Z' => c - b'A',
b'a'..=b'z' => c - b'a' + 26,
b'0'..=b'9' => c - b'0' + 52,
b'+' => 62,
b'/' => 63,
_ => return None,
} as u32)
};
let digits: Vec<u8> = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect();
let mut out = Vec::with_capacity(digits.len() * 3 / 4);
for chunk in digits.chunks(4) {
let mut n = 0u32;
for (i, c) in chunk.iter().enumerate() {
n |= val(*c)? << (18 - 6 * i);
}
for i in 0..chunk.len().saturating_sub(1) {
out.push((n >> (16 - 8 * i)) as u8);
}
}
Some(out)
}

View File

@ -115,3 +115,15 @@ fn new_digital_ids_are_self_signed_and_survive_a_p12_round_trip() {
assert!(sd.verify_signature(&back.certificate, &d));
}
}
#[test]
fn certificates_load_from_pem_and_der_and_export_as_pem() {
let pem_text = data("rsa.crt.pem");
let certs = printcraft_sign::x509::load_certificates(&pem_text).unwrap();
assert_eq!(certs.len(), 1);
let der = certs[0].raw.clone();
assert_eq!(printcraft_sign::x509::load_certificates(&der).unwrap()[0], certs[0]);
let back = printcraft_sign::x509::to_pem(&certs[0]);
assert_eq!(printcraft_sign::x509::load_certificates(back.as_bytes()).unwrap()[0], certs[0]);
assert!(printcraft_sign::x509::load_certificates(b"hello").is_err());
}