Skip to content
Draft
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
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ SIGN_DB_PATH=./data/sign.db
# Provider used by default when no --provider flag is given. One of: dropbox|docusign|signwell.
SIGN_PROVIDER=dropbox

# --- Audit integrity (optional) ---
# Set an HMAC key (held outside the DB) to upgrade the audit chain from an
# unkeyed SHA-256 hash chain to a keyed HMAC chain — forging it then requires
# the key, not just the public algorithm. Use ONE of these; leave both unset
# to keep the default unkeyed (backward-compatible) chain.
SIGN_AUDIT_HMAC_KEY=
# SIGN_AUDIT_HMAC_KEY_FILE=/path/to/audit-hmac.key

# --- Timestamping (optional) ---
# Override the RFC 3161 TSA. Must be https:// (a timestamp is a trust anchor).
# SIGN_TSA_URL=https://timestamp.digicert.com

# --- Embedded signing return URL (optional) ---
# Pin which hosts --return-url may point at (comma-separated, exact match;
# localhost is always allowed). Unset = any https host is accepted.
# SIGN_RETURN_URL_ALLOWED_HOSTS=app.example.com,portal.example.com

# --- Dropbox Sign ---
DROPBOX_SIGN_API_KEY=
# Set to true while validating against Dropbox Sign test mode.
Expand Down
28 changes: 28 additions & 0 deletions docs/reference/security-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,31 @@ An agent driving the requester side never sees signer tokens — `request show`
- `--require-signer-email <email>` — the resolved signer must match.

All three throw `PRE_SIGN_*_MISMATCH` errors (exit `3`) before the sign attempt is recorded. The audit chain doesn't grow on a failed pre-sign check.

## Verified RFC 3161 timestamps

`audit timestamp` and `audit anchor` obtain an RFC 3161 timestamp token over the audit chain head (or a manifest of all heads) and store it as a re-verifiable artifact. The token is **cryptographically verified**, not merely status-checked:

- The TSA's CMS (`SignedData`) signature over the `TSTInfo` is verified with the signer certificate's public key.
- The signer certificate must carry `extendedKeyUsage = id-kp-timeStamping` (RFC 3161 §2.3).
- The token's `messageImprint` must equal the digest we asked to be stamped — a valid token over *different* data is rejected.
- Optionally, the signer can be required to chain to a supplied trust anchor.

The authoritative result is surfaced as `cryptographicallyVerified` and recorded into the `audit.timestamped` / `audit.anchored` events. The legacy `granted` (PKIStatus) and `containsDigest` fields are retained for compatibility but are **not** trustworthy on their own.

TSA transport is HTTPS-only: `issueRfc3161Timestamp` refuses a plaintext `http://` TSA URL (a timestamp is a trust anchor and must not be downgradeable). A localhost TSA, or `SIGN_ALLOW_INSECURE_TSA=1`, is accepted for trusted local test servers.

## Keyed audit chain (optional HMAC)

By default the audit chain is an unkeyed SHA-256 hash chain: tamper-evident against a naive edit, but because the algorithm is public, anyone with write access to the database file could recompute a fully self-consistent forged chain. For deployments that need integrity against a local-file attacker, configure an HMAC key held **outside** the database:

- `SIGN_AUDIT_HMAC_KEY=<material>` — raw key, or
- `SIGN_AUDIT_HMAC_KEY_FILE=<path>` — file containing the key.

When a key is set, new audit events are written with `hash_algo = hmac-sha256` and their chain hash is an HMAC over the event body (with the algorithm bound in). Forging a keyed chain then requires the key, not just the algorithm. The design is:

- **Backward compatible.** Existing unkeyed chains hash byte-identically and keep verifying. Each row records its own `hash_algo`, so mixed-history databases verify correctly.
- **Fail-closed.** A keyed chain cannot be verified without the key — verification fails rather than silently skipping the integrity check.
- **Downgrade-resistant.** Once a chain has a keyed row, any later unkeyed (legacy) row is flagged as tampering, so an attacker can't "downgrade" the chain back to the forgeable scheme.

Keep the key in your secrets manager / KMS, not alongside the database. Losing the key means keyed history can no longer be verified (the events remain readable; only the integrity proof is lost).
38 changes: 38 additions & 0 deletions fixtures/rfc3161/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# RFC 3161 test fixtures

Real, self-issued RFC 3161 timestamp material used by
`src/tests/timestamp-verify.test.ts` to exercise the CMS verification in
`src/lib/timestamp-verify.ts`. Everything here is a throwaway test CA — no
production trust is implied.

| File | What it is |
| --- | --- |
| `stamped-data.bin` | The bytes that were timestamped. |
| `stamped-data.sha256` | `sha256(stamped-data.bin)` — the messageImprint the token must cover. |
| `valid-token.tsr` | A valid `TimeStampResp` (DER) over that digest, signed by `tsa-signer.crt`. |
| `tsa-signer.crt` | The TSA leaf certificate (carries `extendedKeyUsage = timeStamping`). |
| `test-ca.crt` | The root CA that issued the TSA cert (use as a trust anchor). |

## Regenerating

```sh
# Root CA
openssl req -x509 -newkey rsa:2048 -keyout ca.key -out test-ca.crt -days 3650 \
-nodes -subj "/CN=Sign CLI Test TSA Root/O=Sign CLI Test"

# TSA leaf with the required timeStamping EKU
openssl req -newkey rsa:2048 -keyout tsa.key -out tsa.csr -nodes \
-subj "/CN=Sign CLI Test TSA/O=Sign CLI Test"
printf '[v]\nkeyUsage=critical,digitalSignature\nextendedKeyUsage=critical,timeStamping\nbasicConstraints=critical,CA:FALSE\n' > ext.cnf
openssl x509 -req -in tsa.csr -CA test-ca.crt -CAkey ca.key -CAcreateserial \
-out tsa-signer.crt -days 3650 -extfile ext.cnf -extensions v

# Issue a token over the data
printf 'sign-cli-audit-chain-head-digest' > stamped-data.bin
openssl ts -query -data stamped-data.bin -sha256 -cert -out q.tsq
# ...with a tsa.cnf pointing signer_cert=tsa-signer.crt, certs=test-ca.crt, signer_key=tsa.key
openssl ts -reply -config tsa.cnf -section tsa_config -queryfile q.tsq -out valid-token.tsr
```

Note: `openssl ts -reply` refuses to sign with a certificate that lacks the
`timeStamping` EKU, which is itself a good sign the requirement is real.
1 change: 1 addition & 0 deletions fixtures/rfc3161/stamped-data.bin
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sign-cli-audit-chain-head-digest
1 change: 1 addition & 0 deletions fixtures/rfc3161/stamped-data.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
e3eee92130b53a4a067e127781a63222b9e22c180d07fa4bade6aef3abaa134e
20 changes: 20 additions & 0 deletions fixtures/rfc3161/test-ca.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDUzCCAjugAwIBAgIUdnjHYgxZA/gs0WEc27WZtg4HTdIwDQYJKoZIhvcNAQEL
BQAwOTEfMB0GA1UEAwwWU2lnbiBDTEkgVGVzdCBUU0EgUm9vdDEWMBQGA1UECgwN
U2lnbiBDTEkgVGVzdDAeFw0yNjA1MzExMjAyNDBaFw0zNjA1MjgxMjAyNDBaMDkx
HzAdBgNVBAMMFlNpZ24gQ0xJIFRlc3QgVFNBIFJvb3QxFjAUBgNVBAoMDVNpZ24g
Q0xJIFRlc3QwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCN2H95Q6MC
LpjMRwkL97wpmEELubgAM8J+fvVVSuvWz4qxTc5DBEK++6XDki5AUjLDvVVXhpx2
RNdj+FEKULmJ9GVshvd11E1/asMNvvYq0T4TxvzHSWIHamqYkWuJUQDXz1+8tFe9
gSAwhlC5kL7G13aeqvudRXgOBCl/ktjBqXoPdrT04JL4THr/TbaHt86Y9nPuvZ1f
Eb6kIqOrk3bSx42znBltEBIljKub4Fa9I/ieWuyv/gi+Dep3eyqZlgqYqMcWLnn3
3oqv551vrSDN08wjJ0UObQDziZjhMc2dwKefHS1ZfN2+iEvmFoqrvZrsoHCr7z8t
1FVb6Rsz+J87AgMBAAGjUzBRMB0GA1UdDgQWBBSAadzUP7TPLE6kXCmd5QLWrrzk
4DAfBgNVHSMEGDAWgBSAadzUP7TPLE6kXCmd5QLWrrzk4DAPBgNVHRMBAf8EBTAD
AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQAltNfqXOLSwLkRlrcqhu1tYpZGqjKh9AGW
qBruzo1qgzMowaHpul31/rXIPOl7MVHx0NNmIK804zwsoFgOywc1Q8IZVd/xoKgE
GIEclz6gRJFGeR8Y77V6mqzkRpZEs192SM9tlsllcYcHgyGxictKY0JoTe//qJ0/
g0B3+kHQJT+QAiZ6oiO0yY6kLUpw6Ejjv6flF/p+vmoCPqgoDNIOFcjMQJEx4Zf/
7gvaDIPg3tEAtVQxT1gtRFXpRTk+itUYTzm/TuqfKC1cJlVc/a/oUxbTirydmYZK
Gnk/DbpmF6O5grEvDGTNj5/3bl0ttoyBjy/wnVeIvO7nnsx+Jh5o
-----END CERTIFICATE-----
21 changes: 21 additions & 0 deletions fixtures/rfc3161/tsa-signer.crt
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDczCCAlugAwIBAgIUCstX7xEj7K73a6/xRA/pfCw2OGEwDQYJKoZIhvcNAQEL
BQAwOTEfMB0GA1UEAwwWU2lnbiBDTEkgVGVzdCBUU0EgUm9vdDEWMBQGA1UECgwN
U2lnbiBDTEkgVGVzdDAeFw0yNjA1MzExMjAyNDBaFw0zNjA1MjgxMjAyNDBaMDQx
GjAYBgNVBAMMEVNpZ24gQ0xJIFRlc3QgVFNBMRYwFAYDVQQKDA1TaWduIENMSSBU
ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx+2VeKJdiyNFCKib
aMvM0ecqzA+6aECTtTCDi7orY9Jvg5ZNsxaV6M6bepKvfTozYGuvW+QAZhIpRFtv
kZHC0jxV2Hkwpq+ryDtroVamYWgPgZY8qgMFDjhc1NRJc/5/Kdev0PPS8RBXoE2F
I4oXr3bZxkqEVys2jAR2f/ROyvofR6iuqXRTqt8NDX7bNBloGskGOKsaS1MvJusq
2lcZEIOOkT0nmSq/vnkmsbhF8ACTb435qntIvL7wv2MWxvYF+uou5LyHxAgfQUzb
T8EE0/DsCsbXtaA5Lo60pu/T1WW6Xlc+ld3ZOZrkbA1j1kw4pZ0Ci2EZZsAv5LjJ
Y9dszQIDAQABo3gwdjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYB
BQUHAwgwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUgCq5zOibkbPBWEHuIUgSzXBd
oiQwHwYDVR0jBBgwFoAUgGnc1D+0zyxOpFwpneUC1q685OAwDQYJKoZIhvcNAQEL
BQADggEBAFKIoDA+KC/qpx4DVfohpWeYhhFljHCJ5cuk01AJ13c+DfVYVhxyYHI/
+XRRNdcn3Y9zFIMADQz2YNKUFtmmHVFydZi/gxbzekJc087+MY99kephxQcFurcZ
pXMAevdqBxMQQ8WcDnkmU0q6077S3lXZVmZz6hwOdpx6OesFNA2Hn232bR2eL7Z7
lUT6k3wyUURaW/W0TaIJcbamE+Y0SA/1ab/6lWtxkAQ3zQdAKcpSaE+iyJvZhB3y
F9s9hWInEVcRdQVQL5GtUZohVJCJjs/o5VVR+oRierLc4yA22eEzJu4PFJ0vjZcX
zBoy6r0WQ0wrEnZx3VtjGk3Ep2n1KO0=
-----END CERTIFICATE-----
Binary file added fixtures/rfc3161/valid-token.tsr
Binary file not shown.
6 changes: 4 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ sign db postgres-smoke --pg-url postgres://… (end-to-end async-against-pg in
sign db backend [--backend sqlite|postgres] (report the active storage backend)
sign mcp serve [--read-only true] [--tool <name> ...] [--capability tools|resources|prompts ...] [--emit-events ./mcp.ndjson [--emit-events-redact true]] (stdio MCP server; --emit-events tees every JSON-RPC message in/out to NDJSON; --emit-events-redact masks token-shaped fields in the log; --capability/--tool/--read-only further restrict the surface)
sign mcp tools [--format json|markdown] (one-shot tool catalog with input + output JSON-Schema; markdown renders a docs page)
sign serve [--port 4000] [--bind 127.0.0.1] [--auth-token <t>] [--tls-cert ./cert.pem --tls-key ./key.pem [--tls-ca ./ca.pem]] [--web-demo true|<dir>] [--rate-limit <rps> [--rate-limit-burst <n>]] [--read-only true] (HTTP REST surface; --read-only blocks the four lifecycle-mutating routes with FORBIDDEN_READ_ONLY)
sign serve [--port 4000] [--bind 127.0.0.1] [--auth-token <t>] [--tls-cert ./cert.pem --tls-key ./key.pem [--tls-ca ./ca.pem]] [--web-demo true|<dir>] [--rate-limit <rps> [--rate-limit-burst <n>]] [--trust-proxy true] [--read-only true] (HTTP REST surface; --read-only blocks the four lifecycle-mutating routes with FORBIDDEN_READ_ONLY; --trust-proxy honours X-Forwarded-For for rate-limit keying behind a trusted proxy)
sign completion bash|zsh|fish (print a completion script; pipe into your shell init)

Global flags: [--verbose true] Env: SIGN_DEBUG=1, SIGN_HTTP_MAX_RETRIES, SIGN_HTTP_BASE_DELAY_MS, SIGN_MAX_DOCUMENT_BYTES, SIGN_ALLOW_ABSOLUTE_DOCS
Expand Down Expand Up @@ -2848,7 +2848,8 @@ async function main(): Promise<void> {
}
: undefined;
const readOnly = (flagValue(parsed, "read-only") ?? "false") === "true";
const server = startHttpApiServer({ db, port, bind, authToken, tls, webDemoDir, rateLimit, readOnly });
const trustProxy = (flagValue(parsed, "trust-proxy") ?? "false") === "true";
const server = startHttpApiServer({ db, port, bind, authToken, tls, webDemoDir, rateLimit, readOnly, trustProxy });
const shutdown = () => server.close(() => process.exit(0));
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
Expand All @@ -2858,6 +2859,7 @@ async function main(): Promise<void> {
tls: Boolean(tls),
authRequired: Boolean(authToken),
readOnly,
trustProxy,
rateLimit: rateLimit ? { refillPerSec: rateLimit.refillPerSec, capacity: rateLimit.capacity } : null,
webDemo: webDemoDir ? `${tls ? "https" : "http"}://${bind}:${port}/web-demo/index.html` : null,
// Pull from listMockHttpRoutes() so this banner can't drift from the
Expand Down
5 changes: 3 additions & 2 deletions src/lib/audit-anchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export type AnchorReport = {

export async function anchorAllAuditChainHeads(
db: SqliteDb,
input: { tsaUrl?: string; outDir?: string; now?: Date; since?: string } = {},
input: { tsaUrl?: string; outDir?: string; now?: Date; since?: string; trustAnchors?: Array<string | Buffer> } = {},
): Promise<AnchorReport> {
const path = await import("node:path");
const fs = await import("node:fs");
Expand Down Expand Up @@ -77,7 +77,7 @@ export async function anchorAllAuditChainHeads(
const digest = Buffer.from(digestHex, "hex");

const result = await issueRfc3161Timestamp({ digest, tsaUrl: input.tsaUrl });
const inspection = inspectTimestampResponse(result.responseBuffer, digest);
const inspection = inspectTimestampResponse(result.responseBuffer, digest, input.trustAnchors);

const now = input.now ?? new Date();
const stamp = nowIso(now).replace(/[:.]/g, "-");
Expand Down Expand Up @@ -118,6 +118,7 @@ export async function anchorAllAuditChainHeads(
manifestBytes,
coveredRequests: manifest.length,
granted: inspection.granted,
cryptographicallyVerified: inspection.cryptographicallyVerified,
},
now,
});
Expand Down
53 changes: 53 additions & 0 deletions src/lib/audit-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Optional keyed-HMAC integrity for the audit chain.
//
// By default the audit chain is an unkeyed SHA-256 hash chain: tamper-evident
// against a naive edit, but since the algorithm is public an attacker with
// write access to the DB file can recompute a fully self-consistent forged
// chain. Configuring an HMAC key (held OUTSIDE the database) upgrades the
// chain so that forging it requires the key, not just the algorithm.
//
// Resolution order (first hit wins):
// 1. SIGN_AUDIT_HMAC_KEY — raw key material (utf8), or
// 2. SIGN_AUDIT_HMAC_KEY_FILE — path to a file whose contents are the key.
//
// When neither is set, keying is OFF and the chain behaves exactly as before
// (full backward compatibility — existing chains verify unchanged).

import { readFileSync } from "node:fs";

export const HASH_ALGO_LEGACY = "sha256";
export const HASH_ALGO_HMAC = "hmac-sha256";

let cached: { key: Buffer | null } | null = null;

/** Resolve the configured audit HMAC key, or null when keying is disabled.
* Cached after first read; call resetAuditHmacKeyCache() in tests that mutate
* the env between cases. */
export function resolveAuditHmacKey(): Buffer | null {
if (cached) return cached.key;
const raw = process.env.SIGN_AUDIT_HMAC_KEY;
if (raw !== undefined && raw.length > 0) {
cached = { key: Buffer.from(raw, "utf8") };
return cached.key;
}
const file = process.env.SIGN_AUDIT_HMAC_KEY_FILE;
if (file !== undefined && file.length > 0) {
const contents = readFileSync(file);
if (contents.length === 0) {
throw new Error(`SIGN_AUDIT_HMAC_KEY_FILE (${file}) is empty; provide key material or unset it.`);
}
cached = { key: contents };
return cached.key;
}
cached = { key: null };
return null;
}

export function resetAuditHmacKeyCache(): void {
cached = null;
}

/** True when an HMAC key is configured (the chain should be written keyed). */
export function auditKeyingEnabled(): boolean {
return resolveAuditHmacKey() !== null;
}
Loading
Loading