Skip to content

feat(esign): bind signing links to their recipient and record consent - #35

Merged
batonac merged 2 commits into
developfrom
feat/verified-signer-email
Oct 23, 2026
Merged

batonac merged 2 commits into
developfrom
feat/verified-signer-email

Conversation

@batonac

@batonac batonac commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

What changes

Signer attribution that holds up under US ESIGN / UETA: the signing link can vouch for the recipient's email, a web form can require that, the name recorded is only the one the signer typed, and the signer gives explicit consent to sign electronically.

  1. Recipient-bound links. A link sent to one recipient carries email_sig, an HMAC-SHA256 of doctype ‖ document name ‖ normalized email (trimmed, lower-cased), next to the existing key and email. get_esign_link(..., recipient_email) and a single-recipient Send for eSign add it. The signing page (get_context) and accept (from the referrer, like the key) check it with hmac.compare_digest. The share-key check is unchanged and still runs first.

  2. Require Verified Email (new Web Form Check, in the Signer section). With it on:

    • the signing page refuses a link without email_sig ("This document can only be signed through the link emailed to its signer…") and a changed one ("This signing link has been changed, or it was not sent for this document…"). Both render as frappe's 403 message page. accept refuses the same, before anything is written, and also refuses a new document from the web form, which no link can name;
    • the email field (the Signer Email Field, or else the form's Data field with options Email) is pre-filled from the bound address and marked read-only in web_form_doc. accept writes the bound address into that field whatever was submitted, and records it;
    • the audit data records signer_email_verified: true, verified_via: "recipient link" and verified_email.

    With it off, behaviour is as before, except that a valid binding is still checked and recorded: signer_email_verified is true when the recorded email is the bound one. If the signer typed a different address, the bound one is kept as verified_email and shown as "Link sent to …, verified via recipient link".

  3. Signer name. The fallback that named the signer after the User whose email matched is removed. The name is only what the signer typed into the Signer Name Field. Without that field, a logged-in user whose signer email is their own login is named after their own User. Nobody else is named.

  4. Consent. The signing page has a required checkbox, "I agree to sign this document electronically.", whose text a web form can replace (new Consent Text, Small Text). The client checks it and posts esign_consent. accept refuses to sign without it ("To sign, tick the box: …"). The server-side text (the web form's override or the translated default) and the server time it was received are recorded as consent_text and consent_timestamp.

  5. Display. The timeline card and the notification email's audit table show "Email verified via recipient link" (or "Link sent to …") and the consent with its time. The signing page shows "Signing as , verified by your email link" when the link is bound.

Multi-recipient Send for eSign: one link per recipient

With Require Verified Email on, Send for eSign sends one email per recipient in To, each with that recipient's own bound link, to that recipient alone. I picked this over refusing, because:

  • it keeps the dialog working for the common case (two signers on one agreement) with no extra steps for the sender;
  • each link proves something specific: the signer opened the link that was emailed to that address and to nobody else.

The same reasoning means a bound link must never reach anyone but its recipient. Anyone holding it could sign as that recipient and be recorded as verified. So with the setting on, CC, BCC and Send me a copy are refused, with a message saying to put every signer in To. With the setting off, nothing is refused: a single recipient's link carries email as before, but gets email_sig only when nobody else (CC, BCC, Send me a copy) gets the message. Each per-recipient email is recorded as its own Communication. The response keeps communication and esign_link (the first) and adds communications. The dialog's link preview says when a web form sends per-recipient links.

Security reasoning

  • Key. k = HMAC-SHA256(site encryption_key, "esign_webforms:recipient-email-binding"). The key is secret, unique to the site and separate from other uses, and the encryption key itself is never used directly. frappe creates encryption_key on first use (frappe.utils.password.get_encryption_key). Nothing new to configure.
  • What it binds. The doctype, the document name and the email, NUL-separated, so a binding cannot be moved to another document or another address. Email normalization (trim, lower case) means case changes in a forwarded link do not break it. It is still compared against one valid address only.
  • Comparison. hmac.compare_digest on bytes (a non-ASCII value cannot raise). A missing, shortened, changed or moved binding gets None.
  • What it does not do. It does not replace or weaken the share key, which still decides access and expiry. A binding has no expiry of its own and lapses with the share key, or for the whole site when the encryption key rotates. It proves possession of the link sent to that address, not identity: the README Limitations say so.
  • Who can mint one. Only Send for eSign (into the email to that recipient) and Jinja or Python callers that pass the Document. get_esign_link over HTTP still needs email permission but returns no binding (see Review fixes).
  • Server authority. The read-only field is only a convenience. accept overwrites the email field with the bound address, and the consent text recorded is the server's, not anything the client sent.

Upgrade impact

  • New custom fields on Web Form (esign_require_verified_email, default off; esign_consent_text), synced on migrate. Two existing custom fields get a new insert_after.
  • Consent is now required for every eSign signing. accept refuses a post without esign_consent. The signing page sends it once assets are rebuilt (bench build / bench update). A browser still running a cached old bundle, or any custom client posting to accept directly, gets "To sign, tick the box…" until it sends esign_consent=1.
  • Signer names. Guests, and users signing for an address that isn't their own login, are no longer named after a matching User. Existing eSign records are unchanged. Old records have no verification or consent keys, and the card and email simply omit those rows.
  • Links. Existing links keep working. Single-recipient links are now longer (&email_sig= + 64 hex). A single-recipient send with CC/BCC/Send me a copy still pre-fills email but is not verified.
  • Require Verified Email (opt-in) refuses keyless signing (a logged-in owner without a link) and new-document submissions on that form.
  • Overlap with fix(audit): name a signer after a User only for their own login #34. This PR removes the User-name fallback outright, which supersedes fix(audit): name a signer after a User only for their own login #34's narrower fix. Whichever merges second needs a small rebase in _collect_audit_data and the README. Merging this one makes fix(audit): name a signer after a User only for their own login #34 unnecessary.

Verification

  • frappe-test --ci: 144 tests pass, coverage 98.9 %, testmap complete, composition and ty clean.
  • prek run --all-files: clean.
  • New tests:
    • Binding (TestEmailBinding): valid binding; the HMAC construction and per-site key; normalization; tampered (another email, another document, changed, shortened, non-ASCII) and missing parts; compare_digest used.
    • Signing page (TestVerifiedSigningPage): bound prefill and the verified note; unbound not called verified; required setting refusing a missing or tampered binding and a missing share key; field locked, including a custom Signer Email Field; consent checkbox with default and escaped custom text.
    • accept (TestVerifiedAccept): consent required (0, "0", "", None); consent text and time recorded; the required setting refusing a missing or tampered binding and a new document; submitted email overridden server side, including via the Signer Email Field; recorded verified without the setting; a differing typed email recorded unverified, with verified_email.
    • Names (TestSignerName): no User-name fallback for a guest, a bound link or an empty name field; a logged-in user only as themselves.
    • Send for eSign (TestSendEsignEmail): single-recipient bound link; no binding when CC/BCC/copy are present; per-recipient emails with their own bound links; refusals for CC/BCC/copy and bad addresses before anything is sent.
    • Timeline, notification and demo: verification and consent fields, escaping of the new values, notification rows, demo record.
  • I re-inserted the old User-name fallback into this branch and ran the tests: 5 failures, so the name tests catch the regression. The binding, consent and Send for eSign tests import make_email_binding / get_bound_email / build_esign_link, or assert behaviour develop lacks, so they fail on develop.
  • frappe-shots --recreate-site: signing page (ticked consent), signature pad, audit trail (verified email and consent rows) and signer settings (Require Verified Email, Consent Text) regenerated. Send for eSign was unchanged.

Review fixes (second commit)

An adversarial review found three ways around the binding and the consent. All are fixed with regression tests that fail on the first commit (6 failures and 1 error in test_web_form, 3 failures in test_esign):

  1. Frappe's own accept bypassed the signing checks. frappe.website.doctype.web_form.web_form.accept is whitelisted for guests and asks has_web_form_permission(..., "write") before saving. The eSign override answered that from the share key, so a guest with any signing link could post there (key in the query or in the Referer) and update the document with no consent, no binding, and any email even under Require Verified Email. The share key now grants a write only inside this app's accept (a request flag set around its own permission check); reads are unchanged. Test: test_frappes_own_accept_cannot_skip_the_signing_checks, test_share_key_writes_only_through_esign_accept.
  2. get_esign_link over HTTP minted bound links for any address. Anyone with email permission could fetch a link bound to alice@… without emailing Alice and sign as her, recorded as verified. Over HTTP (JSON or dict doc) the link now carries email but never email_sig; Jinja and Python callers that pass the Document still get the binding. Test: test_request_payload_never_gets_a_binding.
  3. Send for eSign leaked the bound link. It was stored in the Communication (readable by everyone who can read the document) and returned to the sender as esign_link. Both now carry the link without email_sig (strip_email_binding); only the email itself has it. Test: test_bound_link_reaches_only_the_recipient.

Also checked, no change needed: HMAC forgery or replay across documents and addresses (doctype, name and normalized email are all covered); timing (compare_digest, with only non-secret length checks first); consent skipping through accept (enforced server side); notification and timeline escaping of the new values; the desk dialog (it never used the returned esign_link).

README (Send for eSign, Links in templates, How It Works, a new Limitations entry on where a Jinja-bound link ends up, such as a Notification's Communication) and docs/README.md are updated. No visible UI changed, so the screenshots stay as they are.

Review verification: frappe-test --ci 149 tests pass, coverage 98.7 %, testmap, composition and ty clean; prek run --all-files clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F

batonac and others added 2 commits October 23, 2026 11:04
A link sent to one recipient now carries an email_sig parameter: an
HMAC-SHA256 of the doctype, the document name and the normalized email,
keyed with a secret derived from the site's encryption key. The signing
page and accept check it in constant time, after the share key, and the
audit trail records the email as verified via the recipient link
(signer_email_verified, verified_via, verified_email).

A new Web Form setting, Require Verified Email, signs only through such
a link: a missing or tampered binding is refused with a clear message,
the email field is pre-filled from the link and read-only, and accept
writes and records the bound address whatever the form submits. Send for
eSign then sends each recipient in To their own email and link, and
refuses CC, BCC and Send me a copy. Without the setting, a single
recipient's link is bound only when nobody else gets the message.

The signer's name is now only what they typed into the Signer Name
Field. The fallback to the User whose email matched is removed; without
a name field, a logged-in user signing as themselves is still named
after their own User.

The signing page has a required consent checkbox, "I agree to sign this
document electronically", whose text a web form can replace (Consent
Text). accept refuses to sign without it, and the consent text and time
are recorded in the audit data, on the timeline card and in the
notification email, which also show "Email verified via recipient link".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
…ng flow

- frappe's own web form accept is whitelisted for guests as well, and it asks
  the web form's has_web_form_permission before saving. The eSign override
  answered "write" from the share key, so a guest with any signing link could
  post there and update the document without the consent, without the
  recipient binding and with any email under Require Verified Email. The share
  key now grants a write only inside this app's accept.
- The whitelisted get_esign_link returned a bound link for any address to
  anyone with email permission, letting them sign as that address, verified.
  Over HTTP it now carries the email parameter but no binding; Jinja and Python
  callers that pass the document still get it.
- Send for eSign stored the bound link in the Communication, which every reader
  of the document can open, and returned it to the sender. Both now carry the
  link without email_sig; only the email to the recipient has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvdEeMiewirZ3ifFoUix5F
@batonac
batonac merged commit 54c5c4a into develop Oct 23, 2026
12 of 13 checks passed
@batonac
batonac deleted the feat/verified-signer-email branch October 23, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant