Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions SIGNATURE_PORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Digital-signature port (vksp2k7/pdfcraft `feat/upstream-signature` -> pdfcraft-android)

Android already had the digital-ID flow (Fill & Sign > "Sign with a digital ID": create or import
a .p12, sign, validate, trust a signer). This port brings in the upstream branch's newer signing
code underneath it.

## Changed
- `crates/sign/**`: replaced with the upstream branch's version (hardened PKCS #12 open/write,
validated-signature appearances, stricter ExtendedKeyUsage, new tests).
- `crates/cos/src/document.rs`: native credential reuse for encrypted earlier revisions, xref
oddity tracking, and exact-entry checks used by signature validation.
- `crates/engine/src/lib.rs`: `display_bytes` now draws validated signature appearances; signatures
are validated before the display copy is built; trust changes redraw the page and increment the
document generation.
- Android UI refreshes the document after trust changes so Compose/page-image caches request the
new validated appearance.
- `crates/automation/src/{signing,tools}.rs`: `sign_document` takes `appearance` and `labels`;
password minimum is `pkcs12::MIN_PASSWORD_CHARS` (8).
- Android UI (`DigitalSign.kt`): Create-ID password minimum 6 -> 8 (`MIN_ID_PASSWORD`).
- Tests: passwords `secret1` -> `secret12`; the three upstream xref/signature regression tests run
normally now that the cos hardening is native.

## Verification
The Rust cos encryption, xref, and signing regression tests pass. The full workspace checks and
Android release build are run separately as part of the delivery checklist.

## Note
New IDs now use 600,000 PBKDF2 rounds (upstream's `WRITE_ROUNDS`), which is noticeably slower on a
phone when creating or opening an ID than the old 2048.
Original file line number Diff line number Diff line change
Expand Up @@ -1206,6 +1206,10 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
}

fun signFieldWithId(doc: OpenDocument, w: io.github.artcraftandroid.pdfcraft.core.WidgetInfo) {
if (!doc.info.readOnlyReason.isNullOrEmpty()) {
tell("This document can't be changed, so it can't be signed.")
return
}
openCertSign(io.github.artcraftandroid.pdfcraft.ui.viewer.CertTarget(doc, w.field, w.page.toInt(), null))
}

Expand Down Expand Up @@ -1411,6 +1415,9 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
private suspend fun applyTrust(eng: Engine) {
val paths = withContext(Dispatchers.IO) { trustedCerts.files().map { it.absolutePath } }
eng.tool("sign_trust", mapOf("clear" to true, "paths" to paths))
// The engine redraws trusted signature appearances under a new generation; refresh the
// document so Compose/page caches request the new pixels and signature state.
document?.let { refresh(it) }
}

// ---- Edit PDF ----------------------------------------------------------------------------
Expand Down Expand Up @@ -1733,11 +1740,13 @@ class AppViewModel(app: Application) : AndroidViewModel(app) {
"checkbox" -> fillField(doc, w.field, !w.on)
"radio" -> w.export?.let { fillField(doc, w.field, it) }
"text", "combo", "list" -> fieldEdit = io.github.artcraftandroid.pdfcraft.ui.viewer.FieldEdit(doc, w)
// a signed field shows its signature's details; an empty one, a drawn or a digital signature
// A signed field shows its signature's details; an empty field opens digital-ID
// signing. Handwritten Fill & Sign remains available from the Fill & Sign toolbar,
// rather than silently turning a certificate tap into a scribble pad.
"signature" -> if (doc.signatures.any { it.field == w.field && it.signed }) {
doc.controller.sheet = io.github.artcraftandroid.pdfcraft.ui.viewer.ViewerSheet.Signatures
} else {
signFieldChoice = doc to w
signFieldWithId(doc, w)
}
"button" -> pressButton(doc, w)
else -> return false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ import org.json.JSONObject
// here), and what the signatures in a document say. The engine signs (PAdES, incrementally) and
// validates; these are the screens around it.

/** The shortest password a new digital ID may have; the engine enforces the same minimum (`pkcs12::MIN_PASSWORD_CHARS`). */
const val MIN_ID_PASSWORD = 8

/** A signer's certificate, as a signature's details show it. */
data class SignerCert(val name: String, val issuer: String, val validTo: String?, val sha256: String, val selfSigned: Boolean)

Expand Down Expand Up @@ -418,7 +421,7 @@ fun CreateIdScreen(vm: AppViewModel) {
again.isNotEmpty() && again != password -> stringResource(R.string.sign_password_mismatch)
else -> null
}
val ok = name.isNotBlank() && password.length >= 6 && again == password
val ok = name.isNotBlank() && password.length >= MIN_ID_PASSWORD && again == password
Column(Modifier.fillMaxSize().background(c.surface).pointerInput(Unit) { detectTapGestures() }.safeTopPadding().navigationBarsPadding().imePadding()) {
Row(Modifier.fillMaxWidth().height(56.dp).padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) {
TextButton(onClick = { vm.idCreate = false }) { Text(stringResource(R.string.common_cancel), color = c.accent) }
Expand Down
6 changes: 3 additions & 3 deletions apps/pdfcraft-android/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -370,10 +370,10 @@ fn signs_with_a_digital_id_and_trusts_its_signer() {
let s = MobileSession::new();
let dir = scratch();
let id = dir.join("me.p12").to_string_lossy().into_owned();
let made = json!({ "name": "Test Signer", "email": "signer@example.com", "password": "secret1", "path": id });
let made = json!({ "name": "Test Signer", "email": "signer@example.com", "password": "secret12", "path": id });
s.execute("sign_id_create".into(), made.to_string()).unwrap();
// the file's certificate, and a wrong password told apart
let info = digital_id_info(id.clone(), "secret1".into()).unwrap();
let info = digital_id_info(id.clone(), "secret12".into()).unwrap();
assert_eq!(info.name, "Test Signer");
assert_eq!(info.email.as_deref(), Some("signer@example.com"));
assert!(info.self_signed && info.valid_to > info.valid_from && info.problem.is_none());
Expand All @@ -388,7 +388,7 @@ fn signs_with_a_digital_id_and_trusts_its_signer() {
let doc = s.open(path.clone(), None).unwrap().id;
assert!(s.signer_certificate(doc, "Signature1".into()).unwrap().is_none());
let args =
json!({ "doc": doc, "id": id, "password": "secret1", "page": 1, "rect": [72, 72, 252, 122], "graphic": { "text": "Test" }, "out": path });
json!({ "doc": doc, "id": id, "password": "secret12", "page": 1, "rect": [72, 72, 252, 122], "graphic": { "text": "Test" }, "out": path });
let out: Value = serde_json::from_str(&s.execute("sign_document".into(), args.to_string()).unwrap()).unwrap();
let field = out["signature"]["field"].as_str().unwrap().to_string();
assert_eq!(out["signature"]["status"], "unknown");
Expand Down
73 changes: 71 additions & 2 deletions crates/automation/src/signing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,62 @@ fn time(secs: i64) -> sign::Time {
sign::Time::from_unix(secs)
}

/// The longest label `sign_document` takes (characters): a label is a few words.
const MAX_LABEL_CHARS: usize = 200;

/// `appearance`: which items the visible signature shows; those not given keep the default
/// (name, date and labels on; reason, location and distinguished name off).
fn appearance_arg(v: &Value) -> Result<sign::Appearance> {
const KEYS: &str = "name, date, reason, location, distinguished_name, labels";
let o = v.as_object().ok_or_else(|| bad(format!("appearance must be an object of true/false items ({KEYS})")))?;
let mut shown = sign::Appearance::default();
for (key, value) in o {
let on = value.as_bool().ok_or_else(|| bad(format!("appearance.{key} must be true or false")))?;
let item = match key.as_str() {
"name" => &mut shown.name,
"date" => &mut shown.date,
"reason" => &mut shown.reason,
"location" => &mut shown.location,
"distinguished_name" => &mut shown.distinguished_name,
"labels" => &mut shown.labels,
other => return Err(bad(format!("unknown appearance item {other:?} ({KEYS})"))),
};
*item = on;
}
Ok(shown)
}

/// `labels`: the words of the visible signature's labels; those not given keep the English. Each
/// must be drawable in the signature's font. Nobody sees a preview here, so a label it can't draw
/// is refused even when this signature doesn't show it.
fn labels_arg(v: &Value) -> Result<sign::AppearanceLabels> {
const KEYS: &str = "signed_by, distinguished_name, reason, location, date";
let o = v.as_object().ok_or_else(|| bad(format!("labels must be an object of strings ({KEYS})")))?;
let mut labels = sign::AppearanceLabels::default();
for (key, value) in o {
let text = value.as_str().ok_or_else(|| bad(format!("labels.{key} must be a string")))?;
if text.chars().count() > MAX_LABEL_CHARS {
return Err(bad(format!("labels.{key} is longer than {MAX_LABEL_CHARS} characters")));
}
let label = match key.as_str() {
"signed_by" => &mut labels.signed_by,
"distinguished_name" => &mut labels.distinguished_name,
"reason" => &mut labels.reason,
"location" => &mut labels.location,
"date" => &mut labels.date,
other => return Err(bad(format!("unknown label {other:?} ({KEYS})"))),
};
*label = text.to_string();
}
if let Some((field, c)) = labels.first_undrawable() {
return Err(bad(format!(
"labels.{field}: {}",
pdfcraft_engine::undrawable_message(&format!("the {field} label of the visible signature"), c)
)));
}
Ok(labels)
}

pub(crate) fn cert_json(c: &Certificate) -> Value {
json!({
"name": c.display_name(),
Expand Down Expand Up @@ -89,8 +145,8 @@ impl Automation {
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"));
if password.chars().count() < sign::pkcs12::MIN_PASSWORD_CHARS {
return Err(bad(format!("the password must have at least {} characters", sign::pkcs12::MIN_PASSWORD_CHARS)));
}
let path = self.resolve(a.str("path")?, true)?;
let key = match a.opt_str("key")?.unwrap_or("rsa2048") {
Expand Down Expand Up @@ -177,6 +233,17 @@ impl Automation {
Some(g) if rect.is_none() && field.is_none() => return Err(bad(format!("graphic {g} needs a visible signature: pass rect or field"))),
Some(g) => Some(self.signature_graphic(g)?),
};
let visible = rect.is_some() || field.is_some();
let appearance = match a.get("appearance") {
None | Some(Value::Null) => sign::Appearance::default(),
Some(_) if !visible => return Err(bad("appearance needs a visible signature: pass rect or field")),
Some(v) => appearance_arg(v)?,
};
let appearance_labels = match a.get("labels") {
None | Some(Value::Null) => sign::AppearanceLabels::default(),
Some(_) if !visible => return Err(bad("labels needs a visible signature: pass rect or field")),
Some(v) => labels_arg(v)?,
};
let out = self.resolve(a.str("out")?, true)?;
let opts = SignOptions {
field,
Expand All @@ -186,6 +253,8 @@ impl Automation {
location: a.opt_str("location")?.map(str::to_string),
contact: a.opt_str("contact")?.map(str::to_string),
certify,
appearance,
appearance_labels,
graphic,
..SignOptions::default()
};
Expand Down
29 changes: 27 additions & 2 deletions crates/automation/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ pub fn tools() -> Vec<ToolDef> {
"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 },
"password": { "type": "string", "minLength": pdfcraft_engine::sign::pkcs12::MIN_PASSWORD_CHARS },
"path": { "type": "string", "description": "The .p12 file to write." },
}),
&["name", "password", "path"],
Expand All @@ -671,7 +671,7 @@ pub fn tools() -> Vec<ToolDef> {
.ro()
.cmd("sign.digital")
.with(schema(json!({}), &[])),
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) 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. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
t("sign_document", "Sign a document", "Sign with a digital ID (a .p12/.pfx path, or on macOS a Keychain identity: \"keychain:<common name or fingerprint>\" from sign_keychain_ids, or on Windows a store identity: \"windows:<common name or fingerprint>\" from sign_windows_ids) 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. graphic shows a picture where the signer's name would be large (Acrobat: Configure Signature Appearance): {\"image\": path} (PNG or JPEG), {\"text\": name} (typed in the Fill & Sign script font) or {\"strokes\": [[[x, y], …], …]} (y up, the drawing pad 1 wide); it needs a visible signature. appearance chooses what a visible signature shows (default: name, date and labels; reason, location and distinguished_name are off unless turned on). labels gives the words of its labels, e.g. in another language (default English: \"Digitally signed by\", \"DN: \", \"Reason: \", \"Location: \", \"Date: \"; include the separator); each must be in Western European characters (Windows-1252), the only ones the signature's font draws, or the call is refused naming the label and character. PAdES B-B, SHA-256 (SHA-384 for P-384 keys).")
.cmd("sign.digital")
.with(schema(
json!({
Expand All @@ -696,6 +696,31 @@ pub fn tools() -> Vec<ToolDef> {
"minProperties": 1,
"maxProperties": 1,
},
"appearance": {
"type": "object",
"description": "What a visible signature shows; items not given keep their default.",
"properties": {
"name": { "type": "boolean", "description": "Default true." },
"date": { "type": "boolean", "description": "Default true." },
"reason": { "type": "boolean", "description": "Default false." },
"location": { "type": "boolean", "description": "Default false." },
"distinguished_name": { "type": "boolean", "description": "Default false." },
"labels": { "type": "boolean", "description": "Default true." },
},
"additionalProperties": false,
},
"labels": {
"type": "object",
"description": "The words of a visible signature's labels (at most 200 characters each, Western European characters only); labels not given stay English.",
"properties": {
"signed_by": { "type": "string", "description": "Above the name. Default \"Digitally signed by\"." },
"distinguished_name": { "type": "string", "description": "Before the certificate's subject. Default \"DN: \"." },
"reason": { "type": "string", "description": "Default \"Reason: \"." },
"location": { "type": "string", "description": "Default \"Location: \"." },
"date": { "type": "string", "description": "Default \"Date: \"." },
},
"additionalProperties": false,
},
"out": { "type": "string", "description": "Where to save the signed document." },
}),
&["doc", "id", "out"],
Expand Down
Loading
Loading