Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 232 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
# Formidable eSign

Sign a document without sending the document or its URL to Formidable eSign.

## 1. Hash the document locally
## Setup

```bash
DOCUMENT_HASH=$(openssl dgst -sha256 -r document.pdf | cut -d' ' -f1)
echo "$DOCUMENT_HASH"
export FESIGN_API_URL="https://api.fesign.formidable.care"
export FESIGN_API_KEY="..."
export FESIGN_CERT_ID="..."
export FESIGN_PIN="..."
```

The result is a 64-character SHA-256 hash. The same document bytes always
produce the same hash; any change produces a different hash.
## Sign a JSON / FHIR document

Hash the file locally, then sign the hash. Use the same file bytes when
verifying.

## 2. Sign the hash
### Hash and sign

```bash
DOCUMENT_HASH=$(openssl dgst -sha256 -r patient.fhir.json | cut -d' ' -f1)

SIGNATURE=$(
jq -n \
--arg hash "$DOCUMENT_HASH" \
Expand All @@ -30,10 +34,7 @@ SIGNATURE=$(
)
```

Only the hash is signed. Do not include the document URL, filename, or document
contents in the request or optional metadata.

## 3. Verify with Formidable eSign
### Verify with Formidable eSign

```bash
jq -n \
Expand All @@ -47,13 +48,9 @@ curl --silent --fail-with-body \
--data-binary @-
```

A valid response returns `"isValid": true` and the certificate, chain, hash,
and signature checks.
A valid response returns `"isValid": true`.

## 4. Verify locally

Decode the returned signature, fetch the Formidable eSign trust anchor, and
verify the original document with OpenSSL:
### Verify locally

```bash
printf '%s' "$SIGNATURE" |
Expand All @@ -70,14 +67,227 @@ openssl cms -verify \
-binary \
-inform DER \
-in signature.p7s \
-content document.pdf \
-content patient.fhir.json \
-CAfile fesign-root-ca.crt \
-purpose any \
-out /dev/null
```

OpenSSL exits successfully only when the document hash, signature, and
certificate chain are valid.
Local verification does not check certificate revocation.

## Sign a PDF

Upload the PDF and save the signed PDF returned by Formidable eSign.

```bash
curl --silent --fail-with-body \
--request POST "$FESIGN_API_URL/documents/signPDF" \
--header "x-api-key: $FESIGN_API_KEY" \
--form "pdf=@document.pdf;type=application/pdf" \
--form "certId=$FESIGN_CERT_ID" \
--form "pin=$FESIGN_PIN" |
jq -r '.signedPdf' |
base64 --decode > signed-document.pdf
```

Maximum size: 10 MB. Verify the result in Adobe Acrobat or with
[`pdfsig`](https://manpages.debian.org/pdfsig):

```bash
pdfsig signed-document.pdf
```

## JavaScript (Node.js)

Uses Node.js 20+ built-ins: [`node:crypto`](https://nodejs.org/api/crypto.html),
`fetch`, `FormData`, and `Blob`. No npm package is required.

```javascript
// esign.mjs
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";

const apiUrl = httpsUrl(required("FESIGN_API_URL"));
const apiKey = required("FESIGN_API_KEY");
const certId = required("FESIGN_CERT_ID");
const pin = required("FESIGN_PIN");

// JSON / FHIR: hash locally, sign only the hash, then verify it.
const fhir = await readFile("patient.fhir.json");
const hash = createHash("sha256").update(fhir).digest("hex");

const signedHash = await postJson("/documents/signHash", {
hash,
certId,
pin,
});

const verification = await postJson("/documents/validate", {
hash,
signature: signedHash.signature,
});

if (!verification.isValid) throw new Error("FHIR signature is invalid");
await writeFile("patient.fhir.signature", signedHash.signature);

// PDF: upload the bytes and save the returned PDF with its embedded signature.
const pdf = await readFile("document.pdf");
const form = new FormData();
form.append("pdf", new Blob([pdf], { type: "application/pdf" }), "document.pdf");
form.append("certId", certId);
form.append("pin", pin);

const pdfResponse = await fetch(`${apiUrl}/documents/signPDF`, {
method: "POST",
headers: { "x-api-key": apiKey },
body: form,
});
if (!pdfResponse.ok) throw new Error(await pdfResponse.text());

const signedPdf = await pdfResponse.json();
await writeFile("signed-document.pdf", Buffer.from(signedPdf.signedPdf, "base64"));

async function postJson(path, body) {
const response = await fetch(`${apiUrl}${path}`, {
method: "POST",
headers: {
"x-api-key": apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) throw new Error(await response.text());
return response.json();
}

function required(name) {
const value = process.env[name];
if (!value?.trim()) throw new Error(`Missing ${name}`);
return value;
}

function httpsUrl(value) {
const url = new URL(value);
if (url.protocol !== "https:") throw new Error("FESIGN_API_URL must use HTTPS");
return url.href.replace(/\/$/, "");
}
```

```bash
node esign.mjs
pdfsig signed-document.pdf
```

## .NET (C#)

Uses `SHA256`, `HttpClient`, and
[`SignedCms`](https://learn.microsoft.com/dotnet/api/system.security.cryptography.pkcs.signedcms).
Add the PKCS package for local JSON/FHIR verification:

```bash
dotnet add package System.Security.Cryptography.Pkcs
```

```csharp
// Program.cs — .NET 8+
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Security.Cryptography.Pkcs;
using System.Security.Cryptography.X509Certificates;
using System.Text.Json;

var apiUrl = new Uri(Required("FESIGN_API_URL").TrimEnd('/') + "/");
if (apiUrl.Scheme != Uri.UriSchemeHttps)
throw new InvalidOperationException("FESIGN_API_URL must use HTTPS");
var apiKey = Required("FESIGN_API_KEY");
var certId = Required("FESIGN_CERT_ID");
var pin = Required("FESIGN_PIN");

using var client = new HttpClient { BaseAddress = apiUrl };
client.DefaultRequestHeaders.Add("x-api-key", apiKey);

// JSON / FHIR: hash locally and sign only the hash.
var fhir = await File.ReadAllBytesAsync("patient.fhir.json");
var hash = Convert.ToHexString(SHA256.HashData(fhir)).ToLowerInvariant();

using var signResponse = await client.PostAsJsonAsync(
"documents/signHash",
new { hash, certId, pin });
signResponse.EnsureSuccessStatusCode();

using var signJson = JsonDocument.Parse(await signResponse.Content.ReadAsStreamAsync());
var signature = signJson.RootElement.GetProperty("signature").GetString()
?? throw new CryptographicException("Signature missing");
await File.WriteAllTextAsync("patient.fhir.signature", signature);

// Verify with Formidable eSign.
using var verifyResponse = await client.PostAsJsonAsync(
"documents/validate",
new { hash, signature });
verifyResponse.EnsureSuccessStatusCode();

using var verifyJson = JsonDocument.Parse(await verifyResponse.Content.ReadAsStreamAsync());
if (!verifyJson.RootElement.GetProperty("isValid").GetBoolean())
throw new CryptographicException("FHIR signature is invalid");

// Verify the detached CMS signature and its certificate chain locally.
using var envelope = JsonDocument.Parse(Convert.FromBase64String(signature));
var cmsBytes = Convert.FromBase64String(
envelope.RootElement.GetProperty("signature").GetString()
?? throw new CryptographicException("CMS signature missing"));

var cms = new SignedCms(new ContentInfo(fhir), detached: true);
cms.Decode(cmsBytes);
cms.CheckSignature(verifySignatureOnly: true);

var rootPem = await client.GetStringAsync("certificates/root-ca");
using var root = X509Certificate2.CreateFromPem(rootPem);
var signer = cms.SignerInfos[0].Certificate
?? throw new CryptographicException("Signer certificate missing");

using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(root);
chain.ChainPolicy.ExtraStore.AddRange(cms.Certificates);
chain.ChainPolicy.ApplicationPolicy.Add(new Oid("1.3.6.1.5.5.7.3.4"));
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
if (!chain.Build(signer))
throw new CryptographicException("Certificate chain is invalid");

// PDF: upload the bytes and save the PDF with its embedded signature.
var pdf = await File.ReadAllBytesAsync("document.pdf");
using var pdfContent = new ByteArrayContent(pdf);
pdfContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");

using var form = new MultipartFormDataContent
{
{ pdfContent, "pdf", "document.pdf" },
{ new StringContent(certId), "certId" },
{ new StringContent(pin), "pin" },
};

using var pdfResponse = await client.PostAsync("documents/signPDF", form);
pdfResponse.EnsureSuccessStatusCode();

using var pdfJson = JsonDocument.Parse(await pdfResponse.Content.ReadAsStreamAsync());
var signedPdf = Convert.FromBase64String(
pdfJson.RootElement.GetProperty("signedPdf").GetString()
?? throw new CryptographicException("Signed PDF missing"));
await File.WriteAllBytesAsync("signed-document.pdf", signedPdf);

static string Required(string name)
{
var value = Environment.GetEnvironmentVariable(name);
return !string.IsNullOrWhiteSpace(value)
? value
: throw new InvalidOperationException($"Missing {name}");
}
```

```bash
dotnet run
pdfsig signed-document.pdf
```

## More

Expand Down