DSS Proxy — "Alpine Sign"¶
The {{ product_name }} DSS proxy (alpine-dss-proxy, product name Alpine Sign) is an on-prem,
single-tenant signing portal. Operators upload PDFs through a browser; the proxy hashes each document
locally and sends only the hash to the central DSS Signing Server, then assembles the
signed document on-prem — so signing never sends document bytes out of the tenant's network. One proxy is
deployed per tenant.
One deliberate exception, and it is opt-in
The validator hand-off can take an operator to a validator outside the tenant network to have a signed document checked. It is off by default, the deployment has to declare it, and the operator confirms per document before going. Signing itself still sends only hashes.
It is one Spring Boot process serving two sections on two HTTP ports, so the admin port can be firewalled separately:
- Operator — port 8090 (
server.port): upload, track and download signed documents (/,/operator/**). - Admin / management portal — port 8091 (
alpine.sign.admin-port): groups, permissions, key profiles, and the first-time setup wizard (/admin/**,/admin/init).
Firewall the admin port
Each port serves only its own section. Restrict the admin port (8091) to trusted networks — it hosts the setup wizard and management portal.
Deployment¶
Prerequisites¶
- Java 25 only.
- PostgreSQL 18 — a dedicated
dss_proxyschema (app data + Quartz job store), created and migrated by Flyway automatically on startup. - A reachable DSS Signing Server (the signer URL is set in the wizard, not in files).
- An OIDC provider (e.g. Keycloak) for admin and operator sign-in.
- An SMTP server for completion notifications.
Build and run¶
# Build this module and its dependencies (from the repository root)
mvn -pl 07_dss/alpine-dss-proxy -am package
The proxy needs only the bootstrap secrets to launch the first time; everything else has dev defaults or is set in the wizard.
ALPINE_SIGN_SECRET='a-long-high-entropy-sealing-key' \
ALPINE_SIGN_SALT='a-unique-per-deployment-salt' \
java -jar 07_dss/alpine-dss-proxy/target/alpine-dss-proxy-<version>.jar
The operator section is on http://localhost:8090 and the admin section + wizard on
http://localhost:8091. On first run every admin-port request redirects (HTTP 302) to
http://localhost:8091/admin/init. Override ports (or any property) with Spring relaxed binding, e.g.
--server.port=9090 --alpine.sign.admin-port=9091.
Change the default salt in production
ALPINE_SIGN_SALT defaults to alpine-sign-default-salt-change-me — a public placeholder. Set a
unique, high-entropy salt per deployment. ALPINE_SIGN_SECRET (the AES-256-GCM sealing key) must be
set before the wizard can store any secret.
First-time setup (the wizard)¶
Initialization is staged, then completed: the wizard validates and stores the configuration and generates the tenant signing key; the system comes fully online only after the first admin login.
- Ops start the app with
ALPINE_SIGN_SECRET(the sealing key), optionally overrideALPINE_SIGN_ADMIN_GROUPS(defaultadmin,administrator), and hand the administrator the tenant UUID, DSS module UUID, and one-time shared secret fromprovision dss. - The admin opens
http://localhost:8091/admin/init— the wizard opens directly (no unlock screen) — then completes the six steps:- Admin OIDC (required) — issuer URL (endpoints auto-discovered), client id, the groups
claim an administrator's groups are read from, and an optional client secret (leave it
empty for the usual public PKCE client; fill it in when the provider registered this as a
confidential one). (Who counts as an administrator stays deployment configuration — the
static
ALPINE_SIGN_ADMIN_GROUPSlist — see below. This step sets only where their groups are read from.) Scopes are not collected here: the wizard requestsopenid+groups+email, and they are editable afterwards on the Admin OpenID page. - Operator OIDC — the same four fields, opening empty. A field left empty uses the
Admin step's answer, since one identity provider commonly serves both portals, so fill in
only what differs for operators. The substitution happens as the settings are written, not in
the form, so the boxes only ever show what you decided. Overriding just one field is fine (a
different groups claim, say); the issuer and client id are the one pair that must be given
together. There is no sync afterwards: the Admin and Operator OpenID pages are
independent, and changing one never changes the other. Two things this step cannot check for
you, and both fail well away from it:
- The provider client must allow both redirect URLs —
…:8091/admin/login/oauth2/code/adminand…:8090/login/oauth2/code/operator— or operator sign-in is refused by the provider withinvalid_redirect_uri. - Operators reach nothing until their claim value is mapped to a group under
Manage → Groups. A correct sign-in that ends in
403means the claim name is right and the group mapping is missing.
- The provider client must allow both redirect URLs —
- Mail / SMTP (optional — a blank host leaves mail unconfigured) — host, port, security
(
None/STARTTLS/TLS), from address, and username/password (authentication is inferred from a non-blank username). Includes a Test connection button. - Egress proxy (optional — leave disabled for a direct connection) — host, port, a no-proxy bypass list, and optional credentials. Includes a Test connection button.
- Backend (required) — remote signer URL (the DSS signer, e.g.
http://localhost:8080) and the DSS-admin gRPC endpoint as a singlegrpc://host:portorgrpcs://host:porttarget (the scheme selects TLS). Optionally enable a mutual-TLS override: a trusted CA-chain PEM, a client PKCS#12, and its password. Includes a Test backend reachability button. - Provisioning (required) — tenant UUID (
tnt-…), DSS module UUID (mod-…), and the one-time shared secret, all taken from theprovision dssoutput.
- Admin OIDC (required) — issuer URL (endpoints auto-discovered), client id, the groups
claim an administrator's groups are read from, and an optional client secret (leave it
empty for the usual public PKCE client; fill it in when the provider registered this as a
confidential one). (Who counts as an administrator stays deployment configuration — the
static
- On submit, the server validates (an SMTP connection test when configured, then OIDC issuer discovery after the write), seals every secret (AES-256-GCM), stores the configuration, generates this tenant's EC signing key (sealed in the DB), marks setup staged, and directs the admin to sign in.
- On the first successful admin login (a token carrying one of
ALPINE_SIGN_ADMIN_GROUPS), the proxy registers its public key with the DSS backend over gRPC, authenticated by the shared secret. Only if that registration succeeds does setup complete and both sections come online. If the secret is rejected or the backend is unreachable, the admin is taken to/admin/provisioningto re-enter the secret and retry (a wrong secret is never consumed, so retry is always safe). Until completion the wizard stays re-openable, so a wrong admin-group claim cannot lock you out.
Configuration¶
Infrastructure/bootstrap settings come from environment variables (or --property=value flags). OIDC,
SMTP and the egress proxy are configured through the setup wizard and stored sealed in the database — not
in application.yml.
Server ports¶
| Key | Env override | Default | Description |
|---|---|---|---|
server.port |
OPERATOR_PORT |
8090 |
Operator section HTTP port. |
alpine.sign.admin-port |
ALPINE_SIGN_ADMIN_PORT |
8091 |
Admin section + setup-wizard HTTP port (firewall to trusted networks). |
Bootstrap secrets¶
| Key | Env override | Default | Description |
|---|---|---|---|
alpine.sign.secret |
ALPINE_SIGN_SECRET |
(empty) | Key that seals OIDC/SMTP/proxy/backend secrets at rest (AES-256-GCM). Required before the wizard can store secrets. |
alpine.sign.salt |
ALPINE_SIGN_SALT |
alpine-sign-default-salt-change-me |
PBKDF2 salt for deriving the sealing key. Set a unique value in production. |
Database¶
| Key | Env override | Default | Description |
|---|---|---|---|
spring.datasource.url |
PROXY_DB_URL |
jdbc:postgresql://localhost:7432/postgres?currentSchema=dss_proxy |
PostgreSQL 18 JDBC URL. |
spring.datasource.username |
PROXY_DB_USER |
pki |
Database user. |
spring.datasource.password |
PROXY_DB_PASSWORD |
Password1 |
Database password (override in production). |
Flyway owns the dss_proxy schema and migrates it automatically on startup.
The signing server¶
The signer base URL is set in the wizard (BACKEND.signerUrl, sealed in the DB). Nothing about the
token the proxy authenticates it with is configurable: the issuer and audience are fixed constants, the
subject is dss-proxy, the lifetime is 300 seconds, and the tenant uuid and signing key come from the
wizard-stored backend settings and the sealed tenant key.
| Key | Env override | Default | Description |
|---|---|---|---|
alpine.sign.pki.base-url |
ALPINE_SIGN_PKI_BASE_URL |
http://localhost:8080 |
Signer base URL used only when the wizard-stored BACKEND.signerUrl is empty. |
Show advanced (property-only) settings
| Key | Default | Description |
|---|---|---|
alpine.sign.worker.delay-ms |
5000 |
Delay between async signing-worker runs. |
alpine.sign.worker.initial-delay-ms |
5000 |
Delay before the first worker run. |
alpine.sign.oidc.admin-groups (env ALPINE_SIGN_ADMIN_GROUPS) |
admin,administrator |
The enforced admin gate — comma-separated OIDC group/role values; any match grants ROLE_ADMIN. Static, not a wizard value. |
Certificate monitoring and revocation¶
A scheduled sweep asks, for every signing key that is in service, whether its certificate has expired and whether the CA has revoked it. A certificate confirmed revoked takes its key out of service automatically; one the CA has placed on hold suspends the key instead, because a hold is meant to be lifted again.
| Key | Env override | Default | Description |
|---|---|---|---|
alpine.sign.cert-monitor.interval-minutes |
ALPINE_SIGN_CERT_MONITOR_INTERVAL_MINUTES |
60 |
Minutes between sweeps. Sweep frequency is not CA request frequency: OCSP responses and CRLs are cached to their nextUpdate. |
alpine.sign.revocation.deny-private-networks |
ALPINE_SIGN_REVOCATION_DENY_PRIVATE_NETWORKS |
true |
Blocks OCSP/CRL fetches whose host resolves to a private, loopback, link-local or IPv6 unique-local address. |
alpine.sign.revocation.max-crl-age-days |
ALPINE_SIGN_REVOCATION_MAX_CRL_AGE_DAYS |
1 |
How stale a CRL may be before it is refused. |
Running an internal CA? Set deny-private-networks to false
OCSP and CRL URLs come out of the certificate itself, so whoever obtains a certificate chooses where the validator connects. Blocking private addresses is what stops that reaching inside your own network.
A private PKI that publishes its responder on an internal hostname is blocked by exactly the same rule.
The symptom is not an error — revocation simply reports "could not be determined" on every sweep, for
ever, and no key is ever retired. Set this to false when your responder is internal, and restrict egress
at the network layer instead.
Running a CRL-only CA? Raise max-crl-age-days
A CA that issues CRLs weekly and runs no OCSP responder has every CRL refused at the default of one day, so revocation checking never succeeds.
The default is deliberately strict: a CRL issued before a certificate was revoked still says "not
revoked", with full authority. Honouring a long nextUpdate would turn a failed OCSP lookup into a
confident wrong answer rather than an honest undetermined one. Raising this accepts that staleness
knowingly — which is why it is a setting and not a constant.
How to tell whether revocation checking is actually working
Each sweep logs one line at INFO:
If any key could not be determined, that line is raised to WARN and names the count, with a line per key
saying why. A deployment whose responder is unreachable is therefore visible in the log rather than silent.
Validator hand-off¶
The proxy reports what each signature says about itself; it does not judge a document. Deciding whether a signature is trustworthy is a judgement against trust lists, which live in the DSS Validator. This section configures the route an operator takes to get there from a document's detail sheet.
The operator's own browser goes to the validator and uploads the document there. No document is ever sent from the proxy to the validator, in either shape.
| Key | Env override | Default | Description |
|---|---|---|---|
alpine.sign.validator.base-url |
ALPINE_SIGN_VALIDATOR_BASE_URL |
(blank) | The validator's base URL. Blank disables the hand-off and no action is offered. |
alpine.sign.validator.egress-allowed |
ALPINE_SIGN_VALIDATOR_EGRESS_ALLOWED |
false |
Whether that destination is outside this installation's network. |
alpine.sign.validator.max-document-bytes |
ALPINE_SIGN_VALIDATOR_MAX_DOCUMENT_BYTES |
20971520 |
Largest document the action will offer, matching the validator's own per-file ceiling. |
Two shapes, and the consent that separates them¶
On-premises (egress-allowed: false, the default) — a validator inside the tenant's network. The detail sheet
shows a plain Validate link whose tooltip names the destination. There is deliberately no warning: nothing
crosses the network boundary, and a warning shown where it does not apply is how operators learn to click through the
one that does.
Hosted (egress-allowed: true) — a validator outside it. The action becomes an amber Validate off-network
button that opens a confirmation naming the document and the destination, and the documents page carries a standing
notice.
That confirmation is a POST carrying a CSRF token, not a link. A plain GET would leave the server nothing to
distinguish a confirmed hand-off from a typed URL, a restored tab or a link in a chat message — so the audit row
would be asserting a consent nothing had observed. On a hosted install a bare GET of the hand-off route is refused
for exactly that reason.
The shape is declared, never inferred from the URL
A host-name rule is defeated by a CNAME, a white-label domain or a bare IP, and silently classifying an off-network destination as on-premises is the worst failure available here — an operator would find out where their document went afterwards. The deployment therefore states the shape, and the proxy refuses to start when the declaration contradicts the destination:
egress-allowed: truewith a blankbase-urlegress-allowed: falsepointing atalpine-pki.comor a subdomain of itegress-allowed: trueover plainhttp— the whole document would cross in cleartext
Plain http is accepted on-premises. The rule is about egress, not encryption.
The alpine-pki.com check is a narrow catch, not a general egress classifier: a white-label host passes it. What
covers the general case is that the destination host is always named in front of the operator.
What is recorded¶
One audit entry per hand-off, and it records only what this installation observed: the request, the operator's confirmation where there was one, and the redirect it issued.
It does not claim a document did or did not leave. The proxy cannot see whether the operator went on to upload anything at the far end, and "on-premises" is a configured declaration rather than something the proxy verified — so the on-premises line says "which this deployment declares on-premises" rather than asserting that nothing left. A refused hand-off writes nothing at all.
Authentication¶
The proxy authenticates in two directions.
Operators and admins sign in via OIDC authorization-code login — each section has its own OIDC
registration, built at runtime from the wizard-stored settings by a dynamic client-registration
repository (Spring Boot's OIDC auto-configuration backs off). On callback, the group claim is mapped
to local authorities: a token whose groups contain any value in ALPINE_SIGN_ADMIN_GROUPS gets
ROLE_ADMIN; operator tokens resolve to their group·profile signing permissions. Admin sign-in is on
the admin port (/admin/oauth2/authorization/admin); operator sign-in on the operator port
(/operator/login).
The proxy authenticates itself to the DSS Signing Server with a client-signed JWT
(RSA/EC), using this tenant's signing key — generated during the wizard and sealed in the DB. The
key's kid is its Subject Key Identifier; the private key is re-read (and unsealed) per request, so a
key rollover is transparent. The iss and aud are the fixed DSS constants, sub is dss-proxy and
the lifetime is 300 seconds — none of them configurable. If an optional egress HTTP proxy is configured
in the wizard, outbound calls honor it.
FAQ¶
Do documents ever leave the tenant's network?
No. The proxy computes each document's hash locally and sends only the hash to the signing server, then assembles the signed document on-prem. This is the whole point of running a proxy per tenant.
Why are there two ports?
The operator section (port 8090) and the admin section + setup wizard (port 8091) are served on separate ports so the admin port can be firewalled to trusted networks. Each port serves only its own section.
Where are OIDC, SMTP and proxy settings configured?
Not in files — an administrator sets them once through the setup wizard at /admin/init, and they are
stored in the database with every secret sealed (AES-256-GCM). Only bootstrap secrets, ports, the database
and the client-JWT settings are environment/file configuration.
Who is granted admin access?
A static, deployment-defined list, ALPINE_SIGN_ADMIN_GROUPS (default admin,administrator). Any OIDC
token whose groups claim contains one of these values gets ROLE_ADMIN. It is not a wizard field, so a
misconfigured wizard can never widen or lock the admin gate.
What stops an unauthorized person from initializing the DSS proxy?
Two things. The admin port (8091) is expected to be firewalled to trusted networks, and — more
importantly — the one-time shared secret issued by the Alpine backend (provision dss) is entered on
the final wizard step and verified by the backend when it registers the proxy's public key. A wrong
shared secret is never consumed, so initialization simply cannot complete without the correct one. If it
is rejected, the admin is taken to /admin/provisioning to re-enter it and retry.
Does the proxy send my documents to the validator?
No. The hand-off is a redirect: your own browser goes to the validator and you upload the document there, so no document travels from the proxy to the validator in either deployment shape.
Whether the document leaves your network at all depends on where the validator itself is. That is declared by
alpine.sign.validator.egress-allowed rather than inferred, and on a hosted install you are asked to confirm per
document before you are taken there. Note that the on-premises declaration is a configured value: the proxy checks it
against the one host it knows to be off-network, but it cannot verify your network topology, which is why the
destination host is shown on the action itself.