The Certum SimplySign cloud-signing protocol

September 14, 2026 · View on GitHub

This is the reverse-engineered reference for the HTTPS protocol that ssign speaks to Certum's SimplySign cloud signing service. It documents the validated end-to-end flow (captured against SimplySign Desktop 2.9.14 via a redacting MITM, then re-implemented from scratch), the version pitfall that blocks older clients, and — in the appendices — the dead ends the static analysis first suggested, so nobody has to walk them again.

Reverse-engineered from the author's own licensed SimplySign Desktop install, for interoperability. No user secret is recorded here — only app-level constants and protocol structure. Not affiliated with or endorsed by Certum / Asseco; "Certum" and "SimplySign" are their trademarks.

Host: cloudsign.webnotarius.pl (all calls). Timestamp authority: http://time.certum.pl/ (RFC 3161, standard, unrelated to auth).

The whole flow is plain HTTPS — there is no cryptographic activation wall (see Appendix A). Session control is the OAuth bearer token, nothing more. That is what makes a GUI-less, container-less, PKCS#11-less signer possible.


The flow, end to end

Each step below maps to the module in ssign-core/src/ that implements it.

1 · Login — OAuth 2.0 authorization-code via the CAS IdP (auth.rs)

Login is not ROPC (resource-owner password) as the binary strings first suggested — it is a full CAS authorization-code dance. The credentials are the account e-mail and a 6-digit TOTP OTP (otp.rs derives it from the seed); there is no stored account password.

  1. GET /idp/oauth2.0/authorize?response_type=code&client_id=<id>&redirect_uri=…&scope=…/idp/oauth2.0/profile302, sets a cookie, redirects to /idp/login?service=…
  2. GET /idp/login?service=…200, an HTML form carrying a hidden CAS execution token (~4700 chars) and lt.
  3. POST /idp/login?service=… (form-urlencoded): username = e-mail, password = 6-digit OTP, execution = the token from step 2, _eventId=submit, submit=LOGIN, lt, geolocation= (empty) → 302, sets the TGT cookie.
  4. GET /idp/oauth2.0/callbackAuthorize?…&ticket=…302
  5. GET /idp/oauth2.0/authorize (again) → 302redirect_uri?code=<code>
  6. POST /idp/oauth2.0/accessToken?client_id=<id>&client_secret=<secret>&scope=…&code=<code>&redirect_uri=…&grant_type=authorization_code200 {"access_token":…,"token_type":"bearer","expires_in":1800,"refresh_token":…}

Two distinct OAuth clients. This web-login client_id/client_secret are 20-char strings (the ones ssign uses, embedded in auth.rs). They are not the 64-hex API client found in SimplySign Desktop's plist — that pair belongs to the PKCS#11/redirector path, which ssign does not use.

Every call from here on carries Authorization: Bearer <access_token>.

2 · Materialize the card (card.rs)

After login, fetch the card and its certificate. Each is an async task (POST …/tasks303GET the result):

  • POST /card/v1/cards/tasks → card list: {profile,label,cardno,pinrequired:false,maxkeysno,validthru}
  • POST /card/v1/cards/{serial}/keys/tasks → keys (multipart; not called by ssign)
  • POST /card/v1/cards/{serial}/certificates/tasks → certificates (multipart; the signing certificate as PEM)

pinrequired is false — the card needs no PIN; the bearer token is the sole control. The exposed key is RSA-4096; the private key stays in the cloud HSM.

3 · Compute the Authenticode digest (authenticode.rs, msi.rs, asn1.rs)

Locally, hash the PE (or MSI) the Authenticode way → a SHA-256 digest. No network, no cloud involvement.

4 · Sign — SCS1_ATOM async protocol (sign.rs)

Three calls, all Authorization: Bearer:

  1. POST /card/v1/cards/{serial}/certificates/signature202 multipart/form-data, two parts:
    • req (application/json;charset=UTF-8): {"digests":["<SHA-256 hex, 64 chars>"],"digesttype":"SHA256"}
    • certificate (application/octet-stream, filename blob): the signing cert exactly as issued (PEM); the endpoint rejects a DER re-encoding → resp {"state":…,"atom:link":<poll URL>,"message":…,"ping-after":<ms>}
  2. GET /scs1/card/v1/cards/{serial}/certificates/signature/task/{taskId}303{"state":…,"atom:link":<result URL>,"message":…} — poll until ready
  3. GET /scs1/card/v1/cards/{serial}/certificates/signature/{resultId}200[{"<digest-hex-64>":"<signature 1024-hex = 512 bytes = RSA-4096>"}]

5 · Assemble & embed (sign.rs, timestamp.rs, certs/)

Wrap [RSA signature + certificate chain + RFC 3161 timestamp from http://time.certum.pl/] into a PKCS#7 blob and embed it in the file. Standard Authenticode assembly.


Version gotcha — why older clients loop forever

This was the root cause of the early failures, and it was not the proxy.

  • SimplySign 2.9.10 (Dec 2017 client): POST /card/v1/cards/tasks returns 415 in a tight loop → the card never inserts → the login page loops.
  • SimplySign 2.9.14 (Mar 2025 binary, same PKCS#11 module 1.0.20): speaks the current API — clean login, no /card/tasks 415 loop.

ssign targets the 2.9.14-era protocol directly, so this only matters if you compare against a captured older-client trace.


Appendix A — superseded hypotheses

The initial static analysis of the SimplySign Desktop binary suggested a more elaborate scheme that the live capture disproved. Recorded here so the strings in the binary don't send anyone back down these paths:

  • SAD (Signature Activation Data). The binary contains templates for a SHA-512-bound, RSA-encrypted sadEncryptedData object over {pin, nonce, encryptkeyid, usertoken, cardno, …}, plus a scs-sad/v1/infrastructureKey endpoint to fetch the encrypting RSA key. None of this is exercised for a cloud code-signing cert: the request has no sadEncryptedData, no encryptKeyId, no pin, no nonce. The SAD path belongs to PIN-protected cards; ours reports pinrequired: false.
  • ROPC login. O2::GrantFlow lists GrantFlowResourceOwnerPasswordCredentials and the only literal token template in the binary is the refresh_token one, which made ROPC (grant_type=password) look likely. The real login is the CAS authorization-code flow in §1.
  • The 64-hex API client (client_id/client_secret in the plist, OAuth2ProtectClientCredentials=Yes) is for the redirector/PKCS#11 layer, not the web login. ssign uses the 20-char web client instead.

Appendix B — why the PKCS#11 module can't sign on its own

SimplySignPKCS_64-MS-1.0.20.so is a thin shim with no network code. It talks to the CloudConnector / "redirector" inside SimplySign Desktop over a named pipe (cccPipeName) + shared memory (shmHandle) — commands like pkislGetUserData, pkislGetCertificateList. The Desktop app holds the OAuth session and makes the HTTPS calls. So PKCS#11 is useless without the authenticated Desktop running. ssign skips this entire layer and talks HTTPS to the signing service directly.

Appendix C — how this was captured

The protocol above was recorded with a redacting MITM: mitmproxy over SOCKS5 (the app ignores http_proxy, so it was routed via proxychains-ng), with the mitmproxy CA dropped into SimplySign's private CACerts/ store to defeat pinning. A mitmproxy addon logged request/response structure only — URLs, param keys, JSON field names and types — and masked every value behind a fail-closed allowlist, so no token, OTP, PIN or signature was ever written to disk. The redacted trace is what made the capture safe to keep and to reason about; the exact byte values in this document are lengths (<43> = 43 chars), not secrets.

A real hello.exe was signed end-to-end through this flow on 2026-07-10 with the live certificate (CN = Sylvain GARGASSON, issuer Certum Code Signing 2021 CA), timestamped and verified OK — which is what validated everything above before ssign was written.