Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PORT=3000
DATA_DIR=./data
MAX_UPLOAD_MB=25
HORIZON_URL=https://horizon.stellar.org
STELLAR_RPC_URL=https://mainnet.sorobanrpc.com
# PUBLIC_BASE_URL=https://sign.example.org
SOROBAN_RPC_URL=https://mainnet.sorobanrpc.com
# SOROBAN_ANCHOR_CONTRACT_ID=C...
Expand Down
131 changes: 88 additions & 43 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,46 @@
# Stellar PDF Signer
# PDF Magic Signer

A full-stack document signer built around [Stellar SEP-53](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md).
PDF Magic Signer creates independently verifiable signatures for PDF documents with Stellar wallets. The application uses SEP-53 signed messages for the cryptographic signature and Stellar RPC for signer-authority reads. It is non-custodial: wallet software performs the signature, and the server never receives a Stellar secret key.

The document-signing path is deliberately off-chain: users choose a wallet through Stellar Wallets Kit, review the PDF, and sign one canonical message without submitting a Stellar transaction or exposing their secret key. The server then checks the actual wallet public key, verifies the SEP-53 signature, and confirms the key's signer authority for the represented account through SDF Horizon before publishing a share URL. Deployments can optionally offer a Soroban mainnet anchor that commits only a digest of the finalized proof.
The document signature itself is off-chain. Deployments can optionally anchor a SHA-256 digest of the finalized proof to Stellar mainnet through Soroban; the PDF and signer identity remain off-chain. Stellar is used as the signer-authorization source of truth: the server reads the represented account's ledger entry through Stellar RPC, checks the wallet key's current signer weight and account thresholds, and binds that snapshot into the signed message.

## What a proof contains

Each published proof binds the exact PDF and the signer-authority snapshot that was checked when the signature was created:

- SHA-256 of the PDF
- claimed signing timestamp
- display name
- wallet public key that produced the SEP-53 signature
- represented Stellar account
- signer weight
- low, medium, and high account thresholds
- account ledger sequence at which the account entry was last modified
- optional visual-insignia SHA-256

The public proof page exposes the raw canonical message and signature so the record can be independently verified.

## Signing flow

1. **Choose a wallet.** Stellar Wallets Kit presents the supported-wallet picker instead of assuming Freighter.
2. **Choose the represented account.** This can be the wallet's own account or another G-account for which the wallet key is an added signer.
3. **Verify authority.** The server loads the represented account from Horizon and checks that the wallet public key appears in its signer list with non-zero weight.
4. **Review the document.** The exact PDF is shown in the signing view and SHA-256 hashed locally.
5. **Review the identity.** A display name and optional PNG/JPEG/WebP visual insignia can be attached.
6. **Sign.** Immediately before signing, the app fetches the wallet address again. After SEP-53 signing, it checks any signer address returned by the wallet against the key that was reviewed.
7. **Recheck on the server.** The server verifies the signature and file hashes, reloads the Horizon account, and rejects the request if signer weights, thresholds, or the account-state ledger changed after review.
8. **Optionally anchor it.** When configured, the signer can request a relayed Soroban mainnet transaction containing only a SHA-256 digest of the canonical SEP-53 message plus signature.
9. **Share the proof.** The public page shows the PDF and signer-authority badge. Anchored proofs also expose their mainnet transaction, ledger, contract, and digest.
1. **Connect a Stellar wallet.** Stellar Wallets Kit provides the wallet picker.
2. **Choose the represented account.** This can be the wallet's own G-address or another G-address for which the wallet key is an active signer.
3. **Read account authority through Stellar RPC.** The server requests the account ledger entry with `getLedgerEntries`, then reads the master signer weight, additional Ed25519 signers, account thresholds, and `lastModifiedLedgerSeq`.
4. **Review the PDF.** The browser hashes the exact document with SHA-256.
5. **Add display metadata.** A display name and optional PNG, JPEG, or WebP visual insignia can be included.
6. **Sign the canonical message.** The wallet signs using SEP-53.
7. **Recheck authority on submission.** The server reads the account entry again and rejects the signature if the signed authority snapshot no longer matches.
8. **Optionally anchor the proof.** When configured, the signer can request a relayed Soroban mainnet transaction containing only a SHA-256 digest of the canonical SEP-53 message plus signature.
9. **Publish the proof.** The service stores the PDF, optional insignia, signed manifest, signature, and server receipt time under a random share ID. Anchored proofs also expose their mainnet transaction, ledger, contract, and digest.

## Signed format
## Canonical signed message

Version 2 binds the signer relationship itself into the SEP-53 message:
Version 2 uses this canonical structure:

~~~text
Stellar PDF Signature v2
document-sha256:<64 lowercase hex characters>
signed-at:2026-09-29T00:00:00.000Z
signer-name:Windsor Flight
signed-at:2026-10-07T00:00:00.000Z
signer-name:Example Signer
signer-public-key:G...
represented-account:G...
signer-weight:10
Expand All @@ -35,19 +51,23 @@ account-last-modified-ledger:123456
insignia-sha256:<64 lowercase hex characters or ->
~~~

The authority label is derived from the strongest **non-zero** account threshold that the individual signer weight satisfies. Default zero thresholds therefore do not incorrectly turn every signer into a “high-weight” signer.
SEP-53 prefixes the UTF-8 message with `Stellar Signed Message:\n`, hashes the result with SHA-256, and verifies the Ed25519 signature against the recorded wallet public key.

The authority label is derived from the strongest non-zero threshold satisfied by the individual signer weight. Zero-valued thresholds do not cause an ordinary signer to be labeled as a high-weight signer.

SEP-53 prefixes the UTF-8 message with `Stellar Signed Message:\n`, hashes that byte sequence with SHA-256, and verifies the Ed25519 signature against the recorded wallet key.
## Stellar RPC

## Why Horizon
The server no longer depends on Horizon for signer-authority lookup. It reads the classic Stellar account ledger entry through the JavaScript SDK's Stellar RPC client.

Classic Stellar account signer weights and low/medium/high thresholds are account state, so Horizon is the straightforward source for this app. The default endpoint is SDF Horizon:
By default:

~~~text
https://horizon.stellar.org
STELLAR_RPC_URL=https://mainnet.sorobanrpc.com
~~~

Override it with `HORIZON_URL` if the deployment uses another Horizon service.
Set `STELLAR_RPC_URL` to another compatible endpoint when using another provider, another network, or a self-hosted RPC service. HTTP endpoints are allowed for local development; production deployments should use HTTPS.

SDF does not operate a public Mainnet RPC endpoint, so production operators should deliberately choose and monitor the RPC provider appropriate for their deployment.

## Optional Soroban mainnet anchor

Expand Down Expand Up @@ -90,16 +110,21 @@ SOROBAN_ANCHOR_SECRET=S...

The relayer secret belongs to the deployment, never the document signer. Without both anchor variables the existing SEP-53 flow stays fully off-chain and the anchor option is hidden.

## Run locally
## Local development

Requirements:

- Node.js 22 or newer
- a reachable Stellar RPC endpoint

Requires Node 22 or newer.
Install dependencies and start the development server:

~~~sh
npm install
npm run dev
~~~

Open `http://localhost:3000`.
Then open `http://localhost:3000`.

Useful checks:

Expand All @@ -110,39 +135,59 @@ npm run build
npm start
~~~

## Persistence
## Configuration

Environment variables:

Records live under `./data/records/<id>/` by default. Each directory contains the original PDF, optional visual insignia, and the JSON proof record. Writes are staged and renamed into place atomically.
| Variable | Default | Purpose |
| --- | --- | --- |
| `PORT` | `3000` | HTTP server port |
| `DATA_DIR` | `./data` | Persistent signature-record storage |
| `MAX_UPLOAD_MB` | `25` | Maximum uploaded file size |
| `STELLAR_RPC_URL` | `https://mainnet.sorobanrpc.com` | Stellar RPC endpoint used for account-ledger reads |
| `SOROBAN_RPC_URL` | `https://mainnet.sorobanrpc.com` | Stellar RPC endpoint used for optional proof anchoring |
| `SOROBAN_ANCHOR_CONTRACT_ID` | unset | Deployed proof-anchor contract ID; required to enable anchoring |
| `SOROBAN_ANCHOR_SECRET` | unset | Dedicated deployment relayer secret; required to enable anchoring |
| `PUBLIC_BASE_URL` | request origin | Canonical public origin used in generated proof URLs |

Set `DATA_DIR` to move persistent storage elsewhere. Public records are addressed by random 96-bit share IDs.
Copy `.env.example` or provide these values through your deployment environment.

## Docker

Build and run with Docker Compose:

~~~sh
docker compose up --build
~~~

For a public deployment, put the service behind HTTPS and set:
The Compose configuration persists records in the `signer-data` volume and configures the default Mainnet RPC endpoint. Override `STELLAR_RPC_URL` for another provider or network.

~~~text
PUBLIC_BASE_URL=https://sign.example.org
HORIZON_URL=https://horizon.stellar.org
~~~
For public deployment, terminate TLS in front of the service and set `PUBLIC_BASE_URL` to the external HTTPS origin.

## HTTP API

- `GET /api/accounts/:account/signers/:signer` — resolve one wallet key's signer weight and threshold level through Horizon
The application's own HTTP API remains small and independent of the upstream Stellar RPC protocol:

- `GET /api/accounts/:account/signers/:signer` — resolve one wallet key's signer weight and threshold level from the account ledger entry
- `POST /api/signatures` — verify and publish a signed PDF
- `GET /api/signatures/:id` — verified public metadata
- `GET /api/signatures/:id/document` — original PDF
- `GET /api/signatures/:id/insignia` — optional visual insignia
- `GET /api/signatures/:id/proof` — downloadable JSON proof
- `GET /api/health` — health check
- `GET /api/signatures/:id` — return verified public metadata
- `GET /api/signatures/:id/document` — return the original PDF
- `GET /api/signatures/:id/insignia` — return the optional visual insignia
- `GET /api/signatures/:id/proof` — download the JSON proof
- `GET /api/health` — service health check

## Persistence

Records are stored under `./data/records/<id>/` by default. Each record directory contains the original PDF, the optional insignia, and the JSON proof record. Writes are staged and atomically renamed into place.

Share IDs are random 96-bit identifiers. A share URL is public-by-link; possession of the URL is sufficient to retrieve the document.

## Trust model

## Authority and timestamp semantics
A valid record proves that the private key corresponding to the recorded wallet public key produced the SEP-53 signature over the canonical message and that the server observed the signed authority snapshot through Stellar RPC when it accepted the record.

“Medium-weight signer,” “high-weight signer,” and similar labels describe the signer weight relative to the represented account's threshold configuration at the Horizon account-state ledger captured in the signed message. The signer configuration can change later; the proof preserves what was checked when the signature was created.
It does **not** prove a person's civil or legal identity merely because a display name is present. Signer weights and account thresholds can also change after signing. The stored ledger sequence and signed snapshot describe the authority state that was checked at creation time, not a guarantee of continuing authority.

Likewise, `signedAt` is cryptographically protected but is still a timestamp claim made by the signer. `serverReceivedAt` records when this server accepted the valid proof. Neither is a trusted timestamp authority. A Soroban anchor independently establishes that the proof digest was committed by the mainnet ledger containing its transaction, but it is not a conventional timestamp-authority certificate.
Likewise, `signedAt` is a signer-controlled claim and `serverReceivedAt` is the accepting server's observation. Neither is a trusted timestamp authority. A Soroban anchor independently establishes that the proof digest was committed by the mainnet ledger containing its transaction, but it is not a conventional timestamp-authority certificate.

See [SECURITY.md](./SECURITY.md) for the full trust model.
See [SECURITY.md](./SECURITY.md) for the complete security model.
6 changes: 3 additions & 3 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ A valid proof establishes that the private key corresponding to the recorded Ste
- the displayed signer name;
- the actual wallet public key reviewed before signing;
- the represented Stellar account;
- a Horizon snapshot of that key's signer weight, account thresholds, and account-state ledger;
- a Stellar RPC snapshot of that key's signer weight, account thresholds, and account-state ledger;
- the SHA-256 hash of the optional visual insignia.

Before accepting a signature, the server fetches the represented account from Stellar Horizon and confirms that the wallet public key is an active signer with non-zero weight. The signed Horizon snapshot must still match when the server receives the signature.
Before accepting a signature, the server reads the represented account ledger entry through Stellar RPC and confirms that the wallet public key is an active signer with non-zero weight. The signed RPC snapshot must still match when the server receives the signature.

The server also recomputes the PDF and insignia hashes before accepting a record and whenever the public proof API is loaded.

Expand All @@ -26,4 +26,4 @@ The server also recomputes the PDF and insignia hashes before accepting a record
- Public share URLs are not an access-control system. Anyone with the URL can view the uploaded PDF.
- A signer can later be removed or its weight/thresholds can change. The proof preserves the signer configuration that was checked and signed at creation time.

Run the service behind HTTPS in production. Treat uploaded documents as public-by-link and apply your own authentication, retention, malware scanning, rate limiting, Horizon availability controls, and backup policies when the deployment requires them.
Run the service behind HTTPS in production. Treat uploaded documents as public-by-link and apply your own authentication, retention, malware scanning, rate limiting, Stellar RPC availability controls, and backup policies when the deployment requires them.
1 change: 1 addition & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ services:
PORT: "3000"
DATA_DIR: /app/data
MAX_UPLOAD_MB: "25"
STELLAR_RPC_URL: https://mainnet.sorobanrpc.com
# PUBLIC_BASE_URL: https://sign.example.org
volumes:
- signer-data:/app/data
Expand Down
14 changes: 7 additions & 7 deletions src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ function HomePage() {

const controller = new AbortController();
setAuthority(null);
setAuthorityError("Checking signer authority with Stellar Horizon…");
setAuthorityError("Checking signer authority with Stellar RPC…");

void fetch(
"/api/accounts/" + encodeURIComponent(account) + "/signers/" + encodeURIComponent(address),
Expand Down Expand Up @@ -367,7 +367,7 @@ function HomePage() {
value={signerName}
onChange={(event) => setSignerName(event.target.value)}
maxLength={120}
placeholder="Windsor Flight"
placeholder="Example Signer"
autoComplete="name"
/>
</label>
Expand Down Expand Up @@ -458,8 +458,8 @@ function HomePage() {
{busy ? "Signing…" : "Sign document"}
</button>
<p className="fineprint">
Horizon is used to confirm the wallet key's signer weight and thresholds for the
represented account. The signature itself remains entirely off-chain.
Stellar RPC is used to read the represented account's signer weights and thresholds
directly from its ledger entry. The signature itself remains entirely off-chain.
</p>
</div>
</section>
Expand Down Expand Up @@ -579,7 +579,7 @@ function SharePage({ id }: { id: string }) {
low {record.authority.lowThreshold} · medium {record.authority.mediumThreshold} · high{" "}
{record.authority.highThreshold}
</dd>
<dt>Horizon account-state ledger</dt>
<dt>Stellar RPC account-state ledger</dt>
<dd>{record.authority.lastModifiedLedger.toLocaleString()}</dd>
<dt>Claimed signed at</dt>
<dd>{new Date(record.signedAt).toLocaleString()}</dd>
Expand All @@ -599,8 +599,8 @@ function SharePage({ id }: { id: string }) {
</details>

<p className="fineprint">
The large signer label comes from the Horizon signer weight and account thresholds
captured in the signed statement. The raw key stays available here for independent verification.
The large signer label comes from the signer weight and account thresholds read through Stellar RPC
and captured in the signed statement. The raw key stays available here for independent verification.
</p>
</aside>
</section>
Expand Down
Loading
Loading