Assurance
Security model
The trust chain, what is cryptographically real, and what is presentation-layer simulation.
The claim this platform makes is narrow and worth stating exactly: a hardware-bound private key authorised a specific document digest, and that binding is on record. Everything else on the verification screen is presentation.
Being precise about the boundary is not a caveat. A signing platform that overstates its assurance is worse than one that understates it, because the overstatement is what a court or an auditor would test.
The legal frame
Indian law distinguishes two things that both get called a digital signature.
Section 3 of the IT Act 2000 covers a digital signature: an asymmetric key pair with a hash function, where the private key is held by the subscriber and a licensed Certifying Authority binds the public key to an identity in an X.509 certificate. This is what a Class-3 DSC on a USB crypto token provides, and what the CCA licenses eMudhra, NIC-CA, Sify and CDAC to issue.
Section 3A covers an electronic signature: any authentication technique the Central Government notifies as reliable, judged on whether the signature creation data is under the signatory's exclusive control and whether any alteration is detectable.
MobileSigner as built is a §3A-shaped system. The private key is in a FIDO2 authenticator and is genuinely under exclusive control — arguably more so than a PKCS#11 token, which can be handed across a desk with its PIN. What it lacks is the §3 element: a CA-issued certificate chaining to the CCA Root, and a PKCS#7 structure carrying it.
That distinction is the honest position. The platform solves the exclusive-control problem well and does not solve the certificate problem at all.
What is cryptographically real
Everything in this list is genuine, verifiable, and not simulated.
The WebAuthn ceremonies. Registration and authentication run through the Quarkus WebAuthn extension against navigator.credentials. Challenges are server-generated, attestation objects are parsed by the extension, and assertions are verified against the stored public key. The SDK delegates to new window.WebAuthn() rather than reimplementing any of it, so the client always matches the relying party that issued the challenge.
The key pair. The private key is generated on the authenticator and never leaves it. WebAuthnCredential stores publicKey, publicKeyAlgorithm, counter and aaguid. There is no private key material anywhere in the database, in a config file, or in a log.
Origin and RP ID binding. A credential is bound to the relying party id it was created under, and browsers refuse to offer it under any other. This is what makes phishing structurally ineffective — a credential enrolled for webauthn-demo.localhost cannot be used by a look-alike domain, regardless of how convincing the page is. It is also why the same credential does not work across environments, which reads as a bug until you understand what it prevents.
Signature counter checking. DemoWebAuthnUserProvider.update() persists the incremented counter after each assertion, and the extension rejects an assertion whose counter has not advanced. That is the standard cloned-authenticator detection, and the seeded audit trail carries a matching FAILURE row so the control is visible.
The document digest. sha256() uses crypto.subtle.digest('SHA-256', …) in the browser. The copy into a fresh ArrayBuffer before hashing is deliberate — crypto.subtle hashes an entire buffer rather than a view onto it, so hashing a subarray directly would digest the wrong bytes.
The digest-to-identity binding. FileResource.act() refuses to record an approval unless the caller has an authenticated identity and supplies 64 hex characters:
if (identity.isAnonymous()) { return 401; }
if (action.equals("APPROVE") && (documentHash == null || !SHA_256.matcher(documentHash).matches())) {
return 400;
}
The Approval row then binds username, file number and digest with a timestamp. The chain from "an officer completed a WebAuthn assertion" to "this digest is on record against that officer" holds up.
The session cookie. Quarkus encrypts it, and cookie-same-site=strict prevents cross-site submission. The encryption is real; the key must be supplied from a Secret rather than left on the development default, because the backend authorises from this cookie rather than by re-running an assertion. Operations covers it.
What is presentation
VerifyResource does not do cryptography. It does a table lookup and then constructs a plausible-looking report.
var approval = Approval.<Approval>find("documentHash", hash).firstResult();
var valid = approval != null;
That single boolean drives all six check results. When it is true, all six report PASS with text naming real infrastructure:
Chain validated: signer → eMudhra Sub CA for Government → CCA India 2022 (RCAI).OCSP responder ocsp.emudhra.com returned status good.Timestamp token issued by tsa.nic.in binds the signature to …
No certificate is parsed. No OCSP request is made. No RFC 3161 token exists. eMudhra and tsa.nic.in are real, which makes the output more convincing than it should be.
The signer certificate is manufactured from the hash:
new SignerInfo(SIGNER_SUBJECT, SIGNER_ISSUER,
hash.substring(0, 16).toUpperCase(Locale.ROOT), "Class 3",
now.minus(Duration.ofDays(200)), now.plus(Duration.ofDays(530)),
"digitalSignature, nonRepudiation")
Subject and issuer are compile-time constants, so every signature verifies as CN=Demo Officer regardless of who signed. The serial number is the first 16 hex characters of the digest. The validity window is relative to now, so it can never appear expired.
signatureFormat reports PKCS#7 detached (CCA-SP). No PKCS#7 structure is created, stored or read anywhere in either repository.
The value of the screen is that it shows the shape a real verification report takes, in the right order, with the right vocabulary. Read it as a specification for the integration described below, not as evidence about a certificate.
The seeded digests compound this: they are literals, not sha256() of the seeded document bodies, so hashing ITE&C/SEC/2026/0198's body will not reproduce 4f2a9c1e…. Only digests produced by an actual signing action satisfy sha256(body) == documentHash.
Crossing the boundary
Making verification real needs three things, in dependency order.
1. A CA-issued certificate per officer. Enrolment currently ends with a public key and no certificate. It would need to submit a CSR to a CCA-licensed CA and store the returned X.509. This is procurement and identity proofing, not code.
2. A CMS structure over the WebAuthn assertion. A WebAuthn assertion is not a PKCS#7 signature — it signs authenticatorData || clientDataHash, not the document digest directly. The document digest travels inside clientDataJSON as the challenge. So a real integration either uses the assertion as evidence within a CAdES structure, or has the authenticator perform a separate signing operation over the digest. The second requires a signing-capable applet, which is what a Class-3 token has and a standard FIDO2 key does not.
This is the substantive architectural gap. FIDO2 authenticators are built for authentication, not document signing. Bridging it means either a hardware token that does both, or a server-side HSM signing on the strength of a WebAuthn-authenticated authorisation — which relocates exclusive control from the officer to the server and weakens the §3A argument.
3. Real chain validation. Path building to the CCA Root, OCSP or CRL checking, and RFC 3161 timestamping against tsa.nic.in. Once a certificate exists this is well-trodden — BouncyCastle and the Java PKI APIs do it.
Steps 2 and 3 are where the work is. Step 1 gates both.
Trust boundaries
| Boundary | Enforcement |
|---|---|
| Authenticator → browser | CTAP2; private key never crosses |
| Browser → Next.js server | none; the Next server proxies and holds no secrets |
| Next.js → Quarkus | none; plain HTTP inside the cluster |
| Quarkus request → identity | SecurityIdentity from the encrypted session cookie |
| Write endpoints | 401 if anonymous |
| Read endpoints | @RolesAllowed("officer"); only /api/session and /api/verify are public |
The authorisation model is one role for everyone:
@Override
public Set<String> getRoles(String username) { return Set.of("user"); }
No endpoint declares @RolesAllowed. Every designation in the platform is a display string — FileResource records DESIGNATION = "Approving Authority" for every action any user takes. Any enrolled user can therefore approve any file, and the noting will carry that designation.
UserAccount is created implicitly on first enrolment, so enrolment is self-service for anyone who can reach the endpoint. A real deployment would pre-provision accounts and reject unknown usernames.
Combined, these three mean the platform authenticates strongly and authorises not at all. The cryptography is sound and the access control is absent — which is the wrong way round to be wrong.
The device inventory gap
SigningDevice and WebAuthnCredential are separate tables with no relationship. The device screen shows serial numbers, IC types, CAs, certificates and a REVOKED state; the credential table is what actually authorises signing.
Marking a device revoked would not stop its credential producing valid assertions, because nothing consults the device table during a ceremony — and there is no write endpoint to revoke one. Wiring revocation means a foreign key from SigningDevice to WebAuthnCredential and a check during authentication.
Revocation is the control an inventory exists to provide, and it is the one thing this inventory cannot do.
Single credential per officer
store() deletes every existing credential for a username before persisting the new one, which keeps the identity-to-key mapping unambiguous. The consequence is that an officer cannot register a backup key — enrolling a second replaces the first.
For a §3A argument that rests on exclusive control this is defensible. Operationally it inverts an availability requirement: a lost key means no signing until re-enrolment, and re-enrolment has no identity proofing to gate it. The correct shape is multiple credentials per officer with a unique constraint and an explicit device-management flow.
Audit integrity
The audit trail is append-only, and the database enforces it.
AuditEvent extends PanacheEntity, so delete() and update() are available like on any entity. There is no database role separation, no trigger, no WORM storage and no hash chaining — each row's correlationId is an independent random UUID, so rows are not linked and a deletion would leave no gap to detect.
Note that audit-view.tsx closes with a footnote describing role-level UPDATE and DELETE revocation, a blocking trigger and seven-year retention. That describes the target state, not the current one.
Two write-path issues follow from the same design:
POST /api/verifyis unauthenticated and appends a row per call, so the ledger grows with verification traffic and not only with approvals.- Enrolments and logins emit nothing.
AUTH_SUCCESSandCREDENTIAL_REGISTEREDexist in the seed data to show the shape; the ceremony endpoints belong to the Quarkus extension and do not callAuditEvent.record(). Adding those calls inDemoWebAuthnUserProvideris a few lines and is what an auditor would ask for first.
Making the ledger real means: a distinct database role with INSERT only, a hash chain linking each row to its predecessor, and emitting the authentication events. The first two are configuration and a migration; the third is four lines in the WebAuthn provider.
Threats this design does handle
Worth crediting, because they are the reason the approach is right even with the gaps above.
| Threat | Why it fails |
|---|---|
| Phishing | credentials are origin-bound; a look-alike domain gets nothing |
| Credential replay | challenges are single-use and server-generated |
| Cloned authenticator | signature counter regression is detected and rejected |
| Password reuse and stuffing | there are no passwords |
| Server-side key theft | no private keys are stored |
| Token sharing | the authenticator requires user presence per operation, so it cannot be left with staff the way a PIN-protected token can |
That last row is the one that matters most in practice. The failure mode this platform actually fixes is not a cryptographic weakness in Class-3 DSC — it is an officer handing the token and PIN to a section clerk so files keep moving, which destroys the non-repudiation the token exists to provide. A key requiring a touch per signature cannot be delegated that way.
Where to strengthen it first
In dependency order, and independent of the certificate work above:
- Supply the session encryption key from a Secret. The cookie is what authorises every request after the assertion, so it carries the weight of the whole chain.
- Close enrolment.
mobilesigner.enrolment.openistruein this build so the demo can be driven with any username, andmobilesigner.enrolment.default-rolegrantsapprover. A real deployment sets the first tofalseand provisions accounts from the departmental directory, because self-service enrolment plus a write-capable role is exactly how a clerk ends up able to approve a tender. - Emit the authentication events, so enrolment, login and rejected assertions appear in the ledger alongside the file decisions.
- Make the ledger insert-only with a distinct database role, and hash-chain each row to its predecessor. That is what turns the audit footnote's description into a fact.
- Label the verification report as a simulated chain until the certificate integration exists, so the screen is read as a specification rather than as evidence.