INDEPENDENT WORKSPACE REVIEW · 05 SEPTEMBER 2026

A strong foundation.
Trust decisions need tighter contracts.

Detailed source review of TrustWeave and TrustWeave SaaS: security boundaries, correctness, storage, protocol integration, delivery quality and user experience.

22 findings & improvements7 P1 · 14 P2 · 1 P3No application code changed
REPOSITORY SCORE

trustweave

69/100

Strong modular foundation; wallet semantics and integration gaps need attention.

DimensionPoints
Security & trust21 / 30
Correctness & data integrity16 / 25
Architecture & maintainability16 / 20
Testing & delivery10 / 15
Developer / user experience6 / 10
REPOSITORY SCORE

trustweave-saas

58/100

Broad product functionality; verification trust gaps should block production identity checks.

DimensionPoints
Security & trust13 / 30
Correctness & data integrity13 / 25
Architecture & maintainability15 / 20
Testing & delivery11 / 15
Developer / user experience6 / 10

Assessment

TrustWeave: 69/100. Modular SPI boundaries, typed outcomes, a candid maturity matrix, guarded network defaults and fail-closed credential verification provide a useful foundation. The main deductions concern wallet data semantics, bounded lifecycle management, uneven format support and incomplete browser-wallet delivery gates.

TrustWeave SaaS: 58/100. The product has coherent tenant helpers, schema migrations, billing boundaries, reusable UI components and a passing frontend suite. The largest deductions are for verification decisions that can pass without proving the required holder relationship or satisfying the requested credential requirements. These are release-blocking for identity-dependent workflows.

Scores are reviewer judgments for the inspected repository scope, not measured test coverage, certification or a security guarantee. The same five weighted dimensions apply to both repositories. Security/correctness gaps dominate deductions; demo limitations and experimental plugins are explicitly scoped. No P0 issue was established.

Scope and method

Reviewed the local working trees, traced selected high-risk operations from entry point to storage/verification, inspected CI and frontend behavior, and ran the checks listed below. This is a detailed, risk-based review, not an exhaustive audit of every module or a live penetration test.

Priorities: P1 address before production reliance or repair delivery gate; P2 next planned engineering cycle; P3 lower-risk improvement. Each finding includes a concrete regression scenario; those proposed scenarios are not claimed as executed tests.

What is already working well

Prioritized finding index

IDRepositoryPriorityFindingArea
T01trustweaveP1A status-list reference is treated as proof of revocationCorrectness
T02trustweaveP1Database wallet truncates before applying filtersData correctness
S01trustweave-saasP1Holder proof does not bind the presenter to the credentialsSecurity
S02trustweave-saasP1Requested credential types and claims are never enforcedCorrectness
S03trustweave-saasP1Verification completion is a read–check–write raceConcurrency
S06trustweave-saasP1Public rate-limit identity trusts arbitrary forwarded headersDeployment-dependent security
S08trustweave-saasP1Backend tests default to a Windows Docker pipe on Linux CICI / portability
T03trustweaveP2File wallet overwrites are not atomicDurability
T04trustweaveP2Generated storage IDs are not embedded in anonymous credentialsAPI consistency
T05trustweaveP2OID4VCI session maps retain offers and bearer tokens indefinitelyResource management
T06trustweaveP2Holder-side OID4VCI only accepts JSON-LD credentialsFormat gap
T07trustweaveP2Reference wallet keeps exportable holder keys in localStorageDemo-to-production gap
T08trustweaveP2Reference wallet schema mismatch silently erases identity and credentialsUX / data loss
T09trustweaveP2Reference web wallet has no automated CI test gateQuality / improvement
S04trustweave-saasP2Unanswered verification requests never become expired on readsUX / correctness
S05trustweave-saasP2Completed verification checks keep polling every two secondsUX / performance
S07trustweave-saasP2Rate-limiter keys accumulate without evictionResource management
S09trustweave-saasP2Nested modal Escape handlers can dismiss both dialogsAccessibility / UX
S10trustweave-saasP2Eager page imports produce a large initial JavaScript bundlePerformance / improvement
S11trustweave-saasP2CI tests against mutable library main rather than a recorded revisionReproducibility / improvement
S12trustweave-saasP2Verifier drops unsupported embedded credential entriesFormat interoperability
T10trustweaveP3Plugin breadth needs a machine-readable capability contractDeveloper experience / improvement

Validation and limitations

CheckObserved result
Frontend production build ? npm run buildPASS. TypeScript and Vite completed. Main JS: 1,286.06 kB / 383.86 kB gzip. Vite reported large chunks and mixed static/dynamic imports.
Frontend tests ? npm test -- --reporter=dotPASS. 61 files, 290 tests. React Router and unhandled MSW request warnings appeared; those warnings were not failures.
Frontend lint ? npm run lintPASS. ESLint completed with the configured zero-warning policy.
Library ? :credentials:plugins:oidc4vci:test :wallet:plugins:file:testBUILD SUCCESSFUL. Both selected test tasks were FROM-CACHE; this is restored validation, not a fresh execution of their test bodies. Other build tasks executed.
SaaS ? :server:test --tests *CredentialVerifierService* --tests *VerificationRequest*24 tests passed across 2 suites, 0 failures/errors/skips. Overall command FAILED in the finalized JaCoCo gate: selected-run instruction coverage 0.16 versus 0.40 required. This does not establish whole-suite coverage or a test failure.
Backend full suite / Linux Docker / live servicesNot run. The Linux Docker endpoint issue is a source/configuration finding. No production credentials or services were used.
HTML artifactPASS: jsdom executed embedded JavaScript; verified 22 finding cards, all navigation targets, unique IDs, repository/priority filters and source expansion. No external assets. Visual browser inspection unavailable: browser tool reported no browser available.
Library cached suite detailRestored XML: OID4VCI 42 tests and file wallet 17 tests; 0 failures/errors/skips. These are cached results, not fresh test-body execution.

No full repository-wide coverage percentage was measured. Cached Gradle results are identified explicitly. The source findings do not imply a successful exploit against a deployed service. Dependency advisory scanning and external protocol conformance validation were outside this review; no CVE or compliance assertions are made.

Recommended action plan

  1. Before production identity checks: fix S01/S02 together as a verified-presentation contract, reject unsupported entries (S12), and add adversarial real-signature tests. Resolve S03 using atomic session transitions.
  2. Restore trustworthy delivery: fix Linux Docker discovery (S08), pin the library revision (S11), and run the full backend suite including migrations and tenant-isolation tests on a fresh database.
  3. Protect storage correctness: fix revocation semantics and database truncation (T01/T02), then atomic file updates and storage handles (T03/T04).
  4. Complete the operator workflow: fix expiry and terminal polling (S04/S05), modal stacking (S09), and give waiting/error states a countdown, retry and clear restart action. Cancel currently only clears local UI state; decide whether it should also invalidate the server request.
  5. Harden long-lived deployments: trusted proxy addressing and bounded rate-limit/session storage (S06/S07/T05), with metrics for rejection, queue age, state cardinality and verification latency.
  6. Unify the product family: publish an issuer → wallet → verifier format matrix (T06/S12), add wallet CI (T09), and keep demo key custody and destructive schema upgrades out of production use (T07/T08).
  7. Improve first use and maintainability: split public-route bundles (S10), generate capability metadata (T10), and revise onboarding/setup documentation to match actual runtime-only Docker builds and current configuration.

Suggested ownership: identity/security team for presentation contracts; storage/SDK team for wallet semantics; platform team for CI and ingress; frontend team for polling, modal behavior and route budgets. Sequence work by dependencies rather than treating findings as independent cosmetic tickets.

Detailed findings

trustweave-saas · Security · High — source trace

P1 S01 — Holder proof does not bind the presenter to the credentials

A caller with a copied valid credential can generate their own did:key and sign the public request nonce/audience. The verifier checks that signature and independently verifies the credential, but never checks that the credential authorizes this holder or that the JWS covers the presentation contents. The top-level holder can also disagree with proof.verificationMethod, yet is later displayed as the presenter. This undermines identity checks even when all cryptographic operations succeed. Bearer/delegated credentials need an explicit policy rather than a blanket subject-equality rule.

Recommended change

Use a supported presentation proof format that signs the complete presentation or a canonical digest; validate credential-specific holder authorization (subject/key binding, cnf, or explicit delegation policy). Derive the displayed holder from the verified proof and reject inconsistent identity fields.

Regression check

Use real signatures: issue to Alice, present with Mallory’s challenge signature, and assert rejection; mutate embedded credentials after signing; mismatch holder and verificationMethod. Keep an explicit authorized-delegation case.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:253
251:         )
252: 
253:         val bound = nonceOk && audOk
254:         return HolderBindingOutcome(
255:             bound = bound,
256:             checks = checks,
257:             error = if (bound) null else "Holder proof not bound to expected nonce/audience",
258:         )
259:     }
260: 
261:     /** Parse + verify a raw credential string (VC-LD JSON or compact SD-JWT VC) and map to the API shape. */
server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:133
131:             var allValid = true
132:             val errors = mutableListOf<String>()
133:             credentials.forEachIndexed { index, credJson ->
134:                 val label = "Credential #${index + 1}"
135:                 val result = verifyCredential(credJson, policy)
136:                 result.fold(
137:                     onSuccess = { vr ->
138:                         val response = toResponse(vr, trustEvaluated = policy.checkIssuerTrust)
139:                         response.checks.forEach { c ->
140:                             checks += VerificationCheck("$label — ${c.name}", c.passed, c.detail)
141:                         }
server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:216
214:         val verificationMethod = (proof["verificationMethod"] as? JsonPrimitive)?.content
215:         val holderField = (presentationJson["holder"] as? JsonPrimitive)?.content
216:         val holderDid = listOfNotNull(verificationMethod?.substringBefore('#'), holderField)
217:             .firstOrNull { it.startsWith("did:key:z") }
218:         if (holderDid == null) {
219:             checks += VerificationCheck("Resolve holder DID", false, "No holder did:key in proof.verificationMethod / holder")
220:             return HolderBindingOutcome(false, checks, "No holder did:key in presentation")
221:         }
222:         checks += VerificationCheck("Resolve holder DID", true, holderDid)
223: 
224:         val claims = runCatching {
trustweave-saas · Correctness · High — source trace

P1 S02 — Requested credential types and claims are never enforced

The QR request advertises type and claim requirements, but submitPresentation passes only nonce, audience and trust-space policy to the verifier. presentation_submission is accepted by the controller and ignored. A trusted, valid credential of the wrong type or missing a requested claim can yield VERIFIED.

Recommended change

Evaluate the submitted descriptor mapping and every required type/claim against verified credential data before marking VERIFIED. Reject missing, duplicate, mismatched or unsatisfied descriptors with an operator-readable reason.

Regression check

Ask for an employee credential with department; submit a valid unrelated credential or omit department. Both must fail even with a valid holder proof.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/VerificationRequestService.kt:190
188: 
189:         val result = runBlocking {
190:             verifier.verifyPresentation(
191:                 presentationJson = presentation,
192:                 expectedNonce = session.nonce,
193:                 expectedAudience = session.audience,
194:                 policy = policyResolver.forTrustSpace(session.trustSpaceId),
195:             )
196:         }
197: 
198:         val holderDid = presentation["holder"]?.toString()?.trim('"')
server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/Oid4VpControllers.kt:58
56:         @PathVariable sessionId: String,
57:         @RequestParam("vp_token") vpToken: String,
58:         @RequestParam("presentation_submission", required = false) presentationSubmission: String?,
59:         request: HttpServletRequest,
60:     ): ResponseEntity<Any> {
61:         rateLimiter.check("oid4vp-response:" + clientIp(request), maxRequests = 20, windowSeconds = 60)
62: 
63:         val session = requests.submitPresentation(sessionId, vpToken)
64:             ?: return ResponseEntity.notFound().build()
65: 
66:         return when (session.status) {
trustweave-saas · Concurrency · High — source trace

P1 S03 — Verification completion is a read–check–write race

Two requests can both read PENDING, verify independently, then overwrite the same session. @Transactional alone supplies no optimistic version or conditional transition. A malformed response can race a legitimate response and overwrite the final verdict. A sequential repeat of a VERIFIED session also returns accepted without checking the new token, because the previous result is returned.

Recommended change

Claim the pending session with a database conditional update or versioned state transition. Define replay responses separately from a newly accepted submission. Avoid holding a row lock during slow network verification.

Regression check

Run concurrent valid/invalid submissions using PostgreSQL and assert exactly one terminal transition; repeat with a different token after completion and assert explicit already-used behavior.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/VerificationRequestService.kt:169
167:     @Transactional
168:     fun submitPresentation(sessionId: String, vpToken: String): VerificationSession? {
169:         val session = sessions.findById(sessionId).orElse(null) ?: return null
170: 
171:         if (session.status != VerificationSessionStatus.PENDING) {
172:             // Single-use: a second presentation against a completed session is refused rather than
173:             // silently overwriting the first verdict.
174:             return session
175:         }
176:         if (session.expiresAt.isBefore(Instant.now())) {
177:             return sessions.save(
server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/VerificationSession.kt:107
105: 
106: @Repository
107: interface VerificationSessionRepository : JpaRepository<VerificationSession, String> {
108:     fun findByTrustSpaceIdOrderByCreatedAtDesc(trustSpaceId: Long): List<VerificationSession>
109: }
trustweave-saas · UX / correctness · High — source trace

P2 S04 — Unanswered verification requests never become expired on reads

Expiry is applied only when someone submits a presentation. A request that nobody answers remains PENDING in get/list, and its public request object remains available. The operator can wait indefinitely despite the ten-minute lifetime.

Recommended change

Derive expiration consistently on reads or persist it in a scheduled/conditional transition; return 410 for an expired request object. Show a countdown and a restart action.

Regression check

Advance a controlled clock beyond TTL without submitting; private get/list must show EXPIRED and public request fetch must return 410.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/VerificationRequestService.kt:281
279:     }.getOrDefault(false)
280: 
281:     fun get(sessionId: String): VerificationSession? = sessions.findById(sessionId).orElse(null)
282: 
283:     fun listFor(trustSpaceId: Long): List<VerificationSession> =
284:         sessions.findByTrustSpaceIdOrderByCreatedAtDesc(trustSpaceId)
285: 
286:     private fun rejected(session: VerificationSession, reason: String) = session.copy(
287:         status = VerificationSessionStatus.REJECTED,
288:         resultJson = errorJson(reason),
289:         completedAt = Instant.now(),
server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/Oid4VpControllers.kt:36
34:         rateLimiter.check("oid4vp-request:" + clientIp(request), maxRequests = 30, windowSeconds = 60)
35:         val session = requests.get(sessionId) ?: return ResponseEntity.notFound().build()
36:         if (session.status != VerificationSessionStatus.PENDING) {
37:             return ResponseEntity.status(HttpStatus.GONE)
38:                 .body(mapOf("error" to "This verification request has already been used."))
39:         }
40:         return ResponseEntity.ok(requests.requestObject(session))
41:     }
42: 
43:     /**
44:      * Where the wallet posts the presentation.
trustweave-saas · UX / performance · High — source trace

P2 S05 — Completed verification checks keep polling every two seconds

session is the original create response and remains PENDING. Poll results are held in polled, so the enabled condition never changes when the server returns VERIFIED or REJECTED. The comment promises polling stops, but the fixed interval continues until reset/unmount.

Recommended change

Make refetchInterval depend on the latest query data and return false for terminal states; stop at expiry and expose polling errors with a retry action.

Regression check

With fake timers, return VERIFIED on the first poll and assert no further requests after several intervals. Also test expiry and a disconnected network.

frontend/src/pages/CheckCredential.tsx:78
76:     queryKey: ['verification-session', spaceIdNum, session?.id],
77:     queryFn: () => verificationRequestsApi.get(spaceIdNum, session!.id),
78:     enabled: !!session && session.status === 'PENDING',
79:     refetchInterval: POLL_MS,
80:   })
81: 
82:   const current = polled ?? session
83:   const result = current?.result
84: 
85:   const toggleType = (vcType: string) => {
86:     // The claims on offer belong to a specific type, so a change of type invalidates them.
trustweave-saas · Deployment-dependent security · High code confidence; exposure depends on ingress

P1 S06 — Public rate-limit identity trusts arbitrary forwarded headers

The public OID4VP controller uses the first caller-supplied X-Forwarded-For value as the rate-limit key. If the backend is reachable directly or the ingress preserves an untrusted first value, clients can rotate the header and bypass limits. This is conditional on ingress configuration; the repository code does not enforce the trust boundary.

Recommended change

Resolve client addresses through an explicitly trusted proxy configuration and block direct backend access. Apply an independent endpoint/global budget so a changing address cannot exhaust cryptographic workers.

Regression check

Send repeated requests with changing forwarded headers directly and through the production ingress configuration. Untrusted headers must not reset the effective budget.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/Oid4VpControllers.kt:75
73: 
74:     private fun clientIp(request: HttpServletRequest): String {
75:         val forwarded = request.getHeader("X-Forwarded-For")
76:         return if (!forwarded.isNullOrBlank()) forwarded.substringBefore(",").trim()
77:         else request.remoteAddr ?: "unknown"
78:     }
79: }
80: 
81: /**
82:  * The verifier's own API: create a request, watch for a result.
83:  *
server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/security/RateLimiter.kt:24
22:     private data class Window(val windowStartEpochSec: Long, val count: Int)
23: 
24:     private val windows = ConcurrentHashMap<String, AtomicReference<Window>>()
25: 
26:     /**
27:      * Records one request against [key]. Throws [TooManyRequestsException] when more than
28:      * [maxRequests] requests are seen for [key] within any [windowSeconds] window.
29:      */
30:     fun check(key: String, maxRequests: Int, windowSeconds: Long) {
31:         val nowSec = System.currentTimeMillis() / 1000L
32:         var rejected = false
trustweave-saas · Resource management · High — source trace

P2 S07 — Rate-limiter keys accumulate without eviction

The map resets counters but never removes expired keys. A long-running service accumulates an entry for every distinct key, including attacker-selected header values on the path in S06. Multiple instances also maintain independent limits, as the class documentation acknowledges.

Recommended change

Use bounded expiring storage, remove idle windows, and publish cardinality/rejection metrics. Use a shared limiter or ingress enforcement when deploying multiple instances.

Regression check

Generate many unique keys, advance beyond their TTL, and assert memory/state returns to a bounded baseline; test multi-instance effective limits.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/security/RateLimiter.kt:34
32:         var rejected = false
33: 
34:         val ref = windows.computeIfAbsent(key) { AtomicReference(Window(nowSec, 0)) }
35:         ref.updateAndGet { current ->
36:             if (nowSec - current.windowStartEpochSec >= windowSeconds) {
37:                 // Window elapsed: start a fresh window counting this request.
38:                 Window(nowSec, 1)
39:             } else if (current.count >= maxRequests) {
40:                 // Over the limit within the current window: reject, do not increment.
41:                 rejected = true
42:                 current
trustweave-saas · CI / portability · High — configuration mismatch; Linux run not performed

P1 S08 — Backend tests default to a Windows Docker pipe on Linux CI

The test task unconditionally falls back to npipe:////./pipe/dockerDesktopLinuxEngine and injects it into both environment and system properties. The Ubuntu workflow does not set DOCKER_HOST. On Linux runners without that variable, Testcontainers receives a Windows endpoint instead of discovering the local Unix socket. This threatens the migration and smoke-boot checks the workflow is meant to guarantee.

Recommended change

Apply the named-pipe fallback only on Windows; otherwise leave Docker discovery untouched. Keep an explicit environment override for remote engines.

Regression check

Run the integration suite on Ubuntu with DOCKER_HOST unset and verify PostgreSQL containers start. Retain a Windows Docker Desktop check.

server/build.gradle.kts:169
167:     // reads from different sources depending on the version. The Gradle test worker does not inherit
168:     // the shell environment, so both must be set explicitly here.
169:     val dockerHost = System.getenv("DOCKER_HOST") ?: "npipe:////./pipe/dockerDesktopLinuxEngine"
170:     environment("DOCKER_HOST", dockerHost)
171:     systemProperty("DOCKER_HOST", dockerHost)
172:     testLogging {
173:         events("passed", "skipped", "failed")
174:     }
175:     finalizedBy(tasks.jacocoTestReport)
176: }
177: 
.github/workflows/ci.yml:32
30:   frontend:
31:     name: Frontend
32:     runs-on: ubuntu-latest
33:     defaults:
34:       run:
35:         working-directory: frontend
36:     steps:
37:       - uses: actions/checkout@v4
38: 
39:       - uses: actions/setup-node@v4
40:         with:
trustweave-saas · Accessibility / UX · High — source trace

P2 S09 — Nested modal Escape handlers can dismiss both dialogs

Every Modal attaches a keydown listener to document. The issuer form can open a scanner as a second modal. stopPropagation does not suppress other listeners on the same document, so Escape can close the underlying form as well as the scanner; both focus traps also remain active. Form input may be lost.

Recommended change

Manage a modal stack and let only its top entry handle Escape/Tab; mark the background inert and restore focus to the scanner trigger. Keep accessible names for ReactNode titles via aria-labelledby.

Regression check

Open the issuer form, enter values, open its scanner, press Escape: only the scanner should close, values should remain, and focus should return to its trigger.

frontend/src/components/ui/Modal.tsx:44
42: 
43:     const onKey = (e: KeyboardEvent) => {
44:       if (e.key === 'Escape' && dismissable) { e.stopPropagation(); onClose(); return }
45: 
46:       if (e.key === 'Tab' && dialogRef.current) {
47:         const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))
48:         if (focusable.length === 0) { e.preventDefault(); return }
49:         const first = focusable[0]
50:         const last = focusable[focusable.length - 1]
51:         if (e.shiftKey) {
52:           if (document.activeElement === first || document.activeElement === dialogRef.current) {
frontend/src/components/IssueCredentialModal.tsx:388
386:     </Modal>
387: 
388:     {scanHolderOpen && (
389:       <HolderDidScannerModal
390:         onClose={() => setScanHolderOpen(false)}
391:         onScan={(did) => setSubjectDid(did)}
392:       />
393:     )}
394:     </>
395:   )
396: }
frontend/src/components/DidScannerModal.tsx:58
56:       title={title}
57:       size="md"
58:       z={60}
59:       onClose={close}
60:       footer={
61:         <button type="button" onClick={close} className="btn btn-secondary btn-sm">
62:           Cancel
63:         </button>
64:       }
65:     >
66:       {hint && <p className="text-sm text-surface-500 mb-4">{hint}</p>}
trustweave-saas · Performance / improvement · High — production build measurement

P2 S10 — Eager page imports produce a large initial JavaScript bundle

The production build generated a 1,286.06 kB main JS chunk (383.86 kB gzip), plus a 429.03 kB spreadsheet chunk. App.tsx imports route pages eagerly, so public claim/verification visitors load much of the authenticated application. This is an observed bundle cost; no mobile timing benchmark was run.

Recommended change

Introduce route-level lazy loading with useful loading/error boundaries, isolate visualization/scanner dependencies, and enforce bundle budgets for public routes.

Regression check

Compare generated chunks and cold-load network traces for /claim and /verify on a throttled mobile profile; set a performance budget based on those measurements.

frontend/src/App.tsx:9
7: import { Login } from './components/Login'
8: import Layout from './components/Layout'
9: import Dashboard from './pages/Dashboard'
10: import TrustSpaces from './pages/TrustSpaces'
11: import Issuers from './pages/Issuers'
12: import CheckCredential from './pages/CheckCredential'
13: import CredentialTypes from './pages/CredentialTypes'
14: import StarterKits from './pages/StarterKits'
15: import AuditLogs from './pages/AuditLogs'
16: import Credentials, { DocumentsRedirect } from './pages/Credentials'
17: import PeopleAndAgents from './pages/PeopleAndAgents'
trustweave-saas · Reproducibility / improvement · High — source trace

P2 S11 — CI tests against mutable library main rather than a recorded revision

The same SaaS commit can resolve different library source on successive CI runs. Passing results cannot reliably identify the dependency revision that was tested, and a library change can break an unrelated SaaS pull request.

Recommended change

Record a library commit or release version in the repository, use it for CI and release builds, and update it through explicit integration changes. Publish both commit IDs with build artifacts.

Regression check

Re-run the same SaaS revision after library main advances and verify the dependency graph and selected library commit remain identical.

.github/workflows/ci.yml:74
72:           repository: geoknoesis/trustweave
73:           # Defaults to main; workflow_dispatch can point at a branch carrying an unmerged fix.
74:           ref: ${{ inputs.trustweave_ref || 'main' }}
75:           path: trustweave-lib
76:           token: ${{ secrets.TRUSTWEAVE_REPO_TOKEN || github.token }}
77: 
78:       - name: Place the library where the composite build expects it
79:         run: mv trustweave-lib ../trustweave
80: 
81:       - uses: actions/setup-java@v4
82:         with:
trustweave-saas · Format interoperability · High — source trace

P2 S12 — Verifier drops unsupported embedded credential entries

extractCredentials silently removes non-object array entries, although the service promises to verify every embedded credential. A mixed array containing a valid JSON credential and an invalid string can pass based solely on the JSON object. Compact credentials are also unsupported in this path, while the reference wallet stores compact JWT/SD-JWT credentials.

Recommended change

Reject unsupported entries explicitly, or dispatch each recognized format to its verifier. Advertise the accepted formats at request creation so incompatible wallets fail early with actionable guidance.

Regression check

Submit [validObject, invalidString], an all-compact array, and a mixed supported-format presentation. Every element must be verified or cause explicit rejection.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:174
172:     private fun extractCredentials(presentationJson: JsonObject): List<JsonObject> {
173:         return when (val node = presentationJson["verifiableCredential"]) {
174:             is JsonArray -> node.mapNotNull { it as? JsonObject }
175:             is JsonObject -> listOf(node)
176:             else -> emptyList()
177:         }
178:     }
179: 
180:     private data class HolderBindingOutcome(
181:         val bound: Boolean,
182:         val checks: List<VerificationCheck>,
trustweave · Correctness · High — source trace

P1 T01 — A status-list reference is treated as proof of revocation

FileWallet classifies any credential with credentialStatus as revoked, excludes it from valid statistics, and uses the same shortcut in revoked filtering. DatabaseWallet also uses status-entry presence as its filter proxy. An active credential issued with a status-list reference is therefore mislabeled or filtered out. Status support is metadata; it is not the current status bit.

Recommended change

Resolve status through the revocation abstraction or expose an explicit unknown status when offline. Align filter and statistics semantics across storage providers, and distinguish a status-capable credential from a revoked one.

Regression check

Store two credentials pointing to a status list with one active bit and one revoked bit; assert correct filtering and statistics. Include unavailable-status behavior.

wallet/plugins/file/src/main/kotlin/org/trustweave/wallet/file/FileWallet.kt:310
308:         }
309:         if (filter.revoked != null) {
310:             val isRevoked = credential.credentialStatus != null
311:             if (isRevoked != filter.revoked) return false
312:         }
313:         return true
314:     }
315: 
316:     /**
317:      * Get wallet statistics.
318:      */
wallet/plugins/file/src/main/kotlin/org/trustweave/wallet/file/FileWallet.kt:337
335:                 } ?: false
336:             },
337:             revokedCredentials = allCredentials.count { it.credentialStatus != null },
338:             collectionsCount = 0, // Would require collection implementation
339:             tagsCount = 0, // Would require tag implementation
340:             archivedCount = 0 // Would require archive implementation
341:         )
342:     }
343: 
344:     /**
345:      * Encrypt data using AES/GCM/NoPadding with a fresh random 12-byte IV.
wallet/plugins/database/src/main/kotlin/org/trustweave/wallet/database/DatabaseWallet.kt:1046
1044:             // filter.revoked=true  → returns only credentials that have a status entry
1045:             // filter.revoked=false → returns only credentials that have no status entry
1046:             val hasStatusEntry = credential.credentialStatus != null
1047:             if (hasStatusEntry != filter.revoked) return false
1048:         }
1049:         return true
1050:     }
1051: 
1052:     /**
1053:      * Get wallet statistics.
1054:      */
trustweave · Data correctness · High — source trace

P1 T02 — Database wallet truncates before applying filters

list() fetches at most 1,000 rows without stable ordering and then applies the filter in memory. A matching credential outside that arbitrary prefix is invisible. A log warning does not inform API consumers that their result is incomplete; presentation selection can wrongly conclude no suitable credential exists.

Recommended change

Push supported filters into SQL and introduce stable cursor pagination. Until the API can express pagination, return an explicit truncation/error result instead of an apparently complete list.

Regression check

Insert more than 1,000 credentials with the only matching item outside the initial window; verify it is discoverable and pagination has no duplicates or omissions.

wallet/plugins/database/src/main/kotlin/org/trustweave/wallet/database/DatabaseWallet.kt:348
346:                     SELECT credential_data FROM credentials
347:                     WHERE wallet_id = ? AND archived = FALSE
348:                     LIMIT 1000
349:                 """).use { stmt ->
350:                     stmt.setString(1, walletId)
351:                     stmt.executeQuery().use { rs ->
352:                         while (rs.next()) {
353:                             val credentialJson = rs.getString("credential_data")
354:                             rawResults.add(json.decodeFromString(VerifiableCredential.serializer(), credentialJson))
355:                         }
356:                     }
wallet/plugins/database/src/main/kotlin/org/trustweave/wallet/database/DatabaseWallet.kt:369
367: 
368:             // Apply in-memory filter after the truncation check so the warning fires on raw size.
369:             if (filter == null) rawResults else rawResults.filter { matchesFilter(it, filter) }
370:         } catch (e: Exception) {
371:             if (e is CancellationException) throw e
372:             throw WalletException.StorageError(
373:                 operation = "list",
374:                 reason = "Failed to list credentials: ${e.message}",
375:                 cause = e
376:             )
377:         }
trustweave · Durability · High — source trace

P2 T03 — File wallet overwrites are not atomic

Updating an existing credential truncates the live file before the replacement bytes are safely written. A process crash or disk failure can destroy the previous valid credential. Readers can observe a partial file; list() then logs and skips it. AES-GCM detects corruption but cannot restore the lost record.

Recommended change

Write to a sibling temporary file, flush it as required by the durability contract, and atomically replace the target. Coordinate writes per credential and define recovery/consistency for metadata sidecars.

Regression check

Inject failure during overwrite and concurrent reads. After restart, readers must see either the full old record or the full new record, never partial content.

wallet/plugins/file/src/main/kotlin/org/trustweave/wallet/file/FileWallet.kt:178
176:         }
177: 
178:         Files.write(credentialFile, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE)
179: 
180:         // Initialize metadata if not exists. The sidecar embeds the raw credential id
181:         // (ids can be PII-bearing URNs), so it is protected with the same AES-GCM
182:         // scheme as the credential file whenever an encryption key is configured.
183:         val metadataFile = resolveDataFile(metadataDir, id)
184:         if (!Files.exists(metadataFile)) {
185:             val metadata = buildJsonObject {
186:                 put("credentialId", id)
trustweave · API consistency · High — source trace

P2 T04 — Generated storage IDs are not embedded in anonymous credentials

store() returns a generated ID when credential.id is absent but serializes the unchanged credential. A subsequent list() returns an item with no ID, leaving callers unable to recover the storage handle for get/delete from that item. Re-storing the same anonymous credential creates another random entry.

Recommended change

Expose a stored-record wrapper with a stable storage ID independent of the signed credential ID. Do not silently edit a signed credential to insert an ID, which could invalidate its proof.

Regression check

Store an ID-less credential, restart, list records and delete the intended record using the returned storage handle. Verify the signed credential bytes remain unchanged.

wallet/plugins/file/src/main/kotlin/org/trustweave/wallet/file/FileWallet.kt:168
166:     // CredentialStorage implementation
167:     override suspend fun store(credential: VerifiableCredential): String = withContext(Dispatchers.IO) {
168:         val id = credential.id?.value ?: UUID.randomUUID().toString()
169:         val credentialJson = json.encodeToString(VerifiableCredential.serializer(), credential)
170: 
171:         val credentialFile = resolveDataFile(credentialsDir, id)
172:         val content = if (secretKey != null) {
173:             encrypt(credentialJson)
174:         } else {
175:             credentialJson.toByteArray(Charsets.UTF_8)
176:         }
trustweave · Resource management · High — source trace

P2 T05 — OID4VCI session maps retain offers and bearer tokens indefinitely

The service retains offers, requests, access tokens and nonces in concurrent maps with no removal/clear path in the class. A long-lived issuer-bound service grows with every exchange and keeps sensitive token material after completion. Application-scoped use makes this an availability and retention concern.

Recommended change

Add bounded TTL-backed exchange storage, remove completed/cancelled request secrets, and define a lifecycle close/purge operation. Preserve deferred requests only until their documented expiry.

Regression check

Complete and abandon large numbers of exchanges using a fake clock; verify bounded cardinality, token removal, and continued availability of valid deferred exchanges.

credentials/plugins/oidc4vci/src/main/kotlin/org/trustweave/credential/oidc4vci/Oidc4VciService.kt:80
78:     private val verifier: CredentialService? = didResolver?.let { credentialService(it) }
79: 
80:     private val offers = ConcurrentHashMap<String, Oidc4VciOffer>()
81:     private val requests = ConcurrentHashMap<String, Oidc4VciCredentialRequest>()
82:     private val accessTokens = ConcurrentHashMap<String, String>() // requestId -> accessToken
83:     private val cNonces = ConcurrentHashMap<String, String>()
84: 
85:     // requestId -> c_nonce from token/credential endpoint
86:     @Volatile private var metadata: CredentialIssuerMetadata? = null
87: 
88:     /** Token endpoint resolved from the authorization server's RFC 8414 metadata (cached). */
credentials/plugins/oidc4vci/src/main/kotlin/org/trustweave/credential/oidc4vci/Oidc4VciService.kt:82
80:     private val offers = ConcurrentHashMap<String, Oidc4VciOffer>()
81:     private val requests = ConcurrentHashMap<String, Oidc4VciCredentialRequest>()
82:     private val accessTokens = ConcurrentHashMap<String, String>() // requestId -> accessToken
83:     private val cNonces = ConcurrentHashMap<String, String>()
84: 
85:     // requestId -> c_nonce from token/credential endpoint
86:     @Volatile private var metadata: CredentialIssuerMetadata? = null
87: 
88:     /** Token endpoint resolved from the authorization server's RFC 8414 metadata (cached). */
89:     @Volatile private var resolvedTokenEndpoint: String? = null
90: 
trustweave · Format gap · High — source trace

P2 T06 — Holder-side OID4VCI only accepts JSON-LD credentials

The new holder verification path correctly fails closed, but rejects compact JWT and SD-JWT credentials. The reference wallet stores those formats and the SaaS has multiple issuance formats, so the product family does not yet offer a consistent acceptance matrix. This is a documented capability gap, not an unverified-signature vulnerability.

Recommended change

Expose supported receive formats through capability discovery and constrain offers accordingly. Add compact-format parsing and verification before claiming those formats work through this SDK flow.

Regression check

Run issuance-to-wallet-to-verifier contract scenarios for each advertised format, with explicit expected rejection for unsupported combinations.

credentials/plugins/oidc4vci/src/main/kotlin/org/trustweave/credential/oidc4vci/Oidc4VciService.kt:444
442:      * Parses the `credential` member of an issuer response into a [VerifiableCredential].
443:      *
444:      * Only VC-LD (JSON-LD) is supported today. Compact representations (JWT / SD-JWT-VC) have no
445:      * compact-to-model parser in `credential-api`, so they are rejected rather than trusted.
446:      */
447:     private suspend fun parseIssuedCredential(
448:         response: JsonObject,
449:         expectedIssuer: String,
450:     ): VerifiableCredential {
451:         val raw = response["credential"]
452:         if (raw == null || raw is JsonNull) {
credentials/plugins/oidc4vci/src/main/kotlin/org/trustweave/credential/oidc4vci/Oidc4VciService.kt:463
461:                 is JsonPrimitive -> throw Oidc4VciException.CredentialVerificationFailed(
462:                     reason =
463:                         "compact (JWT / SD-JWT-VC) issued credentials are not yet verified; " +
464:                             "only VC-LD (JSON-LD) is supported",
465:                     credentialIssuer = expectedIssuer,
466:                 )
467:                 else -> throw Oidc4VciException.CredentialVerificationFailed(
468:                     reason = "unsupported 'credential' representation: ${raw::class.simpleName}",
469:                     credentialIssuer = expectedIssuer,
470:                 )
471:             }
trustweave · Demo-to-production gap · High — explicitly documented demo limitation

P2 T07 — Reference wallet keeps exportable holder keys in localStorage

The browser reference wallet stores the holder private key as cleartext base64url. Any script executing in its origin can read it. The source clearly labels this a walking-skeleton demo limitation, so this finding is a promotion gate for a production wallet, not a claim that the library KMS is insecure.

Recommended change

Keep the demo limitation visible in the UI and deployment documentation. Before production use, implement platform-backed key custody, user verification, recovery and a credential export strategy appropriate to supported algorithms.

Regression check

Verify production wallet code cannot export raw private key material and that recovery, device loss and signing cancellation are explicitly tested.

reference-wallet/lib/storage.ts:23
21:   did: string
22:   publicKey: string  // base64url
23:   privateKey: string  // base64url
24:   createdAt: string
25: }
26: 
27: export interface StoredCredential {
28:   id: string  // local UUID for the wallet's record
29:   format: 'vc+jwt' | 'vc+sd-jwt'
30:   credential: string  // the credential as-issued (VC-JWT or SD-JWT VC compact form)
31:   receivedAt: string
reference-wallet/lib/storage.ts:75
73:   if (!isBrowser()) throw new Error('saveHolder requires a browser environment')
74:   ensureSchemaVersion()
75:   window.localStorage.setItem(HOLDER_KEY, JSON.stringify(holder))
76: }
77: 
78: export function loadCredentials(): StoredCredential[] {
79:   if (!isBrowser()) return []
80:   ensureSchemaVersion()
81:   const raw = window.localStorage.getItem(CREDENTIALS_KEY)
82:   return raw ? (JSON.parse(raw) as StoredCredential[]) : []
83: }
trustweave · UX / data loss · High — source trace

P2 T08 — Reference wallet schema mismatch silently erases identity and credentials

Any non-current stored version triggers removal of the holder identity and every credential. This includes older and newer versions, so an upgrade or rollback can silently change the holder DID and destroy access to previously issued credentials. The source accepts this for the demo; it is still a disruptive demo-user experience.

Recommended change

Migrate supported older schemas without rotating identity. For unknown/newer versions, stop with a recovery/export screen and require an explicit reset action.

Regression check

Open v1, v2, future-version and corrupt-version stores; preserve identity and credentials or present a recoverable error. Never delete records merely by loading the page.

reference-wallet/lib/storage.ts:55
53:   if (!existing) {
54:     window.localStorage.setItem(VERSION_KEY, String(CURRENT_VERSION))
55:   } else if (Number(existing) !== CURRENT_VERSION) {
56:     // v1 → v2: storage shape changed (added `format`, `credential` replaces `vcJwt`,
57:     // added `selectivelyDisclosable`). No automatic migration — wipe and let the user
58:     // re-receive. Acceptable for a demo wallet; a real wallet would migrate in place.
59:     window.localStorage.removeItem(HOLDER_KEY)
60:     window.localStorage.removeItem(CREDENTIALS_KEY)
61:     window.localStorage.setItem(VERSION_KEY, String(CURRENT_VERSION))
62:   }
63: }
trustweave · Quality / improvement · High — source trace

P2 T09 — Reference web wallet has no automated CI test gate

The reference-wallet package defines build, lint and typecheck commands but no test command. The inspected root CI workflow runs only Gradle checks. Browser storage migrations, consent, QR flows and cross-repository presentation contracts therefore need an independent automated gate; Kotlin green builds do not validate them.

Recommended change

Add a wallet CI job for clean install, typecheck/build and focused browser tests, including credential import, selective disclosure, storage upgrades and a SaaS verification round trip.

Regression check

Introduce a deliberate wallet TypeScript error and a broken storage migration in a test branch; CI should reject both. Add at least one real-signature end-to-end contract fixture.

reference-wallet/package.json:6
4:   "private": true,
5:   "description": "Walking-skeleton holder wallet demo for the TrustWeave Wallet SDK",
6:   "scripts": {
7:     "dev": "next dev",
8:     "build": "next build",
9:     "start": "next start",
10:     "lint": "next lint",
11:     "typecheck": "tsc --noEmit",
12:     "resize-demo-photos": "python scripts/resize-demo-photos.py",
13:     "generate-subject-portraits": "python scripts/generate-subject-portraits.py"
14:   },
.github/workflows/ci.yml:37
35: 
36:       - name: Build with Gradle
37:         run: ./gradlew build --no-daemon --stacktrace
trustweave · Developer experience / improvement · High — source trace

P3 T10 — Plugin breadth needs a machine-readable capability contract

The maturity matrix is a useful and candid strength, but experimental modules and explicit stubs still exist alongside production-oriented core APIs. Integrators need to discover supported operations/formats before selecting a plugin, rather than finding a failure during a real workflow.

Recommended change

Publish operation-level capabilities and maturity with plugin metadata; validate required capabilities during startup and generate the human-readable maturity table from the same source.

Regression check

Select a stub or unsupported operation in a configuration fixture and assert a clear startup validation error rather than a late workflow failure.

docs/api-reference/module-maturity.md:17
15: # Module maturity matrix
16: 
17: TrustWeave is a multi-module repository. **Publishing a JAR to Maven Central does not imply the module is complete or recommended for all production use cases.** This matrix states intended support level for integrators.
18: 
19: | Tier | Meaning |
20: |------|--------|
21: | **Supported (GA)** | Intended for production use when configured correctly; breaking API changes follow semver + [Deprecation policy](deprecation-policy.md). |
22: | **Supported with provider** | Core APIs are stable; you must use a mature KMS/DID/anchor **implementation** you trust. |
23: | **Experimental** | APIs may change; may contain `TODO` or stub behavior—verify in your environment before production. |
24: 
25: ## Core platform (typical SDK dependencies)
anchors/plugins/starknet/src/main/kotlin/org/trustweave/anchor/starknet/StarkNetBlockchainAnchorClient.kt:80
78:             chainId = chainId,
79:             operation = "submitTransaction",
80:             reason = "The StarkNet anchor client is a stub and is not implemented: " +
81:                 "transaction submission would require a StarkNet SDK and a Cairo storage " +
82:                 "contract, neither of which exists."
83:         )
84:     }
85: 
86:     override protected suspend fun readTransactionFromBlockchain(txHash: String): AnchorResult {
87:         throw BlockchainException.UnsupportedOperation(
88:             chainId = chainId,