Skip to content

Commit 34080df

Browse files
committed
feat(trust-registry): enforce source assurance policies
1 parent b3017a1 commit 34080df

21 files changed

Lines changed: 743 additions & 523 deletions

File tree

waltid-libraries/credentials/waltid-trust-registry/README.md

Lines changed: 98 additions & 369 deletions
Large diffs are not rendered by default.

waltid-libraries/credentials/waltid-trust-registry/src/commonMain/kotlin/id/walt/trust/model/TrustModel.kt

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,60 @@ import kotlin.time.Instant
1111
enum class SourceFamily { TSL, LOTE, PILOT }
1212

1313
// ---------------------------------------------------------------------------
14-
// Authenticity / freshness state
14+
// Source assurance / freshness state
1515
// ---------------------------------------------------------------------------
1616

1717
@Serializable
18-
enum class AuthenticityState { VALIDATED, FAILED, SKIPPED_DEMO, UNKNOWN }
18+
enum class AuthenticityState {
19+
/** Signature is valid and the signer chains to an independently trusted certificate. */
20+
AUTHENTICATED,
21+
22+
/** Signature integrity is valid, but signer authorization was not established. */
23+
INTEGRITY_VERIFIED,
24+
25+
/** Source is unsigned or signature verification was explicitly disabled. */
26+
UNVERIFIED,
27+
28+
/** Signature, signer trust, or source verification failed. */
29+
FAILED,
30+
31+
UNKNOWN
32+
}
33+
34+
@Serializable
35+
enum class SignatureStatus { NOT_PRESENT, NOT_CHECKED, VALID, INVALID, UNSUPPORTED }
36+
37+
@Serializable
38+
enum class SignerTrust { NOT_APPLICABLE, NOT_EVALUATED, TRUSTED, UNTRUSTED }
39+
40+
/**
41+
* Controls which sources may become active in the registry.
42+
* Invalid signatures are rejected whenever verification is enabled.
43+
*/
44+
@Serializable
45+
enum class SourceAcceptancePolicy {
46+
REQUIRE_AUTHENTICATED,
47+
REQUIRE_VALID_SIGNATURE,
48+
ALLOW_UNSIGNED,
49+
ALLOW_UNVERIFIED
50+
}
51+
52+
@Serializable
53+
data class SourceAssurance(
54+
val signatureStatus: SignatureStatus = SignatureStatus.NOT_CHECKED,
55+
val signerTrust: SignerTrust = SignerTrust.NOT_EVALUATED,
56+
val authenticityState: AuthenticityState = AuthenticityState.UNKNOWN,
57+
val acceptancePolicy: SourceAcceptancePolicy = SourceAcceptancePolicy.REQUIRE_AUTHENTICATED,
58+
val accepted: Boolean = false,
59+
val details: String? = null
60+
)
61+
62+
@Serializable
63+
data class SourceLoadOptions(
64+
val acceptancePolicy: SourceAcceptancePolicy = SourceAcceptancePolicy.REQUIRE_AUTHENTICATED,
65+
/** PEM or Base64-DER certificates authorized to sign the source. */
66+
val trustedSignerCertificates: List<String> = emptyList()
67+
)
1968

2069
@Serializable
2170
enum class FreshnessState { FRESH, STALE, EXPIRED, UNKNOWN }
@@ -34,7 +83,7 @@ data class TrustSource(
3483
val issueDate: Instant? = null,
3584
val nextUpdate: Instant? = null,
3685
val sequenceNumber: String? = null,
37-
val authenticityState: AuthenticityState = AuthenticityState.UNKNOWN,
86+
val assurance: SourceAssurance = SourceAssurance(),
3887
val freshnessState: FreshnessState = FreshnessState.UNKNOWN,
3988
val rawArtifactRef: String? = null,
4089
val metadata: Map<String, String> = emptyMap()
@@ -130,13 +179,16 @@ data class TrustEvidence(
130179
data class TrustDecision(
131180
val decision: TrustDecisionCode,
132181
val sourceFreshness: FreshnessState = FreshnessState.UNKNOWN,
133-
val authenticity: AuthenticityState = AuthenticityState.UNKNOWN,
182+
val sourceAssurance: SourceAssurance = SourceAssurance(),
134183
val matchedSource: TrustSource? = null,
135184
val matchedEntity: TrustedEntity? = null,
136185
val matchedService: TrustedService? = null,
137186
val evidence: List<TrustEvidence> = emptyList(),
138187
val warnings: List<String> = emptyList()
139-
)
188+
) {
189+
/** Compatibility projection; prefer [sourceAssurance]. */
190+
val authenticity: AuthenticityState get() = sourceAssurance.authenticityState
191+
}
140192

141193
// ---------------------------------------------------------------------------
142194
// Query filters
@@ -160,7 +212,7 @@ data class TrustSourceHealth(
160212
val displayName: String,
161213
val sourceFamily: SourceFamily,
162214
val freshnessState: FreshnessState,
163-
val authenticityState: AuthenticityState,
215+
val assurance: SourceAssurance,
164216
val nextUpdate: Instant? = null,
165217
val entityCount: Int = 0,
166218
val serviceCount: Int = 0
@@ -177,5 +229,16 @@ data class RefreshResult(
177229
val entitiesLoaded: Int = 0,
178230
val servicesLoaded: Int = 0,
179231
val identitiesLoaded: Int = 0,
180-
val error: String? = null
232+
val error: String? = null,
233+
val errorCode: SourceLoadErrorCode? = null,
234+
val assurance: SourceAssurance? = null
181235
)
236+
237+
@Serializable
238+
enum class SourceLoadErrorCode {
239+
FETCH_FAILED,
240+
UNKNOWN_FORMAT,
241+
SOURCE_NOT_ACCEPTED,
242+
SIGNATURE_VALIDATION_FAILED,
243+
PARSE_FAILED
244+
}

waltid-libraries/credentials/waltid-trust-registry/src/commonMain/kotlin/id/walt/trust/parser/lote/LoteJsonParser.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ import kotlin.time.Instant
1313
* Input format matches the synthetic sample at:
1414
* waltid-architecture/enterprise/trust-lists/samples/sample-lote-wallet-providers.synthetic.json
1515
*
16-
* Note: this parser handles the MVP JSON shape. It is intentionally lenient —
17-
* unknown fields are ignored. The format is provisional pending TS 119 605 stabilisation.
16+
* This parser handles the currently supported provisional JSON shape and ignores
17+
* unknown fields for forward compatibility pending TS 119 605 stabilisation.
1818
*/
1919
object LoteJsonParser {
2020

21+
private val jsonFormat = Json { ignoreUnknownKeys = true }
22+
2123
// ---------------------------------------------------------------------------
2224
// Raw JSON shape (internal deserialization models)
2325
// ---------------------------------------------------------------------------
@@ -72,10 +74,15 @@ object LoteJsonParser {
7274
json: String,
7375
sourceId: String,
7476
sourceUrl: String? = null,
75-
authenticityState: AuthenticityState = AuthenticityState.SKIPPED_DEMO,
77+
assurance: SourceAssurance = SourceAssurance(
78+
signatureStatus = SignatureStatus.NOT_PRESENT,
79+
signerTrust = SignerTrust.NOT_APPLICABLE,
80+
authenticityState = AuthenticityState.UNVERIFIED,
81+
details = "Unsigned LoTE JSON"
82+
),
7683
validationMetadata: Map<String, String> = emptyMap()
7784
): ParsedLoteSource {
78-
val doc = Json { ignoreUnknownKeys = true }.decodeFromString<RawLoteDocument>(json)
85+
val doc = jsonFormat.decodeFromString<RawLoteDocument>(json)
7986
val meta = doc.listMetadata
8087

8188
val source = TrustSource(
@@ -87,7 +94,7 @@ object LoteJsonParser {
8794
issueDate = meta.issueDate?.let { runCatching { Instant.parse(it) }.getOrNull() },
8895
nextUpdate = meta.nextUpdate?.let { runCatching { Instant.parse(it) }.getOrNull() },
8996
sequenceNumber = meta.sequenceNumber,
90-
authenticityState = authenticityState,
97+
assurance = assurance,
9198
freshnessState = FreshnessState.UNKNOWN,
9299
metadata = buildMap {
93100
meta.listType?.let { put("listType", it) }

waltid-libraries/credentials/waltid-trust-registry/src/commonMain/kotlin/id/walt/trust/service/TrustRegistryService.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,14 +82,33 @@ interface TrustRegistryService {
8282
suspend fun refreshSource(sourceId: String): RefreshResult
8383

8484
/**
85-
* Load a source from raw content (for bootstrapping / demo).
85+
* Load and admit a source using an explicit verification and acceptance policy.
86+
* The default [SourceLoadOptions] policy requires an authenticated signer.
87+
*/
88+
suspend fun loadSourceFromContent(
89+
sourceId: String,
90+
content: String,
91+
sourceUrl: String? = null,
92+
options: SourceLoadOptions
93+
): RefreshResult
94+
95+
/** Load a remote source using an explicit verification and acceptance policy. */
96+
suspend fun loadSourceFromUrl(
97+
sourceId: String,
98+
url: String,
99+
options: SourceLoadOptions
100+
): RefreshResult
101+
102+
/**
103+
* Load a source from raw content through the deprecated compatibility API.
86104
*
87105
* @param sourceId Unique identifier for this trust source
88106
* @param content Raw trust list content (TSL XML, LoTE JSON/XML)
89107
* @param sourceUrl Optional URL to store for future refresh calls
90108
* @param validateSignature Whether to validate XMLDSig signatures (for TSL sources)
91109
* @param trustedSignerCertificates PEM or Base64-DER certificates trusted to sign compact-JWS LoTE sources
92110
*/
111+
@Deprecated("Use the SourceLoadOptions overload with an explicit acceptance policy")
93112
suspend fun loadSourceFromContent(
94113
sourceId: String,
95114
content: String,
@@ -107,6 +126,7 @@ interface TrustRegistryService {
107126
* @param validateSignature Whether to validate XMLDSig signatures (for TSL sources)
108127
* @param trustedSignerCertificates PEM or Base64-DER certificates trusted to sign compact-JWS LoTE sources
109128
*/
129+
@Deprecated("Use the SourceLoadOptions overload with an explicit acceptance policy")
110130
suspend fun loadSourceFromUrl(
111131
sourceId: String,
112132
url: String,

waltid-libraries/credentials/waltid-trust-registry/src/commonMain/kotlin/id/walt/trust/store/InMemoryTrustStore.kt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import kotlinx.coroutines.sync.withLock
88

99
/**
1010
* Thread-safe in-memory implementation of [TrustStore].
11-
* Suitable for MVP / demo use. Not persistent across restarts.
11+
* Suitable for tests and applications that do not require persistence across restarts.
1212
*
1313
* Flow-returning methods snapshot the relevant collection under the mutex and
1414
* emit from the snapshot, so the lock is not held during downstream consumption.
@@ -67,12 +67,10 @@ class InMemoryTrustStore : TrustStore {
6767

6868
override suspend fun updateSourceFreshness(sourceId: String, freshnessState: FreshnessState): Unit = mutex.withLock {
6969
sources[sourceId]?.let { sources[sourceId] = it.copy(freshnessState = freshnessState) }
70-
Unit
7170
}
7271

73-
override suspend fun updateSourceAuthenticity(sourceId: String, authenticityState: AuthenticityState): Unit = mutex.withLock {
74-
sources[sourceId]?.let { sources[sourceId] = it.copy(authenticityState = authenticityState) }
75-
Unit
72+
override suspend fun updateSourceAssurance(sourceId: String, assurance: SourceAssurance): Unit = mutex.withLock {
73+
sources[sourceId]?.let { sources[sourceId] = it.copy(assurance = assurance) }
7674
}
7775

7876
// ---------------------------------------------------------------------------

waltid-libraries/credentials/waltid-trust-registry/src/commonMain/kotlin/id/walt/trust/store/TrustStore.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import kotlinx.coroutines.flow.Flow
55

66
/**
77
* Abstraction over the trust data store.
8-
* MVP implementation: [InMemoryTrustStore].
9-
* Enterprise extension: implement in waltid-identity-enterprise with DB backing.
8+
* [InMemoryTrustStore] provides the default non-persistent implementation.
9+
* Persistent implementations can provide database-backed storage.
1010
*
1111
* Query methods that return multiple items produce a [Flow] so callers can stream
1212
* results without first materialising the entire result set into a List.
@@ -32,7 +32,7 @@ interface TrustStore {
3232
suspend fun getSource(sourceId: String): TrustSource?
3333
suspend fun listSources(): Flow<TrustSource>
3434
suspend fun updateSourceFreshness(sourceId: String, freshnessState: FreshnessState)
35-
suspend fun updateSourceAuthenticity(sourceId: String, authenticityState: AuthenticityState)
35+
suspend fun updateSourceAssurance(sourceId: String, assurance: SourceAssurance)
3636

3737
suspend fun findIdentitiesByCertificateSha256(sha256Hex: String): Flow<ServiceIdentity>
3838
suspend fun listCertificateIdentities(): Flow<ServiceIdentity>

waltid-libraries/credentials/waltid-trust-registry/src/commonTest/kotlin/id/walt/trust/InMemoryTrustStoreTest.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,13 +207,19 @@ class InMemoryTrustStoreTest {
207207
}
208208

209209
@Test
210-
fun `update source authenticity`() = runTest {
210+
fun `update source assurance`() = runTest {
211211
val store = createStore()
212212
store.upsertSource(testSource)
213213

214-
store.updateSourceAuthenticity("src-1", AuthenticityState.VALIDATED)
214+
val assurance = SourceAssurance(
215+
signatureStatus = SignatureStatus.VALID,
216+
signerTrust = SignerTrust.NOT_EVALUATED,
217+
authenticityState = AuthenticityState.INTEGRITY_VERIFIED,
218+
accepted = true
219+
)
220+
store.updateSourceAssurance("src-1", assurance)
215221
val result = store.getSource("src-1")
216-
assertEquals(AuthenticityState.VALIDATED, result?.authenticityState)
222+
assertEquals(assurance, result?.assurance)
217223
}
218224

219225
@Test

waltid-libraries/credentials/waltid-trust-registry/src/jvmMain/kotlin/id/walt/trust/parser/lote/LoteXmlParser.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ object LoteXmlParser {
3939
issueDate = issueDate,
4040
nextUpdate = nextUpdate,
4141
sequenceNumber = sequenceNumber,
42-
authenticityState = AuthenticityState.SKIPPED_DEMO,
42+
assurance = SourceAssurance(
43+
signatureStatus = SignatureStatus.NOT_PRESENT,
44+
signerTrust = SignerTrust.NOT_APPLICABLE,
45+
authenticityState = AuthenticityState.UNVERIFIED,
46+
details = "Unsigned LoTE XML"
47+
),
4348
freshnessState = FreshnessState.UNKNOWN,
4449
metadata = buildMap {
4550
listType?.let { put("listType", it) }

waltid-libraries/credentials/waltid-trust-registry/src/jvmMain/kotlin/id/walt/trust/parser/tsl/TslXmlParser.kt

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ private val logger = KotlinLogging.logger {}
2222
data class TslParseConfig(
2323
/**
2424
* Validate XMLDSig signature on the TSL.
25-
* If false, signature is not checked and authenticityState = SKIPPED_DEMO.
25+
* If false, signature is not checked and the source remains unverified.
2626
*/
2727
val validateSignature: Boolean = true,
2828

@@ -42,7 +42,7 @@ data class TslParseConfig(
4242

4343
/**
4444
* If true, require that the TSL has a signature.
45-
* If false, unsigned TSLs are accepted with authenticityState = SKIPPED_DEMO.
45+
* If false, unsigned TSLs are parsed as unverified.
4646
* Only applies when validateSignature = true.
4747
*/
4848
val requireSignature: Boolean = true
@@ -95,13 +95,31 @@ object TslXmlParser {
9595
null
9696
}
9797

98-
// Determine authenticity state
99-
val isUnsigned = signatureResult?.details?.contains("No Signature element") == true
100-
val authenticityState = when {
101-
signatureResult == null -> AuthenticityState.SKIPPED_DEMO
102-
signatureResult.state == AuthenticityState.VALIDATED -> AuthenticityState.VALIDATED
103-
isUnsigned && !config.requireSignature -> AuthenticityState.SKIPPED_DEMO
104-
else -> AuthenticityState.FAILED
98+
val assurance = when {
99+
signatureResult == null -> SourceAssurance(
100+
signatureStatus = SignatureStatus.NOT_CHECKED,
101+
signerTrust = SignerTrust.NOT_EVALUATED,
102+
authenticityState = AuthenticityState.UNVERIFIED,
103+
details = "XML signature verification was disabled"
104+
)
105+
signatureResult.signatureStatus == SignatureStatus.NOT_PRESENT && !config.requireSignature -> SourceAssurance(
106+
signatureStatus = SignatureStatus.NOT_PRESENT,
107+
signerTrust = SignerTrust.NOT_APPLICABLE,
108+
authenticityState = AuthenticityState.UNVERIFIED,
109+
details = signatureResult.details
110+
)
111+
signatureResult.signatureStatus == SignatureStatus.NOT_PRESENT -> SourceAssurance(
112+
signatureStatus = SignatureStatus.NOT_PRESENT,
113+
signerTrust = SignerTrust.NOT_APPLICABLE,
114+
authenticityState = AuthenticityState.FAILED,
115+
details = "A signature is required but the TSL is unsigned"
116+
)
117+
else -> SourceAssurance(
118+
signatureStatus = signatureResult.signatureStatus,
119+
signerTrust = signatureResult.signerTrust,
120+
authenticityState = signatureResult.state,
121+
details = signatureResult.details
122+
)
105123
}
106124

107125
// Log validation result
@@ -116,7 +134,7 @@ object TslXmlParser {
116134
}
117135

118136
// If strict validation is enabled and signature is invalid, throw
119-
if (config.strictSignatureValidation && authenticityState == AuthenticityState.FAILED) {
137+
if (config.strictSignatureValidation && assurance.authenticityState == AuthenticityState.FAILED) {
120138
throw TslSignatureValidationException(
121139
"TSL signature validation failed: ${signatureResult?.details}",
122140
signatureResult
@@ -132,7 +150,7 @@ object TslXmlParser {
132150
issueDate = issueDate,
133151
nextUpdate = nextUpdate,
134152
sequenceNumber = sequenceNumber,
135-
authenticityState = authenticityState,
153+
assurance = assurance,
136154
freshnessState = FreshnessState.UNKNOWN,
137155
metadata = buildMap {
138156
versionId?.let { put("tslVersion", it) }

0 commit comments

Comments
 (0)