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.
Strong modular foundation; wallet semantics and integration gaps need attention.
Dimension
Points
Security & trust
21 / 30
Correctness & data integrity
16 / 25
Architecture & maintainability
16 / 20
Testing & delivery
10 / 15
Developer / user experience
6 / 10
REPOSITORY SCORE
trustweave-saas
58/100
Broad product functionality; verification trust gaps should block production identity checks.
Dimension
Points
Security & trust
13 / 30
Correctness & data integrity
13 / 25
Architecture & maintainability
15 / 20
Testing & delivery
11 / 15
Developer / user experience
6 / 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.
TrustWeave snapshot:6c62fa0e9a9a589e359a81db902087c873e98884. Existing uncommitted documentation and untracked files were present and preserved. Findings are grounded in source, not inherited from earlier review reports.
SaaS snapshot:34740a945af0465a42e87892c179fdf1a9a233e1. Working tree was clean at initial inspection.
Deep inspection: OID4VCI receive/verification, file and database wallets, SaaS presentation verification and sessions, auth/tenant helpers, rate limiting, token consumption, selected billing/configuration paths, React routing, credential checks, modal/scanner interaction and reference-wallet storage.
Limited inspection: individual cloud KMS/chain adapters, signature-format implementations and native mobile wallets were not comprehensively audited. No live Keycloak, payment provider, blockchain or production ingress was exercised.
UX evidence: source and component tests; no authenticated browser usability study or accessibility conformance audit. Timing/performance conclusions are limited to generated bundle measurements.
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
The library’s OID4VCI client verifies received credentials and fails closed without a resolver; the default HTTP client is SSRF guarded. A scheme-only helper is therefore not, by itself, evidence of an SSRF vulnerability.
File wallet paths use SHA-256-derived names and configured encryption uses authenticated AES-GCM. These protect different concerns than the remaining overwrite and status-semantics defects.
SaaS TenantContext centralizes organization/space checks and provides an owner-only billing boundary. The inspected security configuration denies unspecified routes.
Production/dev profile conflicts are explicitly guarded; staging rejects ephemeral KMS use and uses schema validation.
OID4VCI token consumption already uses a conditional database update, a useful model for repairing presentation session transitions.
Frontend build, lint and 290 tests pass. Shared form, modal, schema and vocabulary infrastructure gives the UX improvements a practical place to land.
BUILD SUCCESSFUL. Both selected test tasks were FROM-CACHE; this is restored validation, not a fresh execution of their test bodies. Other build tasks executed.
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 services
Not run. The Linux Docker endpoint issue is a source/configuration finding. No production credentials or services were used.
HTML artifact
PASS: 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 detail
Restored 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
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.
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.
Protect storage correctness: fix revocation semantics and database truncation (T01/T02), then atomic file updates and storage handles (T03/T04).
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.
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.
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).
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.
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.
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.
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(
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.
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.
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.
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:
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) {
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
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.
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.
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.
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.
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.
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.
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.
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) {
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: }
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.
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)
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,