{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://free-sign.com/evidence/v2/schema.json",
  "title": "FreeSign signing evidence (v2)",
  "description": "The pre-seal signing-evidence record FreeSign embeds inside every signed PDF, as a CMS unsignedAttribute (OID 1.3.6.1.4.1.65834.1.2) on each signer's SignerInfo. v2 supersedes v1 by adding passkey (WebAuthn) signers: a signer is verified either by an emailed one-time code (identity_method 'otp', carrying otp_challenge_id / otp_verified_at) OR by a platform passkey (identity_method 'passkey', carrying a webauthn_assertion and a webauthn claim inside canonical_payload). Every v1 (OTP) record is also a valid v2 record. Extract it from the PDF with any CMS parser; it is self-verifying offline via canonical_payload + signature_base64url + public_key_jwk. The seal/finalize half (CMS signature, certificate chain, RFC 3161 timestamp, OpenTimestamps proof, final_pdf_sha256, final signature) is NOT in this document — it is the CMS itself plus GET /api/receipts/{envelope_id}. Human-readable reference: https://free-sign.com/guides/evidence-json-schema",
  "type": "object",
  "required": [
    "schema",
    "envelope_id",
    "document_sha256",
    "signer_name",
    "identity_method",
    "payload_hash",
    "consent_version",
    "consent_text_sha256",
    "created_at",
    "canonical_payload",
    "signature_base64url",
    "public_key_jwk",
    "receipt_id"
  ],
  "allOf": [
    {
      "$comment": "OTP signers: identity_method 'otp' requires otp_challenge_id + otp_verified_at, NO webauthn_assertion, and a canonical_payload carrying the OTP fields and NO webauthn claim.",
      "if": {
        "required": ["identity_method"],
        "properties": { "identity_method": { "const": "otp" } }
      },
      "then": {
        "required": ["otp_challenge_id", "otp_verified_at"],
        "not": { "required": ["webauthn_assertion"] },
        "properties": {
          "canonical_payload": {
            "required": ["otp_challenge_id", "otp_verified_at"],
            "not": { "required": ["webauthn"] }
          }
        }
      }
    },
    {
      "$comment": "Passkey signers: identity_method 'passkey' requires webauthn_assertion, NO top-level OTP fields, and a canonical_payload carrying a webauthn claim and NO OTP fields.",
      "if": {
        "required": ["identity_method"],
        "properties": { "identity_method": { "const": "passkey" } }
      },
      "then": {
        "required": ["webauthn_assertion"],
        "not": {
          "anyOf": [
            { "required": ["otp_challenge_id"] },
            { "required": ["otp_verified_at"] }
          ]
        },
        "properties": {
          "canonical_payload": {
            "required": ["webauthn"],
            "not": {
              "anyOf": [
                { "required": ["otp_challenge_id"] },
                { "required": ["otp_verified_at"] }
              ]
            }
          }
        }
      }
    }
  ],
  "properties": {
    "schema": {
      "const": "free-sign.com/evidence/v2",
      "description": "Schema identifier and version. Pins this document to v2."
    },
    "envelope_id": {
      "$ref": "#/$defs/envelopeId",
      "description": "The signing envelope this record belongs to."
    },
    "document_sha256": {
      "$ref": "#/$defs/sha256hex",
      "description": "SHA-256 of the ORIGINAL PDF bytes, before any FreeSign stamping. The hash the signer committed to."
    },
    "signer_name": {
      "type": "string",
      "minLength": 1,
      "description": "The signer's typed legal name. Also the leaf certificate Subject CN and the /Sig /Name field."
    },
    "signer_email": {
      "type": "string",
      "description": "The verified signer email, in plaintext. Present because the signer's own PDF already carries it in the leaf certificate subjectAltName.rfc822Name and the /Sig /ContactInfo field. FreeSign servers store only signer_email_hmac, never this plaintext."
    },
    "signer_email_hmac": {
      "$ref": "#/$defs/sha256hex",
      "description": "Envelope-scoped HMAC-SHA-256 of the signer email (key: EMAIL_HMAC_SECRET). The only form of the email the FreeSign database retains."
    },
    "identity_method": {
      "enum": ["otp", "passkey"],
      "description": "How the signer's identity was verified before signing: 'otp' (emailed one-time code) or 'passkey' (a WebAuthn platform authenticator — biometric or device PIN)."
    },
    "otp_challenge_id": {
      "$ref": "#/$defs/otpId",
      "description": "The one-time-code challenge that was verified before signing. Present only when identity_method is 'otp'."
    },
    "otp_verified_at": {
      "type": ["string", "null"],
      "format": "date-time",
      "description": "UTC timestamp at which the OTP challenge was verified. Present only when identity_method is 'otp'."
    },
    "webauthn_assertion": {
      "$ref": "#/$defs/webauthnAssertion",
      "description": "The WebAuthn assertion that proved identity AND intent. Present only when identity_method is 'passkey'."
    },
    "payload_hash": {
      "$ref": "#/$defs/sha256hex",
      "description": "SHA-256 of canonicalJson(canonical_payload). Recompute it and compare to detect any tampering of the consent payload. For passkey signers it is also the WebAuthn challenge."
    },
    "consent_version": {
      "type": "string",
      "minLength": 1,
      "description": "Label of the consent text the signer accepted (env.CONSENT_VERSION)."
    },
    "consent_text_sha256": {
      "$ref": "#/$defs/sha256hex",
      "description": "SHA-256 of the exact consent text shown to the signer, served by GET /api/consent."
    },
    "document_viewed_at": {
      "type": ["string", "null"],
      "format": "date-time",
      "description": "UTC timestamp at which the signer's browser first rendered the PDF preview, or null if not captured."
    },
    "request_fingerprint": {
      "$ref": "#/$defs/requestFingerprint"
    },
    "created_at": {
      "type": "string",
      "format": "date-time",
      "description": "Server clock at POST /api/envelopes/{id}/sign."
    },
    "canonical_payload": {
      "$ref": "#/$defs/canonicalPayload"
    },
    "signature_base64url": {
      "type": "string",
      "description": "base64url-encoded ECDSA P-256 / SHA-256 signature over canonicalJson(canonical_payload). The primary signature. Verify it offline against public_key_jwk."
    },
    "public_key_jwk": {
      "$ref": "#/$defs/ecPublicJwk"
    },
    "receipt_id": {
      "$ref": "#/$defs/receiptId",
      "description": "The signature receipt this ceremony produced (one per signer)."
    },
    "note": {
      "type": "string",
      "description": "Human-readable provenance note. Informational only — not covered by any signature."
    }
  },
  "$defs": {
    "sha256hex": {
      "type": "string",
      "pattern": "^[a-f0-9]{64}$",
      "description": "Lowercase hexadecimal SHA-256 digest."
    },
    "envelopeId": {
      "type": "string",
      "pattern": "^env_[a-f0-9]{32}$"
    },
    "otpId": {
      "type": "string",
      "pattern": "^otp_[a-f0-9]{32}$"
    },
    "receiptId": {
      "type": "string",
      "pattern": "^rcp_[a-f0-9]{32}$"
    },
    "base64url": {
      "type": "string",
      "minLength": 1,
      "pattern": "^[A-Za-z0-9_-]+$",
      "description": "Unpadded base64url-encoded bytes."
    },
    "webauthnClaim": {
      "type": "object",
      "additionalProperties": false,
      "description": "The passkey identity marker inside canonical_payload, mutually exclusive with the OTP fields. For LEGACY (non-discoverable) signers it pins the credential id the signed payload commits to. For DISCOVERABLE / resident passkeys it is the empty object {} — the browser only learns which credential the authenticator picked AFTER navigator.credentials.get(), so the id cannot be committed in the challenge-bound payload and instead rides on webauthn_assertion.credential_id (bound by the assertion's challenge == payload_hash + its signature).",
      "properties": {
        "credential_id": { "$ref": "#/$defs/base64url" }
      }
    },
    "webauthnAssertion": {
      "type": "object",
      "description": "A WebAuthn authentication (assertion) response. The authenticator signed authenticator_data || SHA-256(client_data_json) with the passkey's private key; client_data_json.challenge equals payload_hash, so the assertion is cryptographically bound to this exact ceremony. Re-verify it offline against cose_public_key.",
      "required": [
        "credential_id",
        "authenticator_data",
        "client_data_json",
        "signature",
        "cose_public_key"
      ],
      "properties": {
        "credential_id": {
          "$ref": "#/$defs/base64url",
          "description": "The passkey credential id the assertion was made with. For legacy signers it equals canonical_payload.webauthn.credential_id; for discoverable/resident passkeys the payload carries only the {} marker and THIS field is the sole carrier of the credential id."
        },
        "authenticator_data": {
          "$ref": "#/$defs/base64url",
          "description": "Raw authenticatorData bytes from navigator.credentials.get()."
        },
        "client_data_json": {
          "$ref": "#/$defs/base64url",
          "description": "Raw clientDataJSON bytes. Its `challenge` member is base64url(payload_hash)."
        },
        "signature": {
          "$ref": "#/$defs/base64url",
          "description": "DER ECDSA-Sig-Value over authenticator_data || SHA-256(client_data_json)."
        },
        "cose_public_key": {
          "$ref": "#/$defs/base64url",
          "description": "The passkey's COSE-encoded EC2 / ES256 / P-256 public key, captured at enrolment. Verify `signature` against it."
        },
        "aaguid": {
          "type": ["string", "null"],
          "description": "Authenticator AAGUID (32 lowercase hex chars), all-zero for attestation 'none'. May be null."
        },
        "sign_count": {
          "type": "number",
          "description": "The authenticator's signature counter at assertion time. 0 for synced passkeys (counter unsupported)."
        },
        "sign_count_regressed": {
          "type": "boolean",
          "description": "True if the counter did not advance versus the stored baseline — a possible cloned-authenticator hint, not a hard failure."
        }
      }
    },
    "canonicalPayload": {
      "type": "object",
      "additionalProperties": false,
      "description": "Exactly the object the browser ECDSA-signed. Field ORDER here is irrelevant: both signer and verifier re-serialize through canonicalJson (recursively key-sorted, no whitespace, UTF-8) before hashing and verifying. Exactly one identity proof is present: otp_challenge_id + otp_verified_at (OTP signer) XOR webauthn (passkey signer). Adding or removing any field changes signature_base64url, so this object set is frozen for v2.",
      "required": [
        "app",
        "envelope_id",
        "document_sha256",
        "email_hmac",
        "signer_name",
        "consent_version",
        "consent_text_sha256",
        "document_viewed_at",
        "signed_at",
        "user_agent_sha256"
      ],
      "properties": {
        "app": {
          "const": "free-sign.com",
          "description": "Domain constant that scopes the signature."
        },
        "envelope_id": { "$ref": "#/$defs/envelopeId" },
        "document_sha256": { "$ref": "#/$defs/sha256hex" },
        "email_hmac": {
          "$ref": "#/$defs/sha256hex",
          "description": "Envelope-scoped HMAC of the signer email (equals the top-level signer_email_hmac)."
        },
        "signer_name": { "type": "string", "minLength": 1 },
        "otp_challenge_id": {
          "$ref": "#/$defs/otpId",
          "description": "OTP signers only — the verified one-time-code challenge."
        },
        "otp_verified_at": {
          "type": "string",
          "format": "date-time",
          "description": "OTP signers only — when the challenge was verified."
        },
        "webauthn": {
          "$ref": "#/$defs/webauthnClaim",
          "description": "Passkey signers only — the credential the assertion was made with."
        },
        "consent_version": { "type": "string", "minLength": 1 },
        "consent_text_sha256": { "$ref": "#/$defs/sha256hex" },
        "document_viewed_at": {
          "type": "string",
          "format": "date-time",
          "description": "Browser preview-render time (never null inside the signed payload — the browser substitutes signing time if no view was recorded)."
        },
        "signed_at": {
          "type": "string",
          "format": "date-time",
          "description": "Browser clock at the moment the ceremony was signed."
        },
        "user_agent_sha256": {
          "$ref": "#/$defs/sha256hex",
          "description": "SHA-256 of the signer's browser User-Agent string. A hashed fingerprint, not the raw UA."
        }
      }
    },
    "ecPublicJwk": {
      "type": "object",
      "description": "The signer's ephemeral browser ECDSA P-256 public key, as exported by WebCrypto. Verifies BOTH signature_base64url and the receipts-only final_signature_base64url. The matching private key was non-extractable and is gone once the browser tab closes. WebCrypto may also emit ext / key_ops members; they are not significant.",
      "required": ["kty", "crv", "x", "y"],
      "properties": {
        "kty": { "const": "EC" },
        "crv": { "const": "P-256" },
        "x": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{43}$",
          "description": "base64url EC public-key X coordinate (32 bytes)."
        },
        "y": {
          "type": "string",
          "pattern": "^[A-Za-z0-9_-]{43}$",
          "description": "base64url EC public-key Y coordinate (32 bytes)."
        },
        "ext": { "type": "boolean" },
        "key_ops": { "type": "array", "items": { "type": "string" } }
      }
    },
    "requestFingerprint": {
      "type": ["object", "null"],
      "description": "Request metadata captured server-side at POST /sign — the standard signing-act evidence DocuSign and Adobe Sign also retain. Cloudflare-only fields (cf) are null in local development. The same object is folded into every audit-chain row. May be null if not captured.",
      "properties": {
        "cf_connecting_ip": { "type": ["string", "null"], "description": "Connecting IP address." },
        "x_forwarded_for": {
          "type": ["array", "null"],
          "items": { "type": "string" },
          "description": "The X-Forwarded-For proxy chain, if any."
        },
        "x_real_ip": { "type": ["string", "null"] },
        "true_client_ip": { "type": ["string", "null"] },
        "user_agent": { "type": ["string", "null"] },
        "accept_language": { "type": ["string", "null"] },
        "referer": { "type": ["string", "null"] },
        "sec_ch_ua": { "type": ["string", "null"] },
        "sec_ch_ua_platform": { "type": ["string", "null"] },
        "sec_ch_ua_mobile": { "type": ["string", "null"] },
        "cf": {
          "type": ["object", "null"],
          "description": "Cloudflare edge metadata: country, region, timezone, ASN, colo, TLS version, etc. Shape varies by request.",
          "additionalProperties": true
        },
        "captured_at": { "type": "string", "format": "date-time" }
      },
      "additionalProperties": true
    }
  }
}
