eIDAS QES Design (TrustWeave 0.7.x)
Status: This is a forward-looking design document for adding eIDAS 2.0 qualified-electronic-signature
(QES) support to TrustWeave. None of the modules, packages, types, or wiring described below exist
in the repository yet. The document is intended to guide the implementation work targeted for the
TrustWeave 0.7.x release line. References to existing code (e.g. KeyManagementService,
CredentialProof, the credentials/credential-api engine registry) have been validated against
the current main branch and link to the actual sources where applicable.
1. Goals
- Native eIDAS 2.0 qualified signatures. Produce signatures that, by construction, satisfy the Advanced Electronic Signature (AdES) requirements of eIDAS 2.0 (EU 2024/1183). When the signing key is bound to a Qualified Signature Creation Device (QSCD) and the signing certificate is issued by a Qualified Trust Service Provider (QTSP) listed on a Member-State Trusted List, the same code path yields a Qualified Electronic Signature (QES).
- JAdES as the primary format. ETSI TS 119 182-1 (JAdES) is the JSON-native AdES profile and is the only profile that wraps cleanly around the credential formats TrustWeave already issues (W3C VC-JWT, IETF SD-JWT VC, OID4VCI JWT VC). Implementing JAdES first lets a single signature envelope cover every JWT/JSON-LD-shaped credential the platform produces.
- PKCS#11 binding for QSCDs. A new
kms:plugins:pkcs11module implements the existingKeyManagementServiceSPI on top ofsun.security.pkcs11.SunPKCS11. Any standards-conformant QSCD or HSM is then usable as a signing backend without device-specific code in the credentials or signatures domains. - LoTL/TSL-based trust resolution. Discovery of qualified-TSP certificates happens against the EU List of Trusted Lists (LoTL) and per-Member-State Trusted Service Lists (TSLs) as defined by ETSI TS 119 612, so that the trust anchor for a QES verification is the official European trust graph, not a hand-curated allow-list.
- No commercial dependencies for the QES code path. All new modules build only on Bouncy
Castle, Nimbus JOSE+JWT, the JDK
SunPKCS11provider, and the existing TrustWeave SPIs.
2. Non-goals (MVP)
- CAdES (ETSI EN 319 122-1), XAdES (ETSI EN 319 132-1), PAdES (ETSI EN 319 142-1). Out of scope — the MVP targets JSON payloads only.
- The B-LT and B-LTA JAdES profiles. Long-term-validation material (validation data references, archival timestamps) is deferred until B-B + B-T are stable.
- Full conformance to ETSI EN 319 102-1 Procedures for Creation and Validation of AdES Digital Signatures. The MVP provides cryptographic validation and trust-anchor resolution; the full validation procedure (signature policy evaluation, time-of-signing certificate validity, PoE-driven status determination) is a follow-up.
- Self-verification of the LoTL XAdES signature. The MVP treats the LoTL XML as a pre-verified trust-anchor input supplied by the operator.
- Conversion of any module to Kotlin Multiplatform. TrustWeave remains JVM-only (Kotlin 2.3.21, JVM 21) for this work.
3. Standards in scope
| Standard | Version / Date | What TrustWeave needs from it |
|---|---|---|
| eIDAS 2.0 — Regulation (EU) 2024/1183 | OJ L 2024/1183, 30 Apr 2024 | Legal definitions (AES, QES, QSCD, QTSP); Annex VII (qualified certificate content); justifies the trust-list-driven validation model. |
| ETSI EN 319 122-1 | v1.3.1 (2023-06) | CAdES base — referenced only because JAdES uses several CAdES attribute semantics (signing-certificate-v2, signing-time). Out of scope for code. |
| ETSI EN 319 132-1 | v1.3.1 (2023-06) | XAdES — out of scope for the QES code path, but the LoTL XML uses XAdES signatures (deferred LoTL self-verification will depend on this). |
| ETSI TS 119 182-1 (JAdES) | v1.2.1 (2023-06) | Primary. Defines JAdES header parameters (sigT, x5t#S256, x5c, sigPSt, sigPId, etsiU, xVals, rVals, arcTst), the B-B and B-T profiles, and the canonical serialization rules used for both signing input and verification. |
| ETSI EN 319 102-1 | v1.4.1 (2023-06) | Validation procedure model. MVP implements only the building-block “Basic Signature Validation” path; “Validation with Time” and “Validation with Time and Validation Data” are deferred. |
| ETSI TS 119 612 | v2.3.1 (2024-05) | Trusted List format. Provides the schema for LoTL and per-MS TSL XML — used by trust-lists to parse TrustServiceProvider, TSPService, and ServiceDigitalIdentity structures. |
| RFC 3161 | Aug 2001 | Time-Stamp Protocol. Wire format for the TimeStampReq and TimeStampResp messages exchanged with a TSA. Required by JAdES B-T. |
| RFC 5652 | Sep 2009 | CMS — underlying ASN.1 format of TimeStampToken returned inside a TimeStampResp. We do not implement CMS ourselves; Bouncy Castle’s org.bouncycastle.tsp and org.bouncycastle.cms packages handle parsing. |
| PKCS#11 v2.40 (with v3.0 awareness) | OASIS, 2015 / 2020 | Cryptoki interface used through SunPKCS11. We standardize on a v2.40 mechanism subset (CKM_ECDSA, CKM_SHA256/384/512_RSA_PKCS_PSS, CKM_ECDSA_SHA256 etc.). |
| EN 419 221-5 | v1.0 (2018-04) | Protection Profile for cryptographic modules of trust services — informative; constrains which HSMs qualify as QSCDs. Not implemented, but the design must not block operators from binding to such devices. |
| W3C XML Signature Syntax | Second Edition (2008) | Only relevant for eventually verifying the LoTL signature. The MVP treats LoTL as pre-verified input, so this is touched only minimally (XML namespace handling during TSL parsing). |
4. Module layout
The QES work introduces a sixth top-level domain, signatures/, alongside the existing five
(did/, wallet/, kms/, anchors/, credentials/).
1
2
3
4
5
6
7
8
9
10
11
signatures/ [NEW DOMAIN]
tsa-core/ RFC 3161 TSA client + TimeStampToken model
trust-lists/ LoTL + TSL parser, trust-anchor resolver
jades/ JAdES B-B and B-T engines + verifier
kms/plugins/pkcs11/ [NEW] QSCD / HSM binding via SunPKCS11
credentials/credential-api/ [MODIFIED]
- new CredentialProof.JAdES variant
- new JAdESProofEngine in the engine registry
- IssuanceBuilder / VerificationBuilder DSL hooks
4.1 signatures:tsa-core
- Purpose: Issue RFC 3161 time-stamp requests against any RFC 3161 TSA endpoint, return a
validated
TimeStampTokenvalue object. Pure HTTP + ASN.1. - Public package:
org.trustweave.signatures.tsa - Key dependencies: Bouncy Castle (
bcprov-jdk18on,bcpkix-jdk18on— already in classpath viagradle/libs.versions.toml), OkHttp (already in classpath), no Nimbus.
4.2 signatures:trust-lists
- Purpose: Parse the EU LoTL and the per-Member-State TSLs that the LoTL references. Build
an in-memory trust graph of QTSPs and their qualified service certificates. Expose a
TrustAnchorResolverthat, given a signing-certificate’s issuer + key identifier, answers whether the certificate chains to a trusted QTSP and what qualifier types apply (http://uri.etsi.org/TrstSvc/Svctype/CA/QC,http://uri.etsi.org/TrstSvc/TrustedList/SvcInfoExt/QCWithSSCD, etc.). - Public package:
org.trustweave.signatures.trustlists - Key dependencies: JDK XML (
javax.xml.parsers,javax.xml.namespace), Bouncy Castle for X.509 parsing. No JAXB — the parser is hand-rolled against the TS 119 612 schema using StAX/DOM, to keep the artifact small and Java 21 friendly.
4.3 signatures:jades
- Purpose: Build and verify JAdES B-B and B-T signatures. Composes
kms:kms-core(for signing operations),signatures:tsa-core(for B-T’s signature-time-stamp), andsignatures:trust-lists(for signer-certificate validation against the trust graph). - Public package:
org.trustweave.signatures.jades - Key dependencies: Nimbus JOSE+JWT for JSON Serialization framing and JWS primitives, Bouncy Castle for certificate path validation, kotlinx.serialization for header value modelling.
4.4 kms:plugins:pkcs11
- Purpose: Implement the existing
KeyManagementServiceSPI on top of a PKCS#11 device, usingsun.security.pkcs11.SunPKCS11. Slot + token + PIN are externally configured. The implementation is generic — no device-specific code paths — so any standards-conformant QSCD (SafeNet Luna, Utimaco CryptoServer, Thales ProtectServer, SoftHSM2 for testing, YubiHSM 2 for development) plugs in without changes to JAdES or credentials code. - Public package:
org.trustweave.kms.pkcs11 - Key dependencies: JDK only (
sun.security.pkcs11.SunPKCS11reflective load — works on every JDK 17+ build that bundles the provider, which includes Temurin, Liberica, Zulu, and Oracle JDK).
4.5 credentials/credential-api modifications
- New
CredentialProof.JAdESsealed-class variant (seeCredentialProof.ktfor the existing variants). - New
JAdESProofEngineimplementingProofEngine, registered via the same SPI route used byVcLdProofEngineandSdJwtProofEngine. - New
ProofSuiteId.JADESenum entry alongside the existing values inProofSuiteId.kt. - DSL hooks on
IssuanceRequestBuilderandVerificationBuilder.
5. Public API surfaces
5.1 signatures:tsa-core
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package org.trustweave.signatures.tsa
import kotlinx.datetime.Instant
/**
* RFC 3161 Time-Stamp Authority client.
*
* One instance per TSA endpoint. Implementations MUST be thread-safe.
* MVP keeps this small: synchronous request/response, one digest algorithm per call.
*/
interface TsaClient {
/**
* Request a time-stamp token for the given message digest.
*
* @param digest The digest bytes (already hashed; not the cleartext).
* @param hashAlgorithm The hash algorithm that produced [digest] (must match the digest length).
* @param nonce Optional client-side nonce; if non-null, the TSA's response MUST echo it.
* @return A parsed and structurally validated TimeStampToken.
* @throws TsaException on network failure, non-2xx TSA response, or invalid token.
*/
suspend fun requestTimeStamp(
digest: ByteArray,
hashAlgorithm: TsaHashAlgorithm,
nonce: ByteArray? = null
): TimeStampToken
}
enum class TsaHashAlgorithm(val oid: String, val jcaName: String) {
SHA_256("2.16.840.1.101.3.4.2.1", "SHA-256"),
SHA_384("2.16.840.1.101.3.4.2.2", "SHA-384"),
SHA_512("2.16.840.1.101.3.4.2.3", "SHA-512")
}
/**
* Validated RFC 3161 time-stamp token.
*
* The [encoded] field is the DER-encoded TimeStampToken (CMS SignedData) as returned by the TSA;
* it is what gets placed inside a JAdES `sigTst` header. The accessor fields are eagerly parsed
* for verifier convenience.
*/
data class TimeStampToken(
val encoded: ByteArray,
val genTime: Instant,
val tsaSubject: String, // RFC 2253 subject DN of the TSA cert
val messageImprintAlgorithm: TsaHashAlgorithm,
val messageImprint: ByteArray,
val serialNumber: ByteArray,
val policyOid: String?
) {
override fun equals(other: Any?): Boolean =
other is TimeStampToken && encoded.contentEquals(other.encoded)
override fun hashCode(): Int = encoded.contentHashCode()
}
/**
* Configuration for a single TSA endpoint.
*
* In production, multiple [TsaConfig] instances are typically composed behind a
* round-robin or failover decorator that the application supplies.
*/
data class TsaConfig(
val endpointUrl: String,
val requestTimeoutMs: Long = 10_000,
val username: String? = null, // for HTTP Basic on commercial TSAs
val password: String? = null,
val expectedPolicyOid: String? = null, // require this policy OID in the response
val trustedSignerCertificates: List<ByteArray> = emptyList() // pin TSA signer cert(s)
)
class TsaException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
The default implementation BouncyCastleTsaClient builds the request via
org.bouncycastle.tsp.TimeStampRequestGenerator, posts it as
application/timestamp-query, parses the response with TimeStampResponse, and validates the
returned token’s signature against trustedSignerCertificates (if any) before constructing the
returned value object.
5.2 signatures:trust-lists
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package org.trustweave.signatures.trustlists
import java.security.cert.X509Certificate
/**
* Parsed European trust graph: one root LoTL plus N per-Member-State TSLs.
*/
data class TrustList(
val schemeOperator: String, // "European Commission" for the LoTL
val sequenceNumber: Int,
val issuedAt: kotlinx.datetime.Instant,
val nextUpdateAt: kotlinx.datetime.Instant?,
val memberStateLists: List<MemberStateTsl>
)
data class MemberStateTsl(
val territory: String, // ISO 3166-1 alpha-2 (e.g. "DE", "FR")
val schemeOperator: String,
val sequenceNumber: Int,
val issuedAt: kotlinx.datetime.Instant,
val trustedTsps: List<TrustedTSP>
)
data class TrustedTSP(
val name: String,
val tradeName: String?,
val services: List<TspService>
)
data class TspService(
val serviceName: String,
val serviceType: TspServiceType,
val status: TspServiceStatus,
val statusStartingTime: kotlinx.datetime.Instant,
val serviceCertificates: List<X509Certificate>,
val qualifierUris: List<String> // e.g. URIs under http://uri.etsi.org/TrstSvc/TrustedList/SvcInfoExt/
)
enum class TspServiceType {
CA_FOR_QUALIFIED_CERTIFICATES, // CA/QC
QUALIFIED_TIMESTAMP, // TSA/QTST
QUALIFIED_VALIDATION_SERVICE, // QVS
OTHER
}
enum class TspServiceStatus { GRANTED, WITHDRAWN, RECOGNISEDATNATIONALLEVEL, OTHER }
/**
* Parses LoTL + TSL XML documents. The MVP accepts pre-fetched, pre-verified XML bytes
* (signature verification of the LoTL itself is deferred — see section 12).
*/
interface TrustListParser {
/** Parse the LoTL pointer file and the TSL XML documents it references. */
fun parse(lotlXml: ByteArray, tslXmlByTerritory: Map<String, ByteArray>): TrustList
}
/**
* Trust-anchor resolution against a parsed [TrustList].
*
* Used by the JAdES verifier to decide whether a signer certificate is qualified.
*/
interface TrustAnchorResolver {
/**
* Resolve a signing certificate against the trust graph.
*
* Returns [TrustAnchorMatch.NotTrusted] if no chain to a CA/QC service is found.
*/
fun resolve(signerCert: X509Certificate, chain: List<X509Certificate>): TrustAnchorMatch
}
sealed class TrustAnchorMatch {
/** Signer chains to a qualified CA AND that CA is currently `GRANTED`. */
data class QualifiedActive(
val tspName: String,
val territory: String,
val service: TspService,
val qcWithSscd: Boolean, // true => QES rather than only AES
val qcForESig: Boolean // QCForESig vs QCForESeal vs QCForWSA
) : TrustAnchorMatch()
/** Chains to a qualified CA but the service status is `WITHDRAWN` as of the signing time. */
data class QualifiedWithdrawn(val tspName: String, val withdrawnAt: kotlinx.datetime.Instant) : TrustAnchorMatch()
/** No chain found. */
object NotTrusted : TrustAnchorMatch()
}
5.3 signatures:jades
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
package org.trustweave.signatures.jades
import org.trustweave.core.identifiers.KeyId
import org.trustweave.kms.KeyManagementService
import org.trustweave.signatures.tsa.TsaClient
import org.trustweave.signatures.trustlists.TrustAnchorResolver
import kotlinx.serialization.json.JsonElement
/**
* JAdES profile level. MVP supports B-B (basic) and B-T (basic + signature-time-stamp).
* See ETSI TS 119 182-1 §5.
*/
enum class JadesProfile {
/** Basic signature — JWS + JAdES protected header parameters. */
B_B,
/** Basic signature + at least one signature-time-stamp (`sigTst`) attribute. */
B_T
}
/**
* JAdES protected header — the union of JWS RFC 7515 `protected` and JAdES TS 119 182-1 §5.2 parameters.
*
* The verifier reconstructs this from the wire bytes; the signer builds it during issuance.
*/
data class JadesHeader(
// JWS
val alg: String, // e.g. "ES256", "PS256", "EdDSA"
val kid: String? = null,
val typ: String? = null, // e.g. "JAdES", "vc+ld+json+jwt"
val cty: String? = null, // payload content type
val crit: List<String>? = null,
// JAdES (TS 119 182-1)
val sigT: String, // ISO 8601 claimed signing time, e.g. "2026-05-28T10:15:00Z"
val x5tS256: String, // base64url(SHA-256(signer cert)) — JSON key "x5t#S256"
val x5c: List<String>? = null, // X.5c chain (base64 DER, signer first)
val sigPSt: SignaturePolicyStore? = null,
val sigPId: SignaturePolicyIdentifier? = null,
val sigD: SignatureDetached? = null, // when the payload is detached (NOT used in MVP)
val srCms: List<String>? = null, // commitment-type-indication
val srAts: SignerAttributes? = null,
/** Free-form headers we don't model individually but still must include in the protected JSON. */
val additional: Map<String, JsonElement> = emptyMap()
)
data class SignaturePolicyIdentifier(val id: String, val digestAlgorithm: String, val digestB64: String)
data class SignaturePolicyStore(val spDocSpec: String, val spDocB64: String)
data class SignatureDetached(val pars: List<String>, val hashM: String, val hashV: String, val ctys: List<String>?)
data class SignerAttributes(val claimed: List<JsonElement> = emptyList(), val certified: List<JsonElement> = emptyList())
/**
* Unsigned properties — JAdES `etsiU` array entries. Populated for B-T (sigTst) and beyond.
*/
data class JadesUnsignedProperties(
val sigTst: List<EncodedTimeStampToken> = emptyList()
)
data class EncodedTimeStampToken(val tstTokensB64: List<String>, val canonAlg: String? = null)
/**
* Signer.
*
* Builds a JAdES JSON Serialization (flattened) signature over a JSON payload.
* The signing key is resolved via the existing TrustWeave KMS SPI — no key material is held inside this module.
*/
interface JadesSigner {
suspend fun sign(
payloadJson: JsonElement,
request: JadesSigningRequest
): JadesSignature
}
data class JadesSigningRequest(
val profile: JadesProfile,
val keyId: KeyId, // points at a key in the configured KMS
val signerCertificateChain: List<ByteArray>, // DER X.509, signer first; required for x5c / x5t#S256
val headerOverrides: Map<String, JsonElement> = emptyMap(),
val signaturePolicyId: SignaturePolicyIdentifier? = null,
val signingTime: kotlinx.datetime.Instant? = null, // defaults to Clock.System.now()
val tsaConfig: org.trustweave.signatures.tsa.TsaConfig? = null // required iff profile == B_T
)
/**
* A produced JAdES signature, in JWS JSON Serialization (flattened) form.
*
* The `compact` accessor returns the JWS Compact Serialization variant when the unsigned
* properties block is empty (i.e. strict B-B without `etsiU`).
*/
data class JadesSignature(
val protectedHeaderB64u: String,
val payloadB64u: String,
val signatureB64u: String,
val unsigned: JadesUnsignedProperties = JadesUnsignedProperties(),
val serializedFlattened: String // full JSON-flattened JWS string
) {
fun compact(): String? =
if (unsigned.sigTst.isEmpty()) "$protectedHeaderB64u.$payloadB64u.$signatureB64u" else null
}
/**
* Verifier.
*
* Pure verification — does not fetch trust lists or contact TSAs. The caller supplies
* pre-resolved [TrustAnchorResolver] and (for B-T) the verifier reads the embedded
* `sigTst` tokens directly without re-stamping.
*/
interface JadesVerifier {
suspend fun verify(
jadesSerialized: String,
options: JadesVerificationOptions
): JadesValidationResult
}
data class JadesVerificationOptions(
val requiredProfile: JadesProfile,
val trustAnchorResolver: TrustAnchorResolver,
val acceptedAlgorithms: Set<String> = setOf("ES256", "PS256", "ES384", "PS384", "ES512", "PS512", "EdDSA"),
val allowExpiredCertificateAtSigningTime: Boolean = false,
val maxClockSkew: kotlin.time.Duration = kotlin.time.Duration.parse("PT5M")
)
sealed class JadesValidationResult {
data class Valid(
val header: JadesHeader,
val payload: JsonElement,
val trust: org.trustweave.signatures.trustlists.TrustAnchorMatch,
val signingTime: kotlinx.datetime.Instant,
val signatureTimeStamp: kotlinx.datetime.Instant? // null for B-B
) : JadesValidationResult()
sealed class Invalid : JadesValidationResult() {
data class BadSignature(val reason: String) : Invalid()
data class UntrustedSigner(val cert: java.security.cert.X509Certificate) : Invalid()
data class WrongProfile(val found: JadesProfile, val required: JadesProfile) : Invalid()
data class MissingTimeStamp(val reason: String) : Invalid()
data class TimeStampMismatch(val reason: String) : Invalid()
data class CertificateExpired(val notAfter: kotlinx.datetime.Instant) : Invalid()
data class Malformed(val reason: String) : Invalid()
}
}
5.4 kms:plugins:pkcs11
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package org.trustweave.kms.pkcs11
import org.trustweave.core.identifiers.KeyId
import org.trustweave.kms.Algorithm
import org.trustweave.kms.KeyManagementService
import org.trustweave.kms.results.GenerateKeyResult
import org.trustweave.kms.results.GetPublicKeyResult
import org.trustweave.kms.results.DeleteKeyResult
import org.trustweave.kms.results.SignResult
/**
* PKCS#11-backed KeyManagementService.
*
* Implements the same SPI used by [InMemoryKeyManagementService], [AwsKeyManagementService], etc.
* Devices are not detected at compile time — the library path is supplied at construction.
*
* See [KeyManagementService.kt](../../../kms/kms-core/src/main/kotlin/org/trustweave/kms/KeyManagementService.kt)
* for the contract this class satisfies.
*/
class Pkcs11KeyManagementService(
private val config: Pkcs11Config
) : KeyManagementService {
override suspend fun getSupportedAlgorithms(): Set<Algorithm> { /* dynamic via C_GetMechanismInfo */ }
override suspend fun generateKey(algorithm: Algorithm, options: Map<String, Any?>): GenerateKeyResult { /* ... */ }
override suspend fun getPublicKey(keyId: KeyId): GetPublicKeyResult { /* ... */ }
override suspend fun sign(keyId: KeyId, data: ByteArray, algorithm: Algorithm?): SignResult { /* ... */ }
override suspend fun deleteKey(keyId: KeyId): DeleteKeyResult { /* ... */ }
}
/**
* Pkcs11 provider configuration.
*
* Constructs a per-instance SunPKCS11 provider; multiple instances may coexist for multiple devices.
*/
data class Pkcs11Config(
val name: String, // friendly name, becomes part of the SunPKCS11 provider name
val libraryPath: String, // absolute path to the PKCS#11 .so / .dll / .dylib
val slot: SlotSelector,
val pinStrategy: PinStrategy,
val keyIdEncoding: KeyIdEncoding = KeyIdEncoding.HEX_OBJECT_HANDLE,
val mechanismAllowList: Set<String>? = null // optional: restrict to a curated CK_MECHANISM set
)
sealed class SlotSelector {
data class ById(val slotId: Long) : SlotSelector()
data class ByLabel(val tokenLabel: String) : SlotSelector()
object FirstAvailable : SlotSelector()
}
sealed class PinStrategy {
/** PIN supplied as plaintext; useful for tests with SoftHSM2. */
data class Plain(val pin: CharArray) : PinStrategy()
/** PIN sourced from an environment variable name at first-use. */
data class FromEnv(val variableName: String) : PinStrategy()
/** PIN supplied by an externally provided callback (e.g. a vault, an OS keychain). */
data class Callback(val provider: suspend () -> CharArray) : PinStrategy()
}
enum class KeyIdEncoding {
/** [KeyId] value carries the hex-encoded CK_OBJECT_HANDLE. Fast, but handles aren't stable across sessions. */
HEX_OBJECT_HANDLE,
/** [KeyId] value carries the CKA_LABEL of the key object. Stable; recommended for long-lived QES keys. */
LABEL
}
5.5 credentials/credential-api additions
Extension of the existing CredentialProof sealed class (current shape in
CredentialProof.kt):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package org.trustweave.credential.model.vc
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
sealed class CredentialProof {
// ...existing variants: LinkedDataProof, JwtProof, SdJwtVcProof, MdocProof...
/**
* JAdES (ETSI TS 119 182-1) — qualified-grade signature envelope.
*
* Wraps any JWT/JSON-LD VC payload. `value` is the JWS JSON Serialization (flattened) form;
* `signedHeaderRefs` mirrors the protected header back into the proof object so consumers can
* inspect `sigT`, `x5t#S256`, `sigPId`, etc. without re-decoding the JWS.
*/
@Serializable
data class JAdES(
val value: String,
val signedHeaderRefs: Map<String, JsonElement> = emptyMap()
) : CredentialProof()
}
CredentialProofSerializer (see
CredentialProofSerializer.kt)
gains one branch for the new @type discriminator value JAdES.
ProofSuiteId (see
ProofSuiteId.kt)
gains an entry:
1
JADES("jades")
A new JAdESProofEngine implements
ProofEngine:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package org.trustweave.credential.proof.internal.engines
class JAdESProofEngine(
private val kms: org.trustweave.kms.KeyManagementService,
private val jadesSigner: org.trustweave.signatures.jades.JadesSigner,
private val jadesVerifier: org.trustweave.signatures.jades.JadesVerifier,
private val trustAnchorResolver: org.trustweave.signatures.trustlists.TrustAnchorResolver
) : org.trustweave.credential.spi.proof.ProofEngine {
override val format = org.trustweave.credential.format.ProofSuiteId.JADES
override val formatName = "JAdES (ETSI TS 119 182-1)"
override val formatVersion = "1.2.1"
override val capabilities = org.trustweave.credential.spi.proof.ProofEngineCapabilities(
selectiveDisclosure = false,
zeroKnowledge = false,
revocation = true,
presentation = false,
predicates = false
)
override suspend fun issue(request: org.trustweave.credential.requests.IssuanceRequest):
org.trustweave.credential.model.vc.VerifiableCredential { /* ... */ }
override suspend fun verify(
credential: org.trustweave.credential.model.vc.VerifiableCredential,
options: org.trustweave.credential.requests.VerificationOptions
): org.trustweave.credential.results.VerificationResult { /* ... */ }
}
6. Key data models
The full data-model relationships for the new domain:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// signatures:tsa-core
TsaClient.requestTimeStamp(digest, hashAlgorithm) -> TimeStampToken
// signatures:trust-lists
TrustListParser.parse(lotlXml, tslByTerritory) -> TrustList
TrustList -> List<MemberStateTsl> -> List<TrustedTSP> -> List<TspService>
TrustAnchorResolver.resolve(signerCert, chain) -> TrustAnchorMatch
\_______________________________________
\
// signatures:jades v
JadesSigner.sign(payloadJson, JadesSigningRequest) -> JadesSignature
JadesVerifier.verify(serialized, JadesVerificationOptions) -> JadesValidationResult
^ ^
| |
+-- uses TsaClient (B-T) --+
+-- uses TrustAnchorResolver
// credentials:credential-api
CredentialProof.JAdES(value, signedHeaderRefs)
JAdESProofEngine : ProofEngine
A minimal end-to-end issuance sequence:
1
2
3
4
5
6
7
8
9
val kms: KeyManagementService = Pkcs11KeyManagementService(pkcs11Config)
val tsa: TsaClient = BouncyCastleTsaClient(TsaConfig("https://freetsa.org/tsr"))
val trustList: TrustList = TrustListParser.parse(lotlBytes, perTerritoryTslBytes)
val resolver: TrustAnchorResolver = TrustAnchorResolver.of(trustList)
val signer: JadesSigner = JadesSignerImpl(kms, tsa)
val verifier: JadesVerifier = JadesVerifierImpl(tsaTrustedSignerCerts = listOf(/* ... */))
val engine = JAdESProofEngine(kms, signer, verifier, resolver)
7. Integration points
7.1 KMS — KeyManagementService SPI
The QES code path uses
KeyManagementService
unchanged. JadesSigner calls kms.sign(keyId, signingInput, algorithm) and never sees a private
key. For QES, the application wires Pkcs11KeyManagementService as the active KMS; for AES-only
operation or local tests, the existing InMemoryKeyManagementService works without any JAdES code
change.
7.2 CredentialProof — sealed-class extension
CredentialProof.JAdES is added as a new sibling of LinkedDataProof, JwtProof, SdJwtVcProof,
and MdocProof in
CredentialProof.kt.
The custom @type discriminator in
CredentialProofSerializer.kt
gains one branch.
7.3 IssuanceBuilder DSL — withJadesProfile
The existing
IssuanceRequestBuilder
DSL gets one new configurator. Usage:
1
2
3
4
5
6
7
8
9
10
val request = issuanceRequest(ProofSuiteId.JADES) {
issuer(issuerDid)
subject(subjectDid) {
"givenName" to "Ada"
"familyName" to "Lovelace"
}
type("EuQualifiedCredential")
withJadesProfile(JadesProfile.B_T, tsaConfig = TsaConfig("https://qtsp.example.eu/tsa"))
expiresIn(365.days)
}
Signature:
1
2
3
4
5
fun IssuanceRequestBuilder.withJadesProfile(
profile: JadesProfile,
tsaConfig: TsaConfig? = null,
signaturePolicy: SignaturePolicyIdentifier? = null
): IssuanceRequestBuilder
The builder stashes these in the proofOptions map already used by the SD-JWT and VC-LD engines;
JAdESProofEngine.issue reads them back during proof construction. When profile == B_T,
tsaConfig is required and the builder eagerly validates that.
7.4 VerificationBuilder DSL — requireJadesProfile
The verification side, currently driven by
VerificationOptions,
gains a parallel configurator:
1
2
fun VerificationBuilder.requireJadesProfile(profile: JadesProfile): VerificationBuilder
fun VerificationBuilder.requireQualifiedSigner(): VerificationBuilder // QES gate
JAdESProofEngine.verify reads these (carried inside the existing
VerificationOptions.formatMetadata map) and refuses to return Valid if the
TrustAnchorMatch is anything other than QualifiedActive (when requireQualifiedSigner is set)
or the parsed profile is below the required level.
7.5 Cross-domain plug-in registration
signatures:jades does not introduce a new SPI namespace. The JAdESProofEngine registers
itself via the same SPI route that the existing engines use — see
VcLdProofEngineProvider.kt
and
SdJwtProofEngineProvider.kt
for the pattern. The JAdES engine provider lives in the new signatures:jades module so that
applications that don’t pull in signatures:jades never load the engine.
8. Phased implementation plan
| Phase | Module(s) | Ships | Depends on | Est. LOC | Est. timeline |
|---|---|---|---|---|---|
| A | signatures:tsa-core |
TsaClient + BouncyCastleTsaClient, TimeStampToken model, TsaConfig, FreeTSA + BC-in-process tests |
common, BC, OkHttp (all already in classpath) |
~700 | 1 week |
| B | signatures:trust-lists |
TrustListParser, TrustList/TrustedTSP models, TrustAnchorResolver, fixture-based tests against a snapshotted LoTL |
common, JDK XML |
~1,500 | 2 weeks |
| C | signatures:jades (B-B only) |
JadesSigner/JadesVerifier for the B-B profile, JWS JSON Serialization framing, header model |
A (only for the verifier-side sigTst decode path) + kms:kms-core |
~1,800 | 2 weeks |
| D | signatures:jades (B-T) |
B-T sign + verify, embedding RFC 3161 tokens in etsiU.sigTst, signature-time-stamp validation against trust list |
A, B, C | ~700 | 1 week |
| E | kms:plugins:pkcs11 |
Pkcs11KeyManagementService, Pkcs11Config, SoftHSM2-on-CI tests, manual smoke against SafeNet Luna and YubiHSM 2 |
kms:kms-core only — runnable in parallel with A-D |
~1,400 | 2 weeks |
| F | credentials/credential-api wiring |
CredentialProof.JAdES + serializer branch, ProofSuiteId.JADES, JAdESProofEngine, DSL hooks, EUDI Reference Wallet interop fixtures |
C, D | ~900 | 1 week |
Total: ~7,000 LOC of production code, ~9 calendar weeks with the phases as drawn. E can begin in parallel with A, compressing the calendar to ~7 weeks if two engineers are available.
9. Test strategy
9.1 Unit tests (every module, pure JVM, no externals)
signatures:tsa-core: tests against a BouncyCastle-in-process TSA constructed withorg.bouncycastle.tsp.TimeStampTokenGenerator. Covers SHA-256/384/512, nonce echo, policy-OID mismatch, malformedTimeStampResp, HTTP error mapping.signatures:trust-lists: fixture-driven parsing of a frozen 2026-01 snapshot of the LoTL plus five Member-State TSLs (DE, FR, ES, IT, BE). Tests cover all status values,QCWithSSCDqualifier detection, withdrawn-after-signing-time logic, certificate-chain matching.signatures:jades: golden-file tests for B-B and B-T signatures generated by the engine and re-verified by the engine itself; plus negative tests (tampered payload, wrongx5t#S256, algorithm not in allow-list, missingsigT).kms:plugins:pkcs11: tests against SoftHSM2 (see 9.4). Covers slot-by-label, slot-by-id, PIN-from-env, algorithm advertisement viaC_GetMechanismInfo, error mapping when the device is offline or the PIN is wrong.credentials/credential-api: the newJAdESProofEngineis tested against the same fixture matrix used byVcLdProofEngineandSdJwtProofEngine(seeVcLdProofEngine.kt).
9.2 Interop tests with the EUDI Reference Wallet
The EU Digital Identity Wallet reference implementations
(https://github.com/eu-digital-identity-wallet) ship sample VCs and PIDs as part of their test
suite. We vendor a frozen subset of those as test resources under
signatures/jades/src/test/resources/eudi-fixtures/ and assert that:
- JAdES B-B signatures produced by TrustWeave validate inside the EUDI verifier sample app (one-way export check, run manually before each release; not in CI).
- JAdES B-B and B-T signatures produced by the EUDI reference signer validate inside
JadesVerifier(both-way; CI-resident as static fixtures).
9.3 TSA testing
- CI: Bouncy Castle in-process TSA — deterministic, no network. Provides the bulk of unit coverage.
- Nightly CI job: one test run against the public FreeTSA endpoint (https://freetsa.org/tsr)
marked
@Tag("network")and excluded from PR builds, to detect drift in real-world TSA responses.
9.4 PKCS#11 testing
- CI: SoftHSM2 in a Docker container (
opendnssec/softhsm2:latest). The CI workflow initializes one token, generates an RSA-2048 and an ECDSA-P-256 key, and runs thePkcs11KeyManagementServicetest suite against it. - Manual: documented smoke-test scripts for SafeNet Luna (PCI-E), Utimaco
CryptoServer, and YubiHSM 2 (USB development device). These live under
kms/plugins/pkcs11/MANUAL_TESTING.mdand are run by the release engineer before each major version bump.
9.5 LoTL test fixtures
The EU LoTL is published at https://ec.europa.eu/tools/lotl/eu-lotl.xml. The CI pipeline must NOT live-fetch this URL — the fixture would change under our feet and CI failures would be indistinguishable from regressions. Instead, we snapshot:
eu-lotl-2026-01-15.xml- per-MS TSLs for
DE,FR,ES,IT,BE,NL,PLas referenced from that LoTL snapshot
These are stored in signatures/trust-lists/src/test/resources/lotl-snapshot/ and refreshed
manually on a quarterly cadence as part of routine maintenance.
10. Dependencies
| Dependency | Status | Notes |
|---|---|---|
org.bouncycastle:bcprov-jdk18on |
Already on classpath via gradle/libs.versions.toml |
Used for CMS, TSP, X.509 parsing. |
org.bouncycastle:bcpkix-jdk18on |
Already on classpath | Provides org.bouncycastle.tsp.*. |
com.nimbusds:nimbus-jose-jwt |
Already on classpath | JWS JSON Serialization framing. |
com.squareup.okhttp3:okhttp |
Already on classpath | HTTP transport for TsaClient. |
org.jetbrains.kotlinx:kotlinx-serialization-json |
Already on classpath | JAdES header modelling and signedHeaderRefs. |
org.jetbrains.kotlinx:kotlinx-datetime |
Already on classpath | Instant everywhere consistent with the rest of the codebase. |
sun.security.pkcs11.SunPKCS11 |
JDK-provided | No Gradle dep. Provider class is loaded reflectively; available in every JDK 17+ distribution we target (Temurin, Liberica, Zulu, Oracle). Note the API change in JDK 17+: Provider.configure(String) replaces the deprecated constructor-with-config-file pattern from JDK 8/11. Code must use the new path. |
softhsm2 (Docker opendnssec/softhsm2) |
CI-only | Container is started by GitHub Actions / Dockerfile.softhsm before the PKCS#11 test job. |
| EUDI Reference Wallet fixtures | CI-only (vendored) | Downloaded once, committed under signatures/jades/src/test/resources/eudi-fixtures/. License attribution recorded in THIRD_PARTY_NOTICES.md. |
No new Gradle dependencies are introduced for the production code path. The only additions are test-scope fixtures and a CI container image.
11. External constraints & risks
- SunPKCS11 portability.
SunPKCS11is part of OpenJDK and is present in every mainstream distribution we target, but it is technically an internal JDK package. If a future JDK restricts access (similar to the JEP 403 strong-encapsulation tightening), we have a fallback: the IAIK PKCS#11 wrapper (https://github.com/mw-software-engineering/iaikpkcs11wrapper), an Apache-2.0 alternative. Switching would mean a new dependency but no API change inPkcs11KeyManagementService. - JAdES vs SD-JWT VC layering. These are not alternatives; they layer. SD-JWT VC is the
credential format — it defines the claim layout, the selective-disclosure mechanism, and the
holder-binding rules. JAdES is an outer signature envelope — it can wrap an SD-JWT VC (or a
W3C VC-JWT, or a JSON-LD VC, or any other JSON payload) and provide the eIDAS legal-grade
signature on top. The two coexist: an issuer producing an EU PID will typically issue an
SD-JWT VC and then wrap that SD-JWT VC inside a JAdES B-T signature for non-repudiation. The
TrustWeave API surfaces both via independent
ProofSuiteIdvalues (SD_JWT_VC,JADES) and engines, and the application picks which (or both) to apply. - LoTL refresh policy. The MVP treats the LoTL as immutable input. In production it must be
refreshed periodically (the LoTL itself carries
NextUpdate, typically 6 months out, but per-MS TSLs refresh more frequently — sometimes weekly). The follow-up adds aTrustListRefreshSchedulerthat pollsNextUpdate, re-verifies the LoTL signature, and atomically swaps the in-memoryTrustListinstance. - ETSI EN 319 102-1 conformance. Full conformance requires implementing the
ETSI EN 319 102-1 building blocks (signature policy evaluation, validation context
initialization, X.509 validation constraints, signing-time-of-cert validity, PoE-driven
status determination). The MVP gives basic mathematical validation plus trust-anchor
resolution; we explicitly mark
JadesValidationResult.Validas “cryptographically valid and trust-anchored” rather than “ETSI EN 319 102-1 conformant”. Marketing language and API documentation must respect this distinction.
12. Open questions
- Result vs throw conventions. The
kms:domain returns sealed*Resulttypes (seeSignResult.kt); the credentials API throws (ProofEngine.issueis documented to throw). The newsignatures:modules straddle both. Recommendation: match thekms:pattern —JadesValidationResultis already a sealed type, andJadesSigner.signshould likewise return aJadesSignResultrather than throw. This is consistent with the existing observation in the codebase that crypto operations should never throw across module boundaries. - LoTL cache location. Once refresh is implemented, where does the cached LoTL XML live?
Candidates: (a) a configurable directory (default
~/.trustweave/lotl/); (b) an injectableTrustListStoreSPI mirroring the existingWalletStoragepattern. (b) is preferred — it matches the rest of the codebase and is testable. - Detached vs enveloping signatures. JAdES supports both via the
sigDheader. The MVP ships enveloping only (sigD = null). Detached mode is needed for very large payloads and for the future PAdES work where the signature lives outside the PDF body. Decision deferred. - Selective disclosure interaction. When JAdES wraps an SD-JWT VC, does selective
disclosure happen before or after the JAdES wrapping? Tentative answer: disclosures are
carried alongside the SD-JWT VC payload (after the
~separator), and the JAdES signature covers only the SD-JWT issuer-signed JWT — not the disclosures. This matches IETF SD-JWT VC draft semantics. To be confirmed against the EUDI Reference Wallet fixtures.
13. Out-of-MVP roadmap
In suggested priority order:
- JAdES B-LT and B-LTA profiles. Adds validation-data references (
xVals,rVals,axVals,arVals) for long-term validation and the archival time-stamp (arcTst) for archival validation. Required for documents that must remain verifiable beyond the signer-cert lifetime. - Self-verification of the LoTL XAdES signature. Brings the trust-list ingestion in-band so operators no longer need an out-of-band trust path to the LoTL XML. Pulls in an XML Signature implementation (Santuario via the JDK is sufficient).
- Full ETSI EN 319 102-1 validation pipeline. Signature policy evaluation, validation context initialization, PoE-driven status determination, time-of-signing certificate validity. Required for formal conformance claims.
- CAdES (ETSI EN 319 122-1) and PAdES (ETSI EN 319 142-1). Needed once TrustWeave is used to sign binary documents (CMS containers) and PDFs.
- Periodic LoTL/TSL refresh.
TrustListRefreshScheduler+TrustListStoreSPI. - eIDAS-compliant audit logging. ETSI EN 319 421 sets audit requirements for QTSP
operations. The signing-side audit trail needs to be reproducible, non-repudiable, and
immutable — likely implemented on top of
anchors/for blockchain-anchored audit log integrity.
14. References
| Reference | URL |
|---|---|
| Regulation (EU) 2024/1183 (eIDAS 2.0) | https://eur-lex.europa.eu/eli/reg/2024/1183/oj |
| ETSI TS 119 182-1 (JAdES) | https://www.etsi.org/deliver/etsi_ts/119100_119199/11918201/ |
| ETSI EN 319 102-1 (AdES signature procedures) | https://www.etsi.org/deliver/etsi_en/319100_319199/31910201/ |
| ETSI TS 119 612 (Trusted Lists) | https://www.etsi.org/deliver/etsi_ts/119600_119699/119612/ |
| ETSI EN 319 122-1 (CAdES) | https://www.etsi.org/deliver/etsi_en/319100_319199/31912201/ |
| ETSI EN 319 132-1 (XAdES) | https://www.etsi.org/deliver/etsi_en/319100_319199/31913201/ |
| RFC 3161 (Time-Stamp Protocol) | https://datatracker.ietf.org/doc/html/rfc3161 |
| RFC 5652 (CMS) | https://datatracker.ietf.org/doc/html/rfc5652 |
| PKCS#11 v2.40 (OASIS) | https://docs.oasis-open.org/pkcs11/pkcs11-base/v2.40/pkcs11-base-v2.40.html |
| EUDI Wallet Architecture Reference Framework | https://github.com/eu-digital-identity-wallet/eudi-doc-architecture-and-reference-framework |
| EUDI Reference Wallet (issuer / wallet / verifier samples) | https://github.com/eu-digital-identity-wallet |
| EU LoTL XML (production endpoint) | https://ec.europa.eu/tools/lotl/eu-lotl.xml |
| walt.id Identity (comparison reference) | https://github.com/walt-id/waltid-identity |
End of document.