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).
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.
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:
ChallengeSealer.SubmitOTP.This keeps the new surface area small and isolated behind a provider branch.
esign_sign_envelopes with provider="local", sign_kind="internal". (provider already exists; “local” is a new value, no schema change there.)00068) — add local-OTP state to esign_envelope_signers:otp_code_hash text (nullable; sha256hex of the issued code)otp_expires_at timestamptz (nullable)otp_attempts int NOT NULL DEFAULT 0provider_envelope_id/signer_id columns stay empty/synthetic for local rows.)In the RequestSignature HTTP handler + buildEnvelope:
sign_kind must be global” rule. The rule becomes: a Global external requires a phone (WhatsApp OTP); an Internal external requires an email (email OTP + link). Both still require a name.buildEnvelope runs when sign_kind=="global" OR (sign_kind=="internal" AND ≥1 external signer). For the internal/local case it: builds the roster rows (internal users + externals), mints a 256-bit access token per external row, persists the envelope as provider="local", and sends each external an email invite (real EmailInviter) with the /sign/<token> link. It does not call Mekari and sets no provider signer ids.parallel/sequential) apply as today.SendSignerOTPByToken(tokenHash) and SubmitSignerOTPByToken(...) branch on the loaded envelope’s provider:
sha256hex(code) + otp_expires_at = now + 10m and reset otp_attempts = 0 on the signer row; email the code to the signer’s email (subject e.g. “Your Obscura signing code”); return otp_channel = "email". Respects the existing per-token+IP send limiter + ensureSignerTurn (sequential).otp_code_hash is null/expired (esign.otp.expired), or otp_attempts >= 5 (rate-limited), or the hash doesn’t match (increment otp_attempts, esign.otp.invalid). On match → apply the signature (§7).RequestOTP/SubmitOTP).On a valid local OTP + the decoded drawn-signature PNG:
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.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).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.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).
Internal / Global / PSrE) is usable when externals are present (today it force-locks Global). Validation per tier: a Global external needs name+phone(+email); an Internal external needs name+email (no phone). Helper text explains the Internal-tier external is “email-verified, in-house attestation (no certified provider).”sha256hex, deadline = expiry, public routes gated on the esign module, per-token+IP rate limiter, token nulled on sign/cancel/expiry, signed_ip from X-Forwarded-For.go build ./... && go vet ./...; cd web && npx tsc --noEmit && npx vite build; regen npm run gen:api after OpenAPI edits. NEVER go test (writes the live demo Postgres)./me enabled_modules == [correspondence,watermarking,ai,esign] after each deploy.internal/is_external, the roster completes, and the instance is approved. Also verify a mixed internal+external internal-tier request and the relaxed modal validation. Clean up test artifacts.