Skip to content

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_proxy schema (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.

  1. Ops start the app with ALPINE_SIGN_SECRET (the sealing key), optionally override ALPINE_SIGN_ADMIN_GROUPS (default admin,administrator), and hand the administrator the tenant UUID, DSS module UUID, and one-time shared secret from provision dss.
  2. The admin opens http://localhost:8091/admin/init — the wizard opens directly (no unlock screen) — then completes the six steps:
    1. 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_GROUPS list — see below. This step sets only where their groups are read from.) Scopes are not collected here: the wizard requests openid + groups + email, and they are editable afterwards on the Admin OpenID page.
    2. 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/admin and …:8090/login/oauth2/code/operator — or operator sign-in is refused by the provider with invalid_redirect_uri.
      • Operators reach nothing until their claim value is mapped to a group under Manage → Groups. A correct sign-in that ends in 403 means the claim name is right and the group mapping is missing.
    3. 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.
    4. Egress proxy (optional — leave disabled for a direct connection) — host, port, a no-proxy bypass list, and optional credentials. Includes a Test connection button.
    5. Backend (required) — remote signer URL (the DSS signer, e.g. http://localhost:8080) and the DSS-admin gRPC endpoint as a single grpc://host:port or grpcs://host:port target (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.
    6. Provisioning (required) — tenant UUID (tnt-…), DSS module UUID (mod-…), and the one-time shared secret, all taken from the provision dss output.
  3. 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.
  4. 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/provisioning to 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:

Certificate monitoring swept 12 active key(s): 0 acted on, all others checked and healthy.

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.

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: true with a blank base-url
  • egress-allowed: false pointing at alpine-pki.com or a subdomain of it
  • egress-allowed: true over plain http — 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.