Public API · Cloud Signature Consortium v2 · Tier C1 subset
/csc/v2 · remote-signature service.
A Cloud Signature Consortium (CSC) API v2 Tier C1 subset on CodeB Sovereign Communications. Each authenticated user gets an EC P-256 signing credential whose Subject Distinguished Name is enriched from the OIDC profile (CN = full name, given / family / e-mail with SAN rfc822Name). Signed hashes come back per RFC 5652 CMS; server-side signDoc assembles the full PAdES-B-B / B-T envelope; an RFC 3161 TSA proxy at signatures/timestamp upgrades a client-assembled signature to PAdES-B-T.
Scope disclosure — Advanced Electronic Signature only.
This remote-signature service produces AdES per Regulation (EU) 910/2014 Article 3(11). The signing key is software-backed (per-tenant PFX; per-user credential in Phase 1b) and the certificate is self-signed by an in-tenant CA — it is not a Qualified Signature Creation Device (QSCD) and not a Qualified Electronic Signature (QES). The ICryptoModule boundary is HSM-ready (Azure Key Vault and PKCS#11 back-ends are stubbed and return HTTP 501 until wired). Ready for European Digital Identity Wallet integrations via the OIDC + OID4VP substrate.
Two URL shapes, one handler. Endpoints are dispatched on ?action=<path>. Every URL shown below in the shape POST /csc/v2/<segment> is rewritten by IIS to POST /csc.ashx?action=<segment>. Both forms are accepted. The prefix path is the one advertised by /csc/v2/info and is the shape spec-compliant CSC clients expect.
Auth. Except for info (open) and auth/login (which merely echoes back an already-validated Bearer), every endpoint requires an OIDC access token in the Authorization: Bearer <token> header. The Bearer is introspected against /oidc.ashx?action=introspect to resolve the acting sub; that value keys the per-user signing credential and appears as the sub claim inside every SAD JWT.
Common limits (host-level RASP). Bodies are capped at 32 KiB (10 MiB for signatures/signDoc). A per-IP rate bucket admits 30 requests per minute per action; over-limit returns 429 with Retry-After: 30. Non-HTTPS requests are rejected 403 (https_required). All hashes MUST be SHA-256 (32 raw bytes, base64-encoded); other digests return 400 only_sha256_supported.
Invoice signing for GoBD / Revisionssicherheit. If you are here specifically to make invoices GoBD-compliant immutable under AO §147 with a qualified timestamp instead of a WORM appliance, three cookbooks walk through the practical flow: via API (server-to-server signing from ERP or accounting software), via personal EU Wallet (QR-scan flow for freelancers signing as natural persons), and via Business Wallet (bulk signing for accounting teams via CSC v2 RSSP).
CSC v2 §11.1 service metadata. Returns the supported spec version, the enumerated method list, the signing algorithm OIDs and the authentication type. Also reachable with GET for smoke tests.
Request
No parameters. Body ignored.
Response
{
"specs": "2.0.0.2",
"name": "Aloaha CodeB CSC Signing MVP",
"logo": "https://phone.aloaha.com/favicon.svg",
"region": "MT",
"lang": "en-US",
"description": "Cloud Signature Consortium v2 MVP -- AdES only, per-user signing credential (software-backed, not HSM). European Digital Identity Wallet integrations delivered via sign.html client. HSM-backed keys and QES via QSCD are outside the current phase.",
"authType": [ "oauth2code" ],
"oauth2": "https://phone.aloaha.com/oidc.ashx?action=authorize",
"methods": [
"auth/login", "auth/revoke",
"credentials/list", "credentials/info",
"credentials/authorize", "credentials/authorize/confirm",
"signatures/signHash", "signatures/signDoc",
"signatures/timestamp", "signatures/tsa", "ocsp"
],
"signAlgorithms": {
"algos": [ "1.2.840.10045.4.3.2", "1.2.840.113549.1.1.11" ]
},
"signature_qualifier_supported": [ "eu_eidas_aes" ]
}
Example
curl -X POST https://phone.aloaha.com/csc/v2/info
1.2.840.10045.4.3.2 = ecdsa-with-SHA256; 1.2.840.113549.1.1.11 = sha256WithRSAEncryption. The signature_qualifier_supported value eu_eidas_aes is CSC's identifier for Advanced Electronic Signature and matches the AdES-only scope disclosure above.
CSC v2 session-level token exchange. Accepts the OIDC access token in the Authorization header, introspects it against the CodeB OIDC provider to confirm the subject, and echoes the token back with a fixed expires_in. This makes CSC v2 clients that expect the specification's own login flow work without a second authorisation server.
Request headers
Authorization: Bearer <OIDC access token>
Request body
None — the token is taken from the header.
Response
{
"access_token": "<same token that was posted>",
"token_type": "Bearer",
"expires_in": 3600
}
Errors
401 invalid_token / bearer_required — header missing or not a Bearer.
401 invalid_token / introspect_failed — the token failed RFC 7662 introspection against the same tenant's OIDC provider.
Example
curl -X POST -H "Authorization: Bearer $ACCESS_TOKEN" \
https://phone.aloaha.com/csc/v2/auth/login
Revokes a previously issued session token. Delegates to /oidc.ashx?action=revoke. Returns { ok: true } unconditionally so revoke leaks no state about the token's prior validity.
Request body
{ "token": "<access token to revoke>" }
Response
{ "ok": true }
Errors
400 invalid_request / invalid_json — body is not JSON.
400 invalid_request / token_required — token field missing.
Lists the CSC credential IDs owned by the authenticated user. Under Phase 1b every user has a per-user EC P-256 credential, so the list has exactly one element. If the operator has set Csc:PerUserCerts=false, all users share the tenant-wide fallback credential.
Returns descriptor metadata for a specific credential: leaf certificate (base64 DER), Subject DN, Issuer DN, serial number, validity window, key algorithm OID + key length + curve OID, and module attributes. This is the same output CSC v2 defines in §11.4 with the CodeB-specific moduleType and moduleCertification attributes surfaced from the ICryptoModule boundary.
When the tenant has Csc:DefaultScal=2 the response also carries a PIN descriptor advertising that a Signature Activation Protocol second-factor is required:
Obtains a Signature Activation Data (SAD) — a short-lived JWT bound to the specific hash list the caller intends to sign. In SCAL1 the SAD is returned immediately; in SCAL2 a pending record is minted and the caller must complete a second-factor challenge via credentials/authorize/confirm. Selects between SCAL1 and SCAL2 via the tenant default (Csc:DefaultScal) or the per-request ?scal=1|2 query override.
The SAD is a compact JWS with payload claims iss, sub (= Bearer's introspected subject), aud=csc-sad, iat, exp (iat + 300 s), jti, credentialID, hash, hashAlgo, numSignatures, scal=1. Signed with ES256 by the tenant's SAD signing key when the credential is EC, or RS256 as an automatic fallback when the credential is RSA.
The webauthn_options object is present only when the user has previously registered a passkey; the client passes it verbatim to navigator.credentials.get() and posts the resulting assertion to credentials/authorize/confirm. The pending record TTL is 300 s.
SCAL2 second-factor confirmation. The client posts either a WebAuthn assertion (verified via CodeB.Passkeys.AuthenticationFacade per WebAuthn Level 2 — clientDataJSON type / origin / challenge match, rpIdHash, UP+UV flags, signature over authenticatorData || SHA-256(clientDataJSON), counter regression, one-shot session consume) or a PIN in either the JSON body or the X-CSC-Sign-PIN header (4–12 hex/digit characters).
{
"SAD": "eyJhbGciOiJFUzI1NiIs...<JWT>...",
"authorization_id": "<16-hex slug>",
"scal": "2",
"confirm_factor": "webauthn-hwk" /* or webauthn-swk, or pin */
}
On success the SAD carries an additional confirm_factor claim indicating whether the assertion was hardware-bound (webauthn-hwk), synced (webauthn-swk) or PIN-based (pin). Pending records are single-use and are deleted on both success and hard rejection.
400 webauthn_verification_failed — assertion failed a WebAuthn check; the specific reason is in the error_description.
403 invalid_token / user_mismatch — the Bearer's sub does not match the pending record's user.
404 invalid_request / authorization_not_found — unknown or already-consumed authorization ID.
For SCAL2 the RASP-side PIN filter enforces digits or hex, 4–12 characters. PINs are never written to logs. On WebAuthn hard-reject the pending record is deleted so an attacker cannot swap the assertion.
Signs one or more pre-computed SHA-256 hashes with the per-user credential. The caller assembles the CMS envelope client-side (as sign.html does for PAdES-B-B / B-T). The SAD must have been bound to exactly the hash list submitted here — count and content are verified constant-time.
{
"signatures": [ "<base64 raw signature bytes>" ]
}
The signatures[i] value is the raw signature over hash[i] under signAlgo: ECDSA r || s for ecdsa-with-SHA256, or the RSA signature primitive output for sha256WithRSAEncryption. Each successful signature is written to an append-only tenant audit journal keyed by sub, credentialID, hash and signature size.
CSC v2 §11.10 — server-side full-document signature. The caller ships one or more raw PDFs (base64) and CodeB does incremental-save PDF surgery, hashes the ByteRange, mints the CMS SignerInfo (with the same signed attributes as the browser flow: contentType, messageDigest, signingTime, signingCertificateV2 (RFC 5035 ESSCertIDv2 with issuerSerial), and RFC 6211cmsAlgorithmProtection), optionally fetches the RFC 3161 TimeStampToken and splices it as an id-aa-signatureTimeStampToken unsigned attribute (PAdES-B-T), and returns the fully-signed PDF.
Request body
{
"credentialID": "codeb-user-a1b2c3d4e5f60718",
"SAD": "<JWT from credentials/authorize with numSignatures ≥ documents.length>",
"documents": [
{
"document": "<base64 PDF bytes>",
"signature_format": "P",
"conformance_level": "AdES-B-T",
"signed_envelope_property": "Approval",
"container": "No",
"signAlgo": "1.2.840.10045.4.3.2",
"parameters": {
"signing_time": "2026-07-27T10:00:00Z",
"signing_reason": "I approve this contract",
"signing_location": "Valletta, MT",
"contact_info": "ada@example.org"
}
}
]
}
conformance_level: "AdES-B-B" (ETSI EN 319 142-1 baseline) or "AdES-B-T" (adds RFC 3161 signature-time-stamp).
signed_envelope_property: "Approval" only (certification signatures deferred).
container: "No" only (ASiC deferred).
signAlgo: 1.2.840.10045.4.3.2 (ECDSA-SHA256) or 1.2.840.113549.1.1.11 (RSA-SHA256).
parameters.signing_reason / signing_location / contact_info land in the PDF /Sig dict as /Reason / /Location / /ContactInfo.
MVP scope note — SAD hash-binding relaxed for signDoc.
Per CSC v2 §11.10, the Signature Activation Data (SAD) JWT should be bound to the specific hash(es) that will be signed. Because signDoc computes the document hashes on the server (the client uploads the whole PDF), the SAD cannot be pre-bound to those hashes at credentials/authorize time. This MVP relaxes that check: SADs are verified for signature validity, aud=csc-sad, exp / iat window, sub = Bearer subject, credentialID match, and numSignatures ≥ documents.length, but the specific hash list is not cross-checked. A future revision will implement the two-round-trip flow (upload → hash + challenge → authorize with those hashes → complete) for strict spec compliance. signHash continues to enforce full hash-binding as the spec requires. The relaxation is logged with [CSC-SIGNDOC-DIAG] SAD_ok (hash-binding relaxed for signDoc) so it appears in the audit trail.
400 unsupported_signature_format, unsupported_conformance_level, / only signed_envelope_property=Approval supported, / only container=No supported, / unsupported_signAlgo.
400 invalid_request / pdf_base64_invalid.
400 pdf_encrypted_unsupported — server-side signing of encrypted PDFs is deferred; the browser flow at sign.html supports Standard Security Handler V2 (RC4-128) and V4 (AES-128).
400 pdf_parse_error — xref / catalog / page structure could not be walked.
413 body_too_large — body exceeds the 10 MiB signDoc cap.
The full-document flow deliberately reuses the same on-disk credential and audit trail as signHash; the two endpoints are alternate transports for the same signing key.
RFC 3161 timestamp proxy. The server builds a TimeStampReq DER around a SHA-256 hash, posts it to the configured TSA, parses the TimeStampResp, extracts the TimeStampToken, and returns it base64-encoded. The browser signer (sign.html) splices this token into the CMS SignerInfo.unsignedAttrs as id-aa-signatureTimeStampToken, upgrading PAdES-B-B to PAdES-B-T.
Also available: an in-house RFC 3161 TSA at POST /csc/v2/signatures/tsa that speaks the wire protocol directly and signs with a tenant-generated responder certificate. Useful for air-gapped or sovereign deployments where the trust chain must not leave the tenant.
TSA URL source
The TSA URL is read from the Windows registry at HKLM\SOFTWARE\CodeB, value TSAURL. If the value is empty or missing, the server writes back the default http://timestamp.sectigo.com/qualified and uses it. If the value is present but not a valid http[s]:// URL, the server returns 500 invalid_tsa_url instead of silently falling back — operator intervention is required.
Request body
{
"hash": "<base64 SHA-256 hash of the CMS SignerInfo signature bytes>",
"hashAlgo": "2.16.840.1.101.3.4.2.1",
"nonce": "0a1b2c3d4e5f60718" /* optional, hex; auto-generated when absent */
}
500 invalid_tsa_url — registry value present but not a URL. Operator must fix HKLM\SOFTWARE\CodeB\TSAURL.
502 tsa_upstream_failed — the TSA rejected the request, returned an empty body, or the TimeStampResp status was neither 0 (granted) nor 1 (granted-with-mods). The error_description carries the parsed reason (webexception, crash, empty, parse, status_<code>, no_token_after_status).
RFC 3161 TSA issuer (companion to the proxy above). Speaks the RFC 3161 wire protocol directly: post a DER TimeStampReq, receive a DER TimeStampResp signed by the tenant's own auto-generated responder cert (EKU id-kp-timeStamping). Useful for air-gapped or sovereign deployments where the trust chain must stay in-tenant. See the dedicated TSA server page for the full profile, certificate details, serial-counter behaviour and fallback semantics.
Not eIDAS-qualified. Tokens issued here fall under Regulation (EU) 910/2014 Article 41 — admissible as evidence, but without the presumption of accuracy qualified timestamps carry.
RFC 6960 OCSP responder for the per-user signing certs, the TSA responder cert and the OIDC / VCI issuer certs. Answers CertID queries with a BasicOCSPResponse signed by the tenant's own responder key (ECDSA-with-SHA-256). Used by the browser signer to embed id-aa-ets-revocationValues inside every PAdES-B-LT / B-LTA envelope. See the dedicated OCSP responder page for coverage, revocation-source, delegated-signing notes and observability.
Request
Content-Type: application/ocsp-request
Body: DER-encoded OCSPRequest (RFC 6960 s4.1.1)
tbsRequest.requestList carries one or more CertID queries
The browser signer writes a PDF signature dictionary with the following fields. When you use signHash, these are entirely under client control because the client assembles the whole envelope. When you use signDoc, the server assembles the dictionary; the caller supplies the values through parameters.*.
/Filter /Adobe.PPKLite and /SubFilter /adbe.pkcs7.detached — the CMS envelope is CAdES-B-B by structure (signedAttributes include contentType, messageDigest, signingTime, signingCertificateV2 with issuerSerial, and id-aa-CMSAlgorithmProtection). The /adbe.pkcs7.detached SubFilter routes to Adobe Reader's built-in PKCS7 validator (widely-tested), which accepts the CMS as PAdES basic profile per PAdES §4.2. The stricter /ETSI.CAdES.detached SubFilter is not currently emitted; reserved for a future PAdES-baseline profile.
/Reason — client-controlled (signHash) or from parameters.signing_reason (signDoc).
/M — UTC signing timestamp in PDF D:YYYYMMDDHHMMSSZ form. Server-set on signDoc (from parameters.signing_time when provided, else DateTime.UtcNow).
/Name — pending: currently derived server-side from the Subject DN Common Name. A future release will expose it as parameters.signer_name.
/ByteRange — the four-integer array covering the entire PDF except the placeholder hex window (brackets included in the hole). Filled after CMS assembly so the ByteRange itself is inside the signed range.
Convenience endpoint for server-to-server callers who want a one-shot sign: send raw PDF bytes + HTTP Basic user/password, get back the signed PDF. No SAD dance, no OIDC bearer exchange, no base64 wrapping. Uses the same PDF/CMS/TSA/OCSP helpers as signatures/signDoc; the differences are auth (HTTP Basic instead of Bearer + SAD) and wire (application/pdf in / application/pdf out instead of JSON with base64).
Auth
Authorization: Basic base64(username:password). Realm = the tenant host (matches login.html's HA1 computation: ha1 = MD5(user:realm:password)). Verified against the ha1 stored in the per-tenant credentials file App_Data/<tenant-domain>/sip-credentials/<tenant-slug>.json (shape: { realm, users: { <user>: { ha1, role, ... } } }). Password grant is disabled by default — enable per tenant via Oidc:EnablePasswordGrant=true in App_Data/<tenant-domain>/appsettings.json. All 401 responses return the same opaque invalid_credentials body regardless of failure reason (missing user, wrong password, disabled account, etc.) to avoid a user-enumeration oracle; the specific reason is written to the internal [CSC-SIGNDOCBASIC-DIAG] trace only.
Roles that can call this endpoint: any user with a valid HA1 that has NOT been disabled. Role values in the CodeB stack are superuser (per-machine operator credential from the bridge appsettings), admin (per-tenant admin), poweruser (elevated quotas but non-admin — new 2026-08-12), user (default), siponly (SIP phone only, no browser app) and guest (sign-in only). Full role documentation on the roles & access model page. The signDocBasic endpoint does not gate on role, but rate-limit budgets and per-day signature quotas are role-tiered by the operator's policy.
Request
POST /csc/v2/signatures/signDocBasic?level=B-T
Host: phone.aloaha.com
Authorization: Basic YWxpY2U6Y29ycmVjdGhvcnNlYmF0dGVyeXN0YXBsZQ==
Content-Type: application/pdf
Content-Length: 45812
<raw PDF bytes>
Query parameters
level — one of B-B, B-T, B-LT, B-LTA (default). Case-insensitive. Optional AdES- / PAdES- prefix stripped. See auto-degrade cascade below.
reason — optional signing reason string (default: Signed with CodeB Web Wallet).
location — optional PDF /Location field.
contact — optional PDF /ContactInfo field.
Auto-degrade cascade
Unlike signatures/signDoc, which fails hard when the tenant is not configured for the requested conformance level, signDocBasicauto-degrades to the next-lower level rather than failing the whole sign. This lets callers ask for B-LTA (the new default) unconditionally and receive whatever the tenant can actually produce — useful for integrators who want the strongest signature possible without pre-flighting the tenant's TSA / OCSP configuration.
The cascade rules:
TSA fetch fails (no Csc:TsaUrl configured, TSA server down, upstream 4xx/5xx) → drop to B-B (baseline PAdES with signed attributes, no timestamp).
OCSP responder key missing (LT augmentation cannot build revocation-values) → drop to B-T.
Archive-timestamp append fails (no room, second TSA fetch fails) → drop to B-LT.
Baseline (B-B) is always achievable given a working per-user signing key.
The response carries two headers so the caller knows what actually happened:
HTTP/1.1 200 OK
Content-Type: application/pdf
X-CSC-Requested-Level: AdES-B-LTA
X-CSC-Effective-Level: AdES-B-LT <-- degraded because archive TS append failed
If X-CSC-Requested-Level equals X-CSC-Effective-Level, no degrade happened. Both headers use the canonical AdES-B-* spelling. Integrators whose compliance regime requires a specific level should read X-CSC-Effective-Level and reject responses below their bar.
Try it live
Pick a PDF, enter the Basic-auth credentials for the signer (any user in App_Data/<this-tenant>/sip-credentials/<tenant>.json), choose a level, and click Sign. The response downloads as signed-<filename>.pdf. All auth happens against this tenant — the request is sent from your browser directly to /csc.ashx, so the credentials never leave the tenant boundary.
Anonymous callers cannot enumerate users: every failed Basic-auth attempt returns the same opaque invalid_credentials body regardless of whether the username exists, is disabled, or the password is simply wrong.
Response
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 51248
<signed PDF bytes>
Error responses
400 invalid_request — body empty, not a PDF, or level not recognised.
400 pdf_encrypted_unsupported — source PDF is encrypted; decrypt before sending.
400 pdf_parse_error — malformed xref / catalog.
401 invalid_credentials — Basic auth missing, malformed, or HA1 mismatch. Response body is always the opaque {"error":"invalid_credentials","error_description":"invalid_username_or_password"} (no username / disabled / password enumeration). Response includes WWW-Authenticate: Basic realm="codeb-csc". Detailed reason is in the server trace under [CSC-SIGNDOCBASIC-DIAG] reason=.
413 pdf_too_large — body over 10 MiB.
500 crypto_module_error — signing key not available on tenant.
501 crypto_module_not_configured — tenant has no signing module configured.
Note: unlike signDoc, signDocBasicnever returns 502 tsa_upstream_failed. If the TSA is unreachable the response degrades to AdES-B-B and reports the effective level in the X-CSC-Effective-Level header (see auto-degrade cascade).
Notes
Same signing pipeline as signDoc — same PAdES envelope, same TSA / OCSP / archive-timestamp behaviour, same per-user EC P-256 credential.
Basic auth is straightforward for legacy clients (VB6, VBA, VB.NET 3.5, Excel macros) that cannot easily orchestrate OIDC authorization-code flows.
Per-user rate limit applies (same buckets as signDoc).
Data handling & retention
The signed PDF is transient. It is held only in server memory for the duration of one HTTP request and is never written to disk. Concretely:
Upload → RAM. Your PDF is read from the POST body into a byte buffer in the ASP.NET worker process.
Signing → RAM. The signed output (incremental update + CMS + optional B-LTA archive timestamp) is built in a second byte buffer alongside the input. No temp file. No cache directory. No %TEMP%. No disk cache.
Response → streamed to you. Bytes are written to the response stream via BinaryWrite and delivered with explicit Cache-Control: no-store, no-cache, must-revalidate, max-age=0 + Pragma: no-cache + Expires: 0 (plus Response.Cache.SetCacheability(NoCache)) — no IIS output cache, no browser HTTP-cache, no intermediary proxy is allowed to retain a copy.
Request ends → GC eligible. Both buffers become unreachable when the handler returns and are reclaimed by the .NET garbage collector, typically within seconds. No long-lived reference is held anywhere on the tenant.
What IS persisted on our side (this is the honest full list):
A single JSONL audit line per successful sign, appended to App_Data/<tenant>/audit/csc-YYYY-MM.log.jsonl. The line contains ONLY: UTC timestamp, first-8 chars of a SHA-256 hash of the username (opaque one-way hash), the per-user credential ID, first 8 chars of the CMS hash-to-sign (insufficient to identify which document was signed), the signature length in bytes, and the signing-cert serial number. The PDF itself, its filename, the signing reason string, the file bytes, or the full hash are not recorded. Log files rotate monthly and are retained until you delete them — no automatic purge.
Server trace lines under [CSC-SIGNDOCBASIC-DIAG], [CSC-PAGE-DIAG], [METRIC]. These record tenant name, Redact(subject) (one-way-hashed username), byte lengths, ByteRange values, page numbers, and pipeline outcomes — no PDF content, no credentials, no PII. Retention depends on your IIS trace-listener configuration.
Client-side alternative — sign.html. If you want zero server-side exposure of the PDF, sign.html performs the entire PAdES assembly in the browser and only calls the server for the private-key crypto operation (posting a 32-byte hash, receiving a ~72-byte signature). The PDF bytes never leave the client. Use this flow whenever the client is a modern browser; use signDocBasic when the client is a legacy backend that can't run the JS assembler.
Compliance note: This retention model was designed against GDPR minimisation principles (Art. 5(1)(c)) — we retain only what is cryptographically necessary for non-repudiation of the sign operation itself. If you need stricter deletion guarantees (e.g. "right to erasure" for a specific user's signing metadata), the audit JSONL is the only artefact on our side; matching lines can be removed by user-hash.
Sample: VB.NET (targets .NET Framework 3.5)
Drop this into a Module or Class in your VB.NET project targeting .NET Framework 3.5. Uses only BCL types (System.Net.HttpWebRequest, System.Convert, System.Text.Encoding) — no third-party dependencies.
Imports System.IO
Imports System.Net
Imports System.Text
Public Module CodeBSignClient
''' <summary>
''' Signs a PDF via the CodeB CSC v2 signDocBasic endpoint.
''' </summary>
''' <param name="baseUrl">Tenant base URL, e.g. "https://phone.aloaha.com"</param>
''' <param name="pdfBytes">Raw PDF bytes to sign</param>
''' <param name="username">Signer username</param>
''' <param name="password">Signer password (plaintext; over TLS only)</param>
''' <param name="level">"B-B", "B-T", "B-LT" or "B-LTA"</param>
''' <param name="reason">
''' Optional. Free-text signing reason baked into the PDF signature dict's
''' /Reason field (visible in Adobe's Signatures panel and validators).
''' Examples: "Approved", "I am the author of this document",
''' "Reviewed by Legal 2026-08-19". Server-side capped at 500 characters
''' and defensively escaped. Pass Nothing or "" to accept the server
''' default ("Signed with CodeB Web Wallet").
''' </param>
''' <returns>Signed PDF bytes</returns>
''' <exception cref="WebException">on HTTP 4xx/5xx</exception>
Public Function SignPdf(ByVal baseUrl As String, _
ByVal pdfBytes As Byte(), _
ByVal username As String, _
ByVal password As String, _
ByVal level As String, _
Optional ByVal reason As String = Nothing) As Byte()
If baseUrl Is Nothing OrElse baseUrl.Length = 0 Then _
Throw New ArgumentException("baseUrl required")
If pdfBytes Is Nothing OrElse pdfBytes.Length = 0 Then _
Throw New ArgumentException("pdfBytes required")
If username Is Nothing OrElse username.Length = 0 Then _
Throw New ArgumentException("username required")
If password Is Nothing Then password = ""
If level Is Nothing OrElse level.Length = 0 Then level = "B-T"
' TLS 1.2 is required by the server. .NET 3.5 SP1 supports it if the
' registry has SchUseStrongCrypto set; otherwise value 3072 works.
Try
ServicePointManager.SecurityProtocol = _
CType(3072, SecurityProtocolType) Or ServicePointManager.SecurityProtocol
Catch
' Older frameworks may throw; the server-side TLS check will surface
' the real error when the connection fails.
End Try
Dim url As String = baseUrl.TrimEnd("/"c) & _
"/csc.ashx?action=signatures/signDocBasic&level=" & _
Uri.EscapeDataString(level)
' Optional signing reason -- appended as &reason=... query param
' when the caller supplied a non-empty value. Server accepts it as a
' UTF-8 percent-encoded string and inserts it into the /Reason field
' of the PDF signature dictionary.
If reason IsNot Nothing AndAlso reason.Length > 0 Then _
url = url & "&reason=" & Uri.EscapeDataString(reason)
Dim req As HttpWebRequest = CType(WebRequest.Create(url), HttpWebRequest)
req.Method = "POST"
req.ContentType = "application/pdf"
req.Accept = "application/pdf"
req.KeepAlive = False
req.Timeout = 120000 ' 2 min — signing large PDFs + TSA fetch can take time
req.UserAgent = "CodeB-VBNet35-Client/1.0"
' HTTP Basic auth header
Dim credsBytes As Byte() = Encoding.UTF8.GetBytes(username & ":" & password)
req.Headers.Add("Authorization", "Basic " & Convert.ToBase64String(credsBytes))
' Write the PDF into the request body
req.ContentLength = pdfBytes.Length
Using rs As Stream = req.GetRequestStream()
rs.Write(pdfBytes, 0, pdfBytes.Length)
End Using
' Read the signed PDF back
Using resp As HttpWebResponse = CType(req.GetResponse(), HttpWebResponse)
If resp.StatusCode <> HttpStatusCode.OK Then _
Throw New WebException("Unexpected HTTP " & CInt(resp.StatusCode))
Using ms As New MemoryStream()
Using rs As Stream = resp.GetResponseStream()
Dim buf(8191) As Byte
Dim n As Integer
n = rs.Read(buf, 0, buf.Length)
While n > 0
ms.Write(buf, 0, n)
n = rs.Read(buf, 0, buf.Length)
End While
End Using
Return ms.ToArray()
End Using
End Using
End Function
' -----------------------------------------------------------------
' Example calling code — sign a file on disk and save the result:
' -----------------------------------------------------------------
Public Sub SignFileExample()
Dim srcPath As String = "C:\invoices\invoice-2026-08-123.pdf"
Dim dstPath As String = "C:\invoices-signed\invoice-2026-08-123.pdf"
Directory.CreateDirectory(Path.GetDirectoryName(dstPath)) ' avoid DirectoryNotFoundException on first run
Dim pdfIn As Byte() = File.ReadAllBytes(srcPath)
' Minimal call -- accept server-default reason ("Signed with CodeB Web Wallet"):
Dim pdfOut As Byte() = SignPdf(_
"https://phone.aloaha.com", _
pdfIn, _
"alice", _
"correcthorsebatterystaple", _
"B-T")
File.WriteAllBytes(dstPath, pdfOut)
' With an explicit signing reason (visible in Adobe's Signatures panel):
Dim pdfOutWithReason As Byte() = SignPdf(_
"https://phone.aloaha.com", _
pdfIn, _
"alice", _
"correcthorsebatterystaple", _
"B-LTA", _
"Approved by finance -- invoice #2026-08-123")
File.WriteAllBytes(dstPath, pdfOutWithReason)
End Sub
End Module
VB6 note. The same wire (POST body = PDF, Basic auth header, ?level= query) works from VB6 with MSXML2.ServerXMLHTTP60. Use .setRequestHeader "Authorization", "Basic " & Base64Encode(user & ":" & pass) and .send bytes. Response bytes are in .responseBody.