Table of Contents
- Overview
- Cryptographic Primitives
- W3C Verifiable Credential Structure
- Merkle Transparency Log
- DID Infrastructure
- PDF Embedded Files
- Signing Flow (Step by Step)
- Verification Flow (Step by Step)
- API Reference
- Offline Verification
- Security Model
- Comparison with Other Approaches
Overview
SignForge uses a multi-layered verification system that provides tamper evidence, portable proof, and public auditability for every signed document. The system is built on open standards and does not depend on any third-party trust authority.
Core principle: Your proof lives with the document, not locked in any vendor's database.
What SignForge Verification IS
- A platform-issued receipt — SignForge attesting as a witness that a signing event occurred
- Built on the W3C VC 2.0 model — machine-readable, open-standard
- Offline-verifiable — embedded public keys allow verification without contacting SignForge
- Publicly auditable — Merkle transparency log provides cryptographic proof of existence and ordering
- EU Digital Identity Wallet aligned — built on the same W3C VC foundation that EU wallets target
What SignForge Verification is NOT
- NOT a Qualified Electronic Signature (QES) — we don't use government-issued certificates
- NOT an Adobe AATL signature — we use an open trust model instead of a vendor-managed trust list
- NOT a signer-held PKI signature — SignForge signs the receipt as platform attestation; signers do not hold private keys
- NOT a blockchain — we use a Merkle tree (same math, no chain dependency)
Cryptographic Primitives
Algorithms Used
| Purpose | Algorithm | Standard | Key Size |
|---|---|---|---|
| VC signing | ECDSA P-256 (secp256r1) | FIPS 186-4, W3C DataIntegrityProof | 256-bit |
| STH signing | ECDSA P-256 (secp256r1) | Same | 256-bit |
| Document hashing | SHA-256 | FIPS 180-4 | 256-bit |
| Merkle tree | SHA-256 with domain separation | RFC 6962 pattern | 256-bit |
| Legacy JSON signing | ECDSA P-256 | Custom canonical JSON | 256-bit |
| Key encoding | Multibase (base58btc) | W3C Multibase, Multicodec | Variable |
Domain-Separated Hashing (Merkle Tree)
To prevent second-preimage attacks, the Merkle tree uses domain-separated hashing:
Leaf hash: SHA-256(0x00 || data)
Node hash: SHA-256(0x01 || left || right)
This follows the same pattern used in Certificate Transparency (RFC 6962).
Two-Key Architecture
SignForge uses two independent ECDSA P-256 keys:
| Key | Purpose | Identifier |
|---|---|---|
| Issuer key | Signs W3C VCs and legacy verification JSON | did:key:zIssuer... |
| Log key | Signs Signed Tree Heads (STH) | did:key:zLog... |
Why two keys?
- Compromise of the issuer key doesn't let an attacker forge the transparency log
- Compromise of the log key doesn't let an attacker issue fake VCs
- The log key signs checkpoints (STH), preventing self-referential proofs where a document proves itself
W3C Verifiable Credential Structure
Every completed signing produces a W3C VC 2.0 credential:
{
"@context": [
"https://www.w3.org/ns/credentials/v2"
],
"type": ["VerifiableCredential", "SigningReceipt"],
"id": "https://signforge.io/verify/sf_abc123def456",
"issuer": {
"id": "did:key:zDnae...",
"name": "SignForge",
"alsoKnownAs": "did:web:signforge.io"
},
"validFrom": "2026-04-09T12:00:00Z",
"credentialSubject": {
"type": "SigningEvent",
"envelopeId": "550e8400-e29b-41d4-a716-446655440000",
"envelopeTitle": "Service Agreement",
"documentHash": {
"algorithm": "sha256",
"value": "a1b2c3d4e5f6..."
},
"signedDocumentHash": {
"algorithm": "sha256",
"value": "f6e5d4c3b2a1..."
},
"documentSizeBytes": 145832,
"sender": {
"name": "Alice Johnson",
"email": "alice@company.com"
},
"signers": [
{
"name": "Bob Smith",
"email": "bob@client.com",
"signedAt": "2026-04-09T12:00:00Z"
}
],
"fieldTypes": ["signature", "date"],
"pageCount": 3,
"verificationCode": "sf_abc123def456"
},
"proof": {
"type": "DataIntegrityProof",
"cryptosuite": "ecdsa-jcs-2019",
"created": "2026-04-09T12:00:00Z",
"verificationMethod": "did:key:zDnae...#zDnae...",
"proofPurpose": "assertionMethod",
"proofValue": "z3hY9..."
}
}
Privacy Note
The embedded VC includes sender and signer names/emails. This is intentional — the VC is a signing receipt, and the identities of the parties are core to its purpose. The VC is embedded inside the signed PDF (which already contains this information) and is not published to the transparency log. The log contains only document hashes, not personal data. Future versions may support redacted or minimized embedded receipts for privacy-sensitive workflows.
Proof Generation (DataIntegrityProof)
The proof is generated using the ecdsa-jcs-2019 cryptosuite:
- Canonicalize proof options (everything in
proofexceptproofValue) using JCS (RFC 8785) - Hash the canonicalized proof options:
SHA-256(proof_options_canonical) - Canonicalize the VC (without
prooffield) using JCS - Hash the canonicalized VC:
SHA-256(vc_canonical) - Concatenate both hashes:
proof_hash || vc_hash - Sign the concatenation with ECDSA P-256 using the issuer key
- Encode the signature as multibase base58btc (
zprefix)
Proof Verification
- Extract
prooffrom the VC - Resolve
verificationMethodto get the public key (from did:key or embedded keys) - Repeat steps 1-5 from generation
- Verify the ECDSA signature against the concatenated hash using the public key
Embedded Copy vs. Downloadable Copy
Two copies of the VC exist:
| Copy | Location | Contains signedDocumentHash? | Purpose |
|---|---|---|---|
| Embedded | Inside the signed PDF as signforge_receipt.vc.json | No (null) | Proves the VC was created at signing time. Self-contained with the document. |
| Downloadable | Available via API | Yes | Includes the final signed PDF hash (which can't be known until after embedding) |
The embedded copy is the self-contained proof bundle — it travels with the document and works offline. The downloadable copy adds the signed document hash, which is useful for cross-referencing with the transparency log.
Merkle Transparency Log
Data Structure: Merkle Mountain Range (MMR)
The transparency log uses a Merkle Mountain Range, an append-only variant of a Merkle tree optimized for streaming data:
- O(log n) peak hashes stored (not the full tree)
- O(log n) hash operations per append
- O(log n) proof size for inclusion verification
- Suitable for millions of entries with sub-millisecond performance
Append Operation
State: tree_size=4, peaks=[root_of_4]
Append leaf 5:
1. Hash the leaf: leaf_hash = SHA-256(0x00 || document_hash)
2. Start with leaf_hash, check if bit 0 of tree_size is set
3. If set: merge with rightmost peak (pop peak, hash_node)
4. Repeat for each set bit
5. Push remaining hash as new peak
6. Compute root by folding all peaks right-to-left
7. Generate Merkle proof (merge steps + peak fold steps)
8. Sign the root as STH
State: tree_size=5, peaks=[root_of_4, leaf_5_hash]
Merkle Proof Format
[
{"hash": "abc123...", "position": "left"},
{"hash": "def456...", "position": "right"}
]
Each step in the proof indicates whether the sibling hash should be placed on the left or right when computing the parent hash.
Signed Tree Head (STH)
Every append produces a Signed Tree Head:
{
"log_id": "did:key:zLog...",
"tree_size": 42,
"root_hash": "a1b2c3d4...",
"timestamp": "2026-04-09T12:00:00.000000+00:00",
"signature": "z3hY9..."
}
The STH is signed by the log key (not the issuer key). This prevents self-referential proofs — the issuer cannot forge log entries without the log key, and vice versa.
Log Entry Contents
Each transparency log entry contains:
| Field | Description |
|---|---|
id | Sequential entry number |
document_hash | SHA-256 of the signed PDF (64 hex chars) |
envelope_id | Random UUID (no personal data) |
verification_code | Document verification code (e.g., sf_abc123) |
merkle_root | Tree root hash at time of this append |
merkle_proof | Inclusion proof (array of hash + position pairs) |
signed_tree_head | STH signed by the log key |
created_at | Timestamp of log entry |
The log does not contain document content, signer names, email addresses, or any personally identifiable information.
DID Infrastructure
did:key (Primary)
did:key is a Decentralized Identifier method from the W3C DID specification ecosystem. The did:key method itself is a W3C Credentials Community Group draft — widely adopted but not yet a full W3C Recommendation.
Identifiers are derived deterministically from public key bytes:
1. Get compressed public key bytes (33 bytes for P-256)
2. Prepend multicodec prefix: 0x80 0x24 (p256-pub)
3. Encode as multibase base58btc (prefix 'z')
4. Result: did:key:zDnae...
Advantages:
- Offline-resolvable — no HTTP needed, the DID IS the public key
- Domain-independent — works even if signforge.io is down
- Deterministic — same key always produces the same DID
did:web (Alias)
did:web:signforge.io resolves to https://signforge.io/.well-known/did.json:
{
"@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"],
"id": "did:web:signforge.io",
"alsoKnownAs": ["did:key:zIssuer..."],
"verificationMethod": [
{
"id": "did:web:signforge.io#issuer-key-1",
"type": "Multikey",
"controller": "did:web:signforge.io",
"publicKeyMultibase": "zDnae..."
},
{
"id": "did:web:signforge.io#log-key-1",
"type": "Multikey",
"controller": "did:web:signforge.io",
"publicKeyMultibase": "zDnae..."
}
],
"assertionMethod": ["did:web:signforge.io#issuer-key-1"],
"service": [
{
"id": "did:web:signforge.io#transparency-log",
"type": "TransparencyLog",
"serviceEndpoint": "https://signforge.io/api/transparency"
},
{
"id": "did:web:signforge.io#verification",
"type": "VerificationService",
"serviceEndpoint": "https://signforge.io/api/verify"
}
]
}
Purpose: Human-friendly discovery, service endpoint resolution, "Where do I check the log?" NOT used for: Proof verification (always use did:key for that)
PDF Embedded Files
Every signed PDF contains up to 3 embedded files (accessible via any PDF library that supports embedded file extraction):
1. signforge_verification.json (Legacy)
The original verification data with ECDSA signature. Backward-compatible with pre-VC documents.
{
"version": 1,
"verification_code": "sf_abc123",
"envelope_title": "Service Agreement",
"sender": {"name": "Alice", "email": "alice@co.com"},
"signer": "Bob Smith",
"signed_at": "2026-04-09T12:00:00Z",
"original_sha256": "...",
"page_count": 3,
"signature": "3045022100..."
}
2. signforge_receipt.vc.json (W3C VC)
The W3C Verifiable Credential 2.0 signing receipt. See VC Structure above.
Note: The embedded copy has signedDocumentHash: null because including the hash would change the PDF, which would change the hash (circular dependency).
3. signforge_keys.json (Public Keys)
Both public keys in JWK format, enabling offline verification:
{
"version": 1,
"issuer": {
"did_key": "did:key:zDnae...",
"fingerprint": "sha256:abc123...",
"algorithm": "ECDSA P-256",
"purpose": "Signs W3C Verifiable Credential signing receipts",
"publicKeyJwk": {
"kty": "EC",
"crv": "P-256",
"x": "base64url...",
"y": "base64url..."
}
},
"log": {
"did_key": "did:key:zDnae...",
"fingerprint": "sha256:def456...",
"algorithm": "ECDSA P-256",
"purpose": "Signs Merkle tree Signed Tree Heads (STH)",
"publicKeyJwk": {
"kty": "EC",
"crv": "P-256",
"x": "base64url...",
"y": "base64url..."
}
}
}
Extraction Example (Python)
import fitz # PyMuPDF
import json
doc = fitz.open("signed_document.pdf")
# Extract VC
vc_bytes = doc.embfile_get("signforge_receipt.vc.json")
vc = json.loads(vc_bytes)
# Extract keys
keys_bytes = doc.embfile_get("signforge_keys.json")
keys = json.loads(keys_bytes)
# Extract legacy verification
legacy_bytes = doc.embfile_get("signforge_verification.json")
legacy = json.loads(legacy_bytes)
Signing Flow (Step by Step)
When all recipients have signed an envelope:
1. COLLECT all field values (signatures, dates, text)
2. STAMP the PDF
a. Render signature images at normalized coordinates
b. Add QR code (bottom-right of last page)
c. Build legacy verification JSON + ECDSA signature
d. Build W3C VC (without signedDocumentHash — see circular dependency note)
e. Sign the VC with DataIntegrityProof
f. Build keys document (both public keys in JWK)
g. Embed all 3 JSON files in the PDF
h. Compute SHA-256 of the stamped PDF
3. STORE the signed PDF
4. CREATE verification record
(independent of the envelope — survives if envelope is deleted)
5. BUILD downloadable receipt
a. Re-build VC with signedDocumentHash + documentSizeBytes
b. Re-sign with DataIntegrityProof
c. Store as downloadable receipt
6. APPEND to transparency log
a. Compute leaf hash from signed PDF SHA-256
b. Append to Merkle Mountain Range
c. Sign the new tree head with log key
d. Store log entry with Merkle proof + STH
7. GENERATE audit certificate
(includes transparency log section if available)
8. SEND completion emails to all parties
Degraded States
Steps 5-6 (downloadable receipt + transparency log) are best-effort. If they fail, the signing still completes successfully. The embedded VC and verification JSON in the PDF are already committed at step 2. In this degraded state:
- The signed PDF is still fully valid with embedded proof
- Verification by code or hash still works (via the verification record)
- The transparency log entry and downloadable receipt may be missing
- The system logs the failure for operator investigation
The system records degraded-state events for operator review and remediation. This ensures a signing event is never blocked by a non-critical enhancement.
Verification Flow (Step by Step)
Upload Verification (POST /api/verify/upload)
The system uses a multi-strategy cascade, trying the strongest method first:
Strategy 0: W3C VC Verification (highest priority)
- Extract signforge_receipt.vc.json from PDF
- Verify DataIntegrityProof signature using embedded or known public key
- If valid: enrich with transparency log data (if available)
- Works offline with embedded keys
Strategy 1: Embedded JSON + Record Lookup
- Extract signforge_verification.json from PDF
- Look up verification_code in records
- Compare uploaded file hash with stored hash
Strategy 2: Hash Lookup
- Compute SHA-256 of uploaded PDF
- Look up in records by hash
Strategy 3: ECDSA Signature Verification (offline fallback)
- Extract signforge_verification.json from PDF
- Verify the ECDSA signature using the public key
- Works even if SignForge's database is unavailable
Strategy 4: Not found
- Return verified=false with the computed hash
Code Verification (GET /api/verify/{code})
Direct lookup by verification code, enriched with transparency log data when available.
API Reference
Transparency Log API (Public, No Auth)
| Endpoint | Method | Description | Rate Limit |
|---|---|---|---|
/api/transparency/latest | GET | Current log state (tree_size, root, STH) | 30/min |
/api/transparency/hash/{hash} | GET | Look up by document SHA-256 hash (64 hex chars) | 30/min |
/api/transparency/entry/{id} | GET | Look up by sequential entry ID | 30/min |
/api/transparency/keys | GET | Public keys document (issuer + log keys in JWK) | 30/min |
/api/transparency/did.json | GET | DID:web document (also at /.well-known/did.json) | 30/min |
Receipt Download API
| Endpoint | Method | Auth | Description |
|---|---|---|---|
/api/sign/{token}/documents/receipt | GET | Signing token | Download VC JSON via signing token |
/api/envelopes/{id}/documents/receipt | GET | JWT (sender) | Download VC JSON via envelope ID |
Verification API (Public, No Auth)
| Endpoint | Method | Description | Rate Limit |
|---|---|---|---|
/api/verify/{code} | GET | Verify by code (includes transparency data) | 30/min |
/api/verify/upload | POST | Upload PDF for verification (multi-strategy cascade) | 10/min |
Offline Verification
Truth Table
| Scenario | VC Signature | Merkle Proof | STH | Live Log | Overall |
|---|---|---|---|---|---|
| SignForge online | Verify via API or embedded keys | Verify against live root | Verify with log key | Check latest state | Full verification |
| SignForge offline, PDF + proof file | Verify with embedded keys | Self-consistent check | Verify with embedded log key | Unavailable | Strong (self-consistent proof bundle) |
| SignForge offline, PDF only | Verify with embedded keys | Unavailable | Unavailable | Unavailable | Good (issuer attestation only) |
| No prior trust anchor, no internet | Math works | Math works | Math works | Unavailable | Math valid, issuer identity unverifiable* |
*This is a fundamental limit of all PKI systems. Without a prior trust anchor (a cached DID document, a previously verified SignForge document, or an external directory listing), you cannot confirm that the key belongs to SignForge specifically — only that the signatures are internally consistent.
Offline Verification Example (Python)
import json
import hashlib
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes
# 1. Extract embedded files from PDF
import fitz
doc = fitz.open("signed_document.pdf")
vc = json.loads(doc.embfile_get("signforge_receipt.vc.json"))
keys = json.loads(doc.embfile_get("signforge_keys.json"))
# 2. Get the public key from embedded keys
issuer_jwk = keys["issuer"]["publicKeyJwk"]
# ... decode JWK to public key object ...
# 3. Verify the VC proof
proof = vc.pop("proof")
proof_options = {k: v for k, v in proof.items() if k != "proofValue"}
# JCS canonicalize
proof_canonical = json.dumps(proof_options, sort_keys=True, separators=(',', ':'))
vc_canonical = json.dumps(vc, sort_keys=True, separators=(',', ':'))
# Hash both
proof_hash = hashlib.sha256(proof_canonical.encode()).digest()
vc_hash = hashlib.sha256(vc_canonical.encode()).digest()
# Verify ECDSA signature
combined = proof_hash + vc_hash
signature_bytes = base58btc_decode(proof["proofValue"])
public_key.verify(signature_bytes, combined, ec.ECDSA(utils.Prehashed(hashes.SHA256())))
# If no exception: signature is valid
Security Model
What We Guarantee
- Tamper evidence: Any modification to the signed PDF changes the SHA-256 hash, breaking verification
- Issuer attestation: The ECDSA signature on the VC can only be produced by SignForge's issuer key
- Temporal ordering: The Merkle tree provides a total ordering of all signed documents
- Non-repudiation of log: The STH is signed by a separate key, preventing forged log entries
- Portable proof: Verification data is embedded in the PDF, not locked in our database
What We Do NOT Guarantee
- Signer identity: We verify email delivery, not government ID. The signer is "whoever clicked the link"
- Document content: We attest that THIS document was signed, not that its content is truthful
- Legal validity: E-signatures are legally valid in most jurisdictions (ESIGN Act, eIDAS), but specific compliance depends on the use case
- Key secrecy: If our keys are compromised, past signatures remain valid but new ones could be forged until key rotation
Threat Model
| Threat | Mitigation |
|---|---|
| Forged VC | ECDSA P-256 with 128-bit security level |
| Forged log entry | Separate log key; STH prevents self-referential proofs |
| Modified PDF | SHA-256 hash mismatch detected on verification |
| Database loss | Embedded files in PDF enable database-free verification |
| Domain seizure | did:key (offline) is primary; did:web is just an alias |
| Key compromise | Two-key separation limits blast radius |
Comparison with Other Approaches
vs. Adobe AATL (Approved Trust List)
| Aspect | SignForge | Adobe AATL |
|---|---|---|
| Cost | Free | Requires a paid signing certificate |
| Trust model | Open standards (W3C VC, DID) | Vendor-managed trust list |
| Reader integration | Verify via web, API, or embedded keys | Blue badge in Adobe Acrobat |
| Offline verification | Yes (embedded keys + VC) | Yes (certificate chain) |
| Open standard | W3C VC 2.0, DID | PDF 2.0 digital signatures |
AATL is best for workflows that need native Adobe Acrobat trust indicators. SignForge is optimized for portable, tool-agnostic verification — built on open standards (W3C VC, DID, Merkle proofs) that work across any tool, online and offline, without requiring a paid certificate.
vs. Blockchain Timestamping
| Aspect | SignForge | Blockchain |
|---|---|---|
| Data structure | Merkle tree (MMR) | Merkle tree + consensus |
| Write cost | Negligible | Gas fees ($0.01-$50+) |
| Latency | Milliseconds | Seconds to minutes |
| Dependency | SignForge servers | Blockchain network |
| Verification | Public API + offline | Blockchain node/explorer |
| Guarantees | Tamper-evident, ordered | Tamper-evident, ordered, decentralized |
Trade-off: Blockchain adds decentralization (no single operator can tamper). Our log is operated by SignForge, but proofs are embedded in every PDF and distributed to all signing parties, making tampering detectable.
vs. Certificate Transparency (CT)
| Aspect | SignForge | CT (RFC 6962) |
|---|---|---|
| Purpose | Document signing receipts | TLS certificate logging |
| Tree type | MMR (peaks only) | Full Merkle tree |
| Signed checkpoints | STH (every append) | STH (periodic) |
| Public monitoring | API only | Multiple independent logs |
| Auditing | Single operator (SignForge) | Multiple CT log operators |
Similarity: We follow the same patterns (domain-separated hashing, signed tree heads, inclusion proofs) adapted for document signing.
For AI Tools (ChatGPT, Claude, etc.)
Quick Verification Prompt
To verify a SignForge document:
- Hash the PDF:
sha256sum document.pdf - Check the log:
GET https://signforge.io/api/transparency/hash/{hash} - If found: The document exists in the transparency log with entry #{id}
- If not found: Either not signed through SignForge, or the PDF was modified after signing
Note: Log absence alone is not conclusive — the document may have been generated during a degraded-state event where the transparency log append did not complete. Verify using the embedded VC or verification code as fallback.
Extracting Embedded Data
import fitz, json
doc = fitz.open("signed.pdf")
names = doc.embfile_names() # ['signforge_verification.json', 'signforge_receipt.vc.json', 'signforge_keys.json']
vc = json.loads(doc.embfile_get("signforge_receipt.vc.json"))
print(vc["credentialSubject"]["verificationCode"]) # sf_abc123
DID Resolution
# Online (did:web)
curl https://signforge.io/.well-known/did.json
# Offline (did:key) — the DID IS the public key
# did:key:zDnae... → decode multibase → strip multicodec prefix → compressed P-256 public key