External-party signing — Phase 2: Internal-tier external signers (design)

Date: 2026-06-29
Status: Approved (design); ready for implementation plan
Builds on: Phase 1 (2026-06-28-external-party-signing-design.md) — the shipped Global-external flow (roster esign_sign_envelopes/esign_envelope_signers, per-signer 256-bit access tokens, the public token-gated signing page, MySign/Send/SubmitSignerOTPByToken, roster-driven finalizeEnvelopeIfComplete + FinalizeInstance, and the LOW-polish hardening).

1. Goal

Let a requester invite an external party (no Obscura account) to sign a document on the Internal tier — verified by an Obscura-sent email OTP and sealed with an in-house PAdES signature (“self-hosted attestation”) — with no Mekari dependency and no per-signature provider cost. This is the lower-assurance, high-volume counterpart to the certified Global-external flow already shipped.

Assurance positioning: an internal-tier external signature proves “someone who controlled that email address signed this document at time T, via a link only they received.” It is NOT a government-PKI or AATL-certified identity (that is the Global tier, which already ships PAdES-B-LT). The trust anchor is the deployment’s own in-house CA.

2. Decisions (locked during brainstorm)

3. Architecture

An internal-tier request with an external signer creates a local envelope: the same esign_sign_envelopes + esign_envelope_signers roster and per-signer access tokens as the Global flow, but provider="local", sign_kind="internal", and no Mekari RequestMultiSign call. The public signing page, token hashing/storage, deadline-as-token-expiry, the per-token+IP rate limiter, token revocation on sign, and roster-driven completion are all reused unchanged. Only three steps branch on the envelope’s provider:

  1. OTP send — local: Obscura emails a code; Mekari: existing WhatsApp path.
  2. OTP submit — local: validate the local code; Mekari: existing ChallengeSealer.SubmitOTP.
  3. Apply signature — local: in-house PAdES per submit; Mekari: provider seal fetched at finalize.

This keeps the new surface area small and isolated behind a provider branch.

4. Data model

5. Request creation

In the RequestSignature HTTP handler + buildEnvelope:

6. OTP flow (email)

SendSignerOTPByToken(tokenHash) and SubmitSignerOTPByToken(...) branch on the loaded envelope’s provider:

7. Signature — self-hosted attestation

On a valid local OTP + the decoded drawn-signature PNG:

  1. Ensure a singleton “Obscura Attestation” signing cert exists by reusing the existing per-principal signing-cert issuance/storage (EnsureSigningCert) with a reserved system principal id (e.g. system:obscura-attestation) — no new cert storage, idempotent ensure-on-first-use, issued from the same in-house CA as user certs.
  2. Apply an in-house PAdES signature to the document’s current version via the existing Signer.Sign(attestationCertPEM, attestationKeyPEM, caCertPEM, pdf, SignInfo{...}):
    - Name = <external name>, Reason = "Self-hosted attestation — <name> <email>, email-verified, <UTC RFC3339>", Image = drawnPNG, Placement = the signer’s box (default if none). It is an ApprovalSignature (stacks with other signatures).
  3. Land it as a new version via the existing versionWriter.AddSignedVersion (idempotent-by-content-hash from the regression fix). Insert an evidence row in signatures: assurance="internal", is_external=true, signer_name=<external name>, signed_ip/ua, signed/source hashes.
  4. Mark the roster row signed (MarkSignerSignedExternal — also nulls the row’s token). When all roster rows are signed → mark the envelope completed + FinalizeInstance (roster-driven, idempotent). No FetchSealed — there is no external provider.

Multi-signer note: each submit signs the current version, so signatures stack as PAdES approval signatures. ensureSignerTurn keeps a sequential request clean (each signer signs after the prior). A parallel local request signs whatever the latest version is at submit time (acceptable; documented).

8. UI

9. Security

10. Out of scope

11. Testing