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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ DATA_DIR=./data
MAX_UPLOAD_MB=25
HORIZON_URL=https://horizon.stellar.org
# PUBLIC_BASE_URL=https://sign.example.org
SOROBAN_RPC_URL=https://mainnet.sorobanrpc.com
# SOROBAN_ANCHOR_CONTRACT_ID=C...
# SOROBAN_ANCHOR_SECRET=S...
16 changes: 16 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[workspace]
resolver = "2"
members = ["contracts/proof-anchor"]

[workspace.dependencies]
soroban-sdk = "26"

[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true
48 changes: 45 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A full-stack document signer built around [Stellar SEP-53](https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0053.md).

The app is deliberately off-chain: there is no Stellar transaction and the server never receives a secret key. Users choose a wallet through Stellar Wallets Kit, review the PDF, and sign one canonical message. 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.
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.

## Signing flow

Expand All @@ -13,7 +13,8 @@ The app is deliberately off-chain: there is no Stellar transaction and the serve
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. **Share the proof.** The public page shows the PDF and a signer-authority badge such as **“Medium-weight signer for GAAAA…BBBB”**. The raw signing key remains in the verification details instead of being the primary identity label.
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.

## Signed format

Expand Down Expand Up @@ -48,6 +49,47 @@ https://horizon.stellar.org

Override it with `HORIZON_URL` if the deployment uses another Horizon service.

## Optional Soroban mainnet anchor

A minimal event-only contract lives in `contracts/proof-anchor`. It accepts one `BytesN<32>` proof digest, emits it with the ledger sequence and ledger timestamp, and stores no PDF or identity data.

The digest is:

~~~text
SHA-256(
"Stellar PDF Soroban Anchor v1\n"
|| canonical SEP-53 message
|| "sep53-signature:"
|| base64 SEP-53 signature
|| "\n"
)
~~~

Build and deploy it:

~~~sh
stellar contract build --package proof-anchor

stellar network add signer-mainnet \
--rpc-url https://mainnet.sorobanrpc.com \
--network-passphrase "Public Global Stellar Network ; September 2015"

stellar contract deploy \
--wasm target/wasm32v1-none/release/proof_anchor.wasm \
--source-account <your-cli-identity> \
--network signer-mainnet
~~~

Configure the deployment with a dedicated, conservatively funded relayer account:

~~~text
SOROBAN_RPC_URL=https://mainnet.sorobanrpc.com
SOROBAN_ANCHOR_CONTRACT_ID=C...
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

Requires Node 22 or newer.
Expand Down Expand Up @@ -101,6 +143,6 @@ HORIZON_URL=https://horizon.stellar.org

“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.

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.
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.

See [SECURITY.md](./SECURITY.md) for the full trust model.
15 changes: 15 additions & 0 deletions contracts/proof-anchor/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "proof-anchor"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
crate-type = ["cdylib", "rlib"]
doctest = false

[dependencies]
soroban-sdk = { workspace = true }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }
47 changes: 47 additions & 0 deletions contracts/proof-anchor/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#![no_std]

use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, BytesN, Env};

#[contracttype]
#[derive(Clone, Eq, PartialEq)]
pub struct AnchorReceipt {
pub proof_hash: BytesN<32>,
pub ledger: u32,
pub ledger_timestamp: u64,
}

#[contract]
pub struct ProofAnchor;

#[contractimpl]
impl ProofAnchor {
pub fn anchor(env: Env, proof_hash: BytesN<32>) -> AnchorReceipt {
let receipt = AnchorReceipt {
proof_hash,
ledger: env.ledger().sequence(),
ledger_timestamp: env.ledger().timestamp(),
};
env.events().publish((symbol_short!("proof"),), receipt.clone());
receipt
}
}

#[cfg(test)]
mod test {
use super::{ProofAnchor, ProofAnchorClient};
use soroban_sdk::{BytesN, Env};

#[test]
fn anchors_the_exact_digest() {
let env = Env::default();
let contract_id = env.register(ProofAnchor, ());
let client = ProofAnchorClient::new(&env, &contract_id);
let digest = BytesN::from_array(&env, &[7; 32]);

let receipt = client.anchor(&digest);

assert_eq!(receipt.proof_hash, digest);
assert_eq!(receipt.ledger, env.ledger().sequence());
assert_eq!(receipt.ledger_timestamp, env.ledger().timestamp());
}
}
50 changes: 49 additions & 1 deletion src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,18 @@ function HomePage() {
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const [anchorEnabled, setAnchorEnabled] = useState(false);
const [anchorOnMainnet, setAnchorOnMainnet] = useState(false);

useEffect(() => {
ensureWalletKit();
void fetch("/api/config")
.then(async (response) => {
if (!response.ok) throw new Error("Unable to load deployment configuration.");
return response.json() as Promise<{ sorobanAnchorEnabled?: boolean }>;
})
.then((config) => setAnchorEnabled(Boolean(config.sorobanAnchorEnabled)))
.catch(() => setAnchorEnabled(false));
}, []);

useEffect(() => {
Expand Down Expand Up @@ -242,10 +251,15 @@ function HomePage() {
);
}

setStatus("Checking the returned public key and publishing the proof…");
setStatus(
anchorOnMainnet
? "Publishing the proof and anchoring its digest on Stellar mainnet…"
: "Checking the returned public key and publishing the proof…",
);
const form = new FormData();
form.set("manifest", JSON.stringify(manifest));
form.set("signature", result.signedMessage);
form.set("anchor", anchorOnMainnet ? "true" : "false");
form.set("document", document);
if (insignia) form.set("insignia", insignia);

Expand Down Expand Up @@ -418,6 +432,23 @@ function HomePage() {
{error && <div className="notice error">{error}</div>}
{status && <div className="notice">{status}</div>}

{anchorEnabled && (
<label className="file-field">
<span>
<input
type="checkbox"
checked={anchorOnMainnet}
disabled={busy}
onChange={(event) => setAnchorOnMainnet(event.target.checked)}
/>{" "}
<strong>Anchor proof on Stellar mainnet</strong>
</span>
<span className="muted">
Publishes only a SHA-256 proof digest through Soroban; the PDF and identity stay off-chain.
</span>
</label>
)}

<button
className="primary-button"
type="button"
Expand Down Expand Up @@ -502,6 +533,23 @@ function SharePage({ id }: { id: string }) {
</div>
)}

{record.anchor && (
<section className="panel">
<p className="eyebrow">Soroban mainnet anchor</p>
<h2>Anchored at ledger {record.anchor.ledger.toLocaleString()}</h2>
<p>
Proof SHA-256 <code>{record.anchor.proofSha256}</code>
</p>
<a
href={"https://stellar.expert/explorer/public/tx/" + record.anchor.transactionHash}
target="_blank"
rel="noreferrer"
>
View mainnet transaction
</a>
</section>
)}

<section className="proof-layout">
<div className="pdf-card">
<iframe src={record.documentUrl} title={record.documentFilename} />
Expand Down
31 changes: 31 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ import {
type StoredSignatureRecord,
} from "./store";
import { signatureToBase64, verifySep53 } from "./stellar";
import {
anchorProofOnMainnet,
proofAnchorDigest,
sorobanAnchorConfigured,
} from "./soroban";

const app = express();
const port = Number(process.env.PORT || 3000);
Expand Down Expand Up @@ -146,6 +151,7 @@ function toPublicRecord(
insigniaMimeType: record.insigniaMimeType,
signature: record.signature,
canonicalMessage: record.canonicalMessage,
anchor: record.anchor ?? null,
verified: checks.signature && checks.documentHash && checks.insigniaHash,
checks,
documentUrl: root + "/api/signatures/" + record.id + "/document",
Expand All @@ -159,6 +165,10 @@ app.get("/api/health", (_req, res) => {
res.json({ ok: true, service: "stellar-pdf-signer" });
});

app.get("/api/config", (_req, res) => {
res.json({ sorobanAnchorEnabled: sorobanAnchorConfigured() });
});

app.get("/api/accounts/:account/signers/:signer", async (req, res, next) => {
try {
const status = await resolveSignerAuthority(req.params.account, req.params.signer);
Expand Down Expand Up @@ -192,6 +202,14 @@ app.post(
res.status(400).json({ error: "manifest and signature fields are required." });
return;
}
if (
req.body.anchor !== undefined &&
req.body.anchor !== "true" &&
req.body.anchor !== "false"
) {
res.status(400).json({ error: "anchor must be true or false." });
return;
}

let parsedManifest: unknown;
try {
Expand Down Expand Up @@ -240,13 +258,26 @@ app.post(
}

const signature = signatureToBase64(req.body.signature);
const anchorRequested = req.body.anchor === "true";
let anchor = null;
if (anchorRequested) {
if (!sorobanAnchorConfigured()) {
res.status(503).json({
error: "Soroban mainnet anchoring is not configured on this deployment.",
});
return;
}
anchor = await anchorProofOnMainnet(proofAnchorDigest(canonicalMessage, signature));
}

const id = createId();
const insigniaExtension = insignia ? acceptedInsigniaTypes.get(insignia.mimetype)! : null;
const record: StoredSignatureRecord = {
id,
manifest,
signature,
canonicalMessage,
anchor,
serverReceivedAt: new Date().toISOString(),
documentFilename: safeFilename(document.originalname),
documentMimeType: "application/pdf",
Expand Down
Loading
Loading