Round 2 remediation report: fixes, validation, and the remaining release gate
Fresh code review · 5 September 2026 · Current working trees

What still prevents a perfect score?

The remediation closed important gaps, but fresh inspection and adversarial probes found additional defects. This report supersedes the provisional 85/86 estimates with evidence-based scores for the inspected scope.

27 findings13 SDK / 14 SaaS9 high priority / 18 medium priority7 reproductions

Revised scores

trustweave

79 /100

Previously 69 in the original review; provisional post-fix estimate 85. New wallet correctness, privacy and dependency findings reduce that estimate.

DimensionPoints
Security & trust22 / 30
Correctness & data integrity19 / 25
Architecture & maintainability17 / 20
Testing & delivery13 / 15
Developer / user experience8 / 10
trustweave-saas

75 /100

Previously 58 in the original review; provisional post-fix estimate 86. Public credential disclosure and inaccurate verification semantics are release blockers.

DimensionPoints
Security & trust20 / 30
Correctness & data integrity19 / 25
Architecture & maintainability16 / 20
Testing & delivery12 / 15
Developer / user experience8 / 10

Scores are reviewer judgments using the original 30/25/20/15/10 rubric. They are not test coverage, a certification, or a guarantee of safety. Deductions apply to overlapping dimensions rather than adding a fixed penalty for each finding. Intentional unsupported formats and a clearly scoped demo do not need every imaginable feature to earn full marks; their advertised contracts must be accurate and safely enforced.

DimensionSDK deductions chiefly supported bySaaS deductions chiefly supported by
Security / trustTW01–03, TW13SA01, SA03, SA05–07, SA12
Correctness / integrityTW02–05, TW07–10SA02, SA04–06, SA11, SA14
Architecture / maintenanceTW06, TW08, TW11SA07–08, SA10–11
Testing / deliveryTW12–13; probes found contract gapsSA13; policy/race/legacy-route test gaps
ExperienceTW01, TW05, TW07, TW10SA05, SA07–09

First priorities

  1. Close public credential disclosure (SA01). A known credential ID leads to holder discovery and raw credential retrieval without holder proof.
  2. Make verification decisions truthful (SA03–06). Require the actual API audience; execute the chosen policy consistently; never show unchecked revocation as passed; make Strict require trust configuration.
  3. Serialize issuance by offer (SA02). Atomic access-token consumption does not prevent two different grants redeeming the same invitation.
  4. Repair wallet proof and disclosure handling (TW01–03). Import authenticity and issuer-bound disclosure integrity need executable negative and round-trip tests.
  5. Triage dependencies (TW13, SA12). Audit reports affected package versions, including development tools; prioritize reachable production paths and untrusted spreadsheet parsing.
The passing remediation suite did not prove these paths safe. The seven new probes pass because they assert the currently observed undesirable behavior. They are evidence, not remediation tests claiming the code is fixed.

Issue register

P1: high priority before the affected feature is used for production trust decisions or exposed to relevant untrusted input. P2: substantive correctness, reliability, privacy, scale or delivery work. No P0 was established.

IDRepositoryPriorityFinding
TW01trustweaveP1Credential import accepts invalid issuer signatures
TW02trustweaveP1Decrypting a claim creates a disclosure the issuer never signed
TW03trustweaveP2Multiple SD-JWT credentials fall into the full-disclosure path
TW04trustweaveP2Cloud listing hides storage and status-resolution errors
TW05trustweaveP2Anonymous cloud credentials still lose their discoverable storage handle
TW06trustweaveP2Database queries now materialize every credential
TW07trustweaveP2File listing presents partial recovery as a complete result
TW08trustweaveP2Atomic replacement does not coordinate delete and metadata
TW09trustweaveP2Credential writes are not serialized across tabs
TW10trustweaveP2Current-schema data is cast without runtime validation
TW11trustweaveP2Capability checks and the catalog remain separate sources of truth
TW12trustweaveP2Browser custody and cross-profile flows lack end-to-end coverage
TW13trustweaveP1Reference-wallet dependency graph has unresolved advisories
SA01trustweave-saasP1Public legacy redemption exposes credentials without proof of possession
SA02trustweave-saasP1An offer can be redeemed concurrently through multiple grants
SA03trustweave-saasP1azp bypasses the intended API audience requirement
SA04trustweave-saasP2Adding a trusted issuer changes disabled checks back to enabled
SA05trustweave-saasP1Unchecked expiry and revocation are reported as passing checks
SA06trustweave-saasP1Strict verification silently relaxes issuer trust when the list is empty
SA07trustweave-saasP2The request requires selective disclosure while accepting whole JSON credentials
SA08trustweave-saasP2Verification history causes unbounded N+1 read/write work
SA09trustweave-saasP2Cancel only dismisses the operator’s local view
SA10trustweave-saasP2Presentation cost is not bounded by credential count or concurrency
SA11trustweave-saasP2Undeserializable subscription events are permanently acknowledged
SA12trustweave-saasP1Frontend dependency graph includes vulnerable spreadsheet parsing
SA13trustweave-saasP2CI does not yet validate the remediated SDK revision
SA14trustweave-saasP2Token creation ignores revoked/redeemed offer status

Evidence and acceptance criteria

P1TW01trustweave · Security / misleading UX

Credential import accepts invalid issuer signatures

Reproduced: wallet probe 1

Observed behavior and impact

Import only decodes credential metadata and checks the claimed subject. A corrupted signature was accepted and its attacker-controlled name became the preview. The wallet describes its library as verified credentials, but import establishes neither issuer authenticity nor time/status validity. This is a wallet trust/UI failure; it does not prove a server verifier accepts the forgery.

Required improvement

Verify supported issuer proofs and holder binding before marking an import verified. If offline import is intentional, quarantine it as unverified and prevent verified-looking previews until checks finish.

Acceptance criteria

Negative tests reject tampered signatures, altered payloads and unsupported algorithms; an offline import has an explicit unverified state.

reference-wallet/lib/wallet.ts:288
286: }
287: 
288: function extractVcJwtMeta(vcJwt: string): CredentialMeta {
289:   const parts = vcJwt.split('.')
290:   if (parts.length !== 3) throw new Error('VC-JWT must have three parts')
291:   const payload = JSON.parse(b64uDecodeString(parts[1])) as Record<string, unknown>
292:   const vc = payload.vc as Record<string, unknown> | undefined
293:   const issuerDid = String(payload.iss ?? vc?.issuer ?? '')
294:   const subjectDid = String(payload.sub ?? '')
295:   const t = vc?.type

Open current source file · SHA-256: 9e8bdd4ed6400b1f…

reference-wallet/lib/wallet.ts:139
137:   }
138:   if (!isCredentialBoundToHolder(cred, holder.did)) throw new Error("Credential was not issued to this wallet")
139:   const result = upsertCredential(cred)
140:   pruneStaleCredentialsForBusinessIdentity(cred)
141:   // Never delete user data merely by opening the wallet; selection enforces holder binding.
142:   if (!isCredentialBoundToHolder(result.credential, holder.did)) {
143:     deleteCredFromStorage(result.credential.id)
144:     throw new Error('Credential was not issued to this wallet. Scan the issuer QR again.')
145:   }
146:   return result

Open current source file · SHA-256: 9e8bdd4ed6400b1f…

P1TW02trustweave · Cryptographic correctness

Decrypting a claim creates a disclosure the issuer never signed

Reproduced: wallet probe 3

Observed behavior and impact

The presentation path decrypts an encrypted SD-JWT claim and creates a fresh disclosure, including a new salt. Its digest no longer matches the issuer-signed _sd entry. The local digest probe confirms the mismatch, and the SaaS demo verifier checks exactly that membership. Sharing encrypted photos/claims therefore produces an invalid presentation.

Required improvement

Preserve the exact issuer-committed disclosure. Design a reviewed encrypted-claim protocol, or have the issuer commit to a plaintext disclosure using a scheme that preserves confidentiality and verification. Do not bypass digest validation at the verifier.

Acceptance criteria

An issuer-to-wallet-to-verifier test successfully shares an encrypted claim while retaining issuer integrity; changing the revealed value is rejected.

reference-wallet/lib/wallet.ts:259
257:     if (isClaimJwePayload(value)) {
258:       const plaintext = await decryptClaimJwe(value, args.holderDid)
259:       selected.push(buildPlaintextDisclosure(name, plaintext))
260:     } else {
261:       selected.push(d)
262:     }
263:   }
264: 
265:   const prefix = [issuerJwt, ...selected, ''].join('~')
266:   const sdHash = b64uEncode(sha256(new TextEncoder().encode(prefix)))

Open current source file · SHA-256: 9e8bdd4ed6400b1f…

reference-wallet/lib/claim-jwe.ts:73
71: /** Build a presentation disclosure with decrypted plaintext (Option A). */
72: export function buildPlaintextDisclosure(claimName: string, plaintext: string): string {
73:   return createObjectDisclosure(claimName, plaintext).disclosure
74: }
75: 
76: export { b64uEncodeString }

Open current source file · SHA-256: 112ad0f031a17ad5…

Primary references: rfc9901.html

P2TW03trustweave · Privacy / API contract

Multiple SD-JWT credentials fall into the full-disclosure path

Reproduced: wallet probe 2

Observed behavior and impact

createPresentation accepts an array but only special-cases exactly one SD-JWT. With two SD-JWTs it emits a VP-JWT containing the complete stored strings and ignores the requested disclosure selection. A probe with an empty selection still emitted both complete credentials. The current page selects one credential, so this is a reachable library API bug rather than the normal page flow.

Required improvement

Reject mixed/multiple SD-JWT inputs until a composable envelope is implemented. Validate every selected credential and reject missing IDs rather than silently shrinking the request.

Acceptance criteria

Multiple/mixed format inputs fail explicitly, or retain per-credential disclosure selections in a verified envelope.

reference-wallet/lib/wallet.ts:209
207: 
208:   // SD-JWT VC: spec-compliant single-credential path with KB-JWT.
209:   if (creds.length === 1 && creds[0].format === 'vc+sd-jwt') {
210:     assertCredentialBoundToHolder(creds[0].credential, creds[0].format, holder.did)
211:     return presentSdJwtVcWithDecryption({
212:       sdJwtVc: creds[0].credential,
213:       selectDisclose: disclose,
214:       holderDid: holder.did,
215:       audience: verifierUri,
216:       nonce: challenge,

Open current source file · SHA-256: 9e8bdd4ed6400b1f…

reference-wallet/lib/wallet.ts:233
231:       type: ['VerifiablePresentation'],
232:       holder: holder.did,
233:       verifiableCredential: creds.map((c) => c.credential),
234:     },
235:   }
236:   return signHolderJws(payload, holder.did)
237: }
238: 
239: /** Present SD-JWT VC; decrypts JWE claim values to plaintext when sharing (Option A). */
240: async function presentSdJwtVcWithDecryption(args: {

Open current source file · SHA-256: 9e8bdd4ed6400b1f…

P2TW04trustweave · Reliability / status semantics

Cloud listing hides storage and status-resolution errors

Reproduced: cloud outage probe; status/cancellation paths source-confirmed

Observed behavior and impact

CloudWallet catches every Exception inside its download/decode/filter loop. A storage authorization failure was reproduced as a successful empty listing. The new offline status filter also throws UNKNOWN-related errors inside this catch, so cloud callers receive missing records instead of the promised explicit status failure. Cancellation is also caught.

Required improvement

Propagate cancellation, transport/authentication failures and unknown-status errors. If partial corrupt-record recovery is supported, return a structured partial result with failed record handles.

Acceptance criteria

Authorization failures and UNKNOWN status cannot become an empty successful list; corrupt-record recovery includes diagnostics.

wallet/plugins/cloud/src/main/kotlin/org/trustweave/wallet/cloud/CloudWallet.kt:136
134:                         }
135:                     } catch (e: Exception) {
136:                         // Skip corrupted files
137:                     }
138:                 }
139: 
140:             credentials
141:         }
142: 
143:     override suspend fun delete(credentialId: String): Boolean =

Open current source file · SHA-256: 991e070275605034…

P2TW05trustweave · Storage API completeness

Anonymous cloud credentials still lose their discoverable storage handle

Source-confirmed

Observed behavior and impact

CloudWallet generates a random ID for an anonymous credential but returns unchanged credentials from list and does not implement CredentialRecordStorage. After the store result is lost, callers cannot recover its handle through the wallet API to delete or address it. The earlier fix covered file/database providers only.

Required improvement

Implement stored-record enumeration for cloud providers using object keys/sidecars and preserve signed credential bytes. Publish capability differences until providers are aligned.

Acceptance criteria

An anonymous credential can be stored, listed after reopening, and deleted using only the public record API.

wallet/plugins/cloud/src/main/kotlin/org/trustweave/wallet/cloud/CloudWallet.kt:85
83:     override suspend fun store(credential: VerifiableCredential): String =
84:         withContext(Dispatchers.IO) {
85:             val id = credential.id?.value ?: UUID.randomUUID().toString()
86:             val credentialJson = json.encodeToString(VerifiableCredential.serializer(), credential)
87: 
88:             val key = "$credentialsPath/$id.json"
89:             upload(key, credentialJson.toByteArray(Charsets.UTF_8))
90: 
91:             // Initialize metadata if not exists
92:             val metadataKey = "$metadataPath/$id.json"

Open current source file · SHA-256: 991e070275605034…

wallet/plugins/cloud/src/main/kotlin/org/trustweave/wallet/cloud/CloudWallet.kt:119
117:         }
118: 
119:     override suspend fun list(filter: CredentialFilter?): List<VerifiableCredential> =
120:         withContext(Dispatchers.IO) {
121:             val credentials = mutableListOf<VerifiableCredential>()
122: 
123:             val keys = listKeys(credentialsPath)
124:             keys
125:                 .filter { it.endsWith(".json") }
126:                 .forEach { key ->

Open current source file · SHA-256: 991e070275605034…

P2TW06trustweave · Scalability

Database queries now materialize every credential

Source-confirmed

Observed behavior and impact

Removing the former 1,000-row cap fixed missing results, but list/query still deserialize the entire matching wallet dataset before applying most filters. Narrow issuer/type/status queries and statistics become proportional to total wallet size and memory. This is an unresolved scalability cost, not a return of the truncation defect.

Required improvement

Introduce a stable cursor/page API, push indexed predicates into SQL, and separate streaming/batched status resolution from materialization. Keep legacy List behavior explicit.

Acceptance criteria

Large-wallet benchmarks establish memory/latency budgets; pagination has no gaps/duplicates and filtered queries avoid full JSON scans.

wallet/plugins/database/src/main/kotlin/org/trustweave/wallet/database/DatabaseWallet.kt:404
402:                                 while (rs.next()) {
403:                                     val credentialJson = rs.getString("credential_data")
404:                                     rawResults.add(json.decodeFromString(VerifiableCredential.serializer(), credentialJson))
405:                                 }
406:                             }
407:                         }
408:                 }
409: 
410:                 // The complete ordered result is filtered; the List API never silently truncates.
411:                 if (filter == null) rawResults else rawResults.filter { matchesFilter(it, filter) }

Open current source file · SHA-256: d1e57e1d52686041…

wallet/plugins/database/src/main/kotlin/org/trustweave/wallet/database/DatabaseWallet.kt:411
409: 
410:                 // The complete ordered result is filtered; the List API never silently truncates.
411:                 if (filter == null) rawResults else rawResults.filter { matchesFilter(it, filter) }
412:             } catch (e: Exception) {
413:                 if (e is CancellationException) throw e
414:                 throw WalletException.StorageError(
415:                     operation = "list",
416:                     reason = "Failed to list credentials: ${e.message}",
417:                     cause = e,
418:                 )

Open current source file · SHA-256: d1e57e1d52686041…

P2TW07trustweave · Data integrity / observability

File listing presents partial recovery as a complete result

Source-confirmed

Observed behavior and impact

FileWallet logs and skips unreadable records, while list returns an ordinary List and statistics count that partial list. A wrong key or damaged files can therefore resemble an empty/smaller wallet, whereas listRecords fails on missing or malformed sidecars. The two APIs have inconsistent recovery semantics.

Required improvement

Define a common partial-read contract exposing failed handles and reasons. Make statistics indicate incomplete data and give callers a strict read mode.

Acceptance criteria

One corrupted credential or wrong decryption key produces an explicit incomplete/error result, with healthy records available through a deliberate recovery path.

wallet/plugins/file/src/main/kotlin/org/trustweave/wallet/file/FileWallet.kt:284
282:                         } catch (e: Exception) {
283:                             // Skip corrupted/unreadable files, but record why so failures are diagnosable.
284:                             logger.warn("Skipping credential file that could not be read: {}", file, e)
285:                         }
286:                     }
287:             }
288: 
289:             credentials.filter { filter == null || matchesFilter(it, filter) }
290:         }
291: 

Open current source file · SHA-256: 0e5265713e0f6016…

wallet/plugins/file/src/main/kotlin/org/trustweave/wallet/file/FileWallet.kt:420
418:                     if (filter != null && !matchesFilter(credential, filter)) return@mapNotNull null
419:                     // Legacy anonymous records have a random handle retained in the metadata sidecar.
420:                     val metadataBytes = readBytes(metadataDir.resolve(path.fileName))
421:                     val metadata =
422:                         json
423:                             .parseToJsonElement(
424:                                 if (secretKey !=
425:                                     null
426:                                 ) {
427:                                     decrypt(metadataBytes)

Open current source file · SHA-256: 0e5265713e0f6016…

P2TW08trustweave · Concurrency

Atomic replacement does not coordinate delete and metadata

Source-confirmed

Observed behavior and impact

The read/replace lock protects individual credential byte replacement, but delete does not acquire it and deletes the credential and sidecar separately. A delete can remove the old credential, a concurrent store can observe the old metadata and write a new credential, then delete removes that metadata. listRecords subsequently requires the missing sidecar. Individual atomic moves do not make the record operation atomic.

Required improvement

Coordinate store/delete/metadata initialization under a common per-record lock and define multi-process locking or journal/recovery semantics. Rebuild metadata only where the handle can be recovered safely.

Acceptance criteria

A deterministic interleaving test leaves either a complete record or no record, never an orphan that breaks record enumeration.

wallet/plugins/file/src/main/kotlin/org/trustweave/wallet/file/FileWallet.kt:214
212:             // scheme as the credential file whenever an encryption key is configured.
213:             val metadataFile = resolveDataFile(metadataDir, id)
214:             if (!Files.exists(metadataFile)) {
215:                 val metadata =
216:                     buildJsonObject {
217:                         put("credentialId", id)
218:                         put("createdAt", Clock.System.now().toString())
219:                         put("updatedAt", Clock.System.now().toString())
220:                         put("notes", JsonNull)
221:                         put("tags", buildJsonArray { })

Open current source file · SHA-256: 0e5265713e0f6016…

wallet/plugins/file/src/main/kotlin/org/trustweave/wallet/file/FileWallet.kt:295
293:         withContext(Dispatchers.IO) {
294:             val credentialFile = resolveDataFile(credentialsDir, credentialId)
295:             val deleted = Files.deleteIfExists(credentialFile)
296: 
297:             if (deleted) {
298:                 // Clean up related files
299:                 Files.deleteIfExists(resolveDataFile(metadataDir, credentialId))
300:                 Files.deleteIfExists(resolveDataFile(tagsDir, credentialId))
301:             }
302: 

Open current source file · SHA-256: 0e5265713e0f6016…

P2TW09trustweave · Browser data integrity

Credential writes are not serialized across tabs

Source-confirmed

Observed behavior and impact

Credential upsert and deletion perform read-modify-write on one localStorage array. The new navigator lock only protects holder initialization. Two tabs can read the same array and overwrite each other’s additions/deletions; reset can race with import or signing and leave keys/metadata inconsistent.

Required improvement

Move credential records into IndexedDB transactions and serialize identity reset with signing/import operations. Coordinate tabs and surface stale-state changes.

Acceptance criteria

Two real tabs concurrently importing distinct credentials retain both; reset/import races leave a consistent recoverable state.

reference-wallet/lib/storage.ts:101
99: 
100: /** Insert or replace a credential with the same logical identity (issuer, subject, type, and stable claims). */
101: export function upsertCredential(cred: StoredCredential): { credential: StoredCredential; replaced: boolean } {
102:   const all = loadCredentials()
103:   const key = credentialDedupKey(cred)
104:   const idx = all.findIndex((c) => credentialDedupKey(c) === key)
105:   if (idx >= 0) {
106:     const updated: StoredCredential = {
107:       ...all[idx],
108:       ...cred,

Open current source file · SHA-256: c51184de067e647c…

reference-wallet/lib/storage.ts:122
120: 
121: export function deleteCredential(id: string): void {
122:   const all = loadCredentials().filter((c) => c.id !== id)
123:   saveCredentials(all)
124: }
125: 
126: export function resetWallet(): void {
127:   if (!isBrowser()) return
128:   window.localStorage.removeItem(HOLDER_KEY)
129:   window.localStorage.removeItem(CREDENTIALS_KEY)

Open current source file · SHA-256: c51184de067e647c…

reference-wallet/lib/wallet.ts:53
51: /** Bootstrap. Idempotent — generates a holder identity on first run. */
52: export async function bootstrap(): Promise<WalletState> {
53:   const holder = await (holderInitialization ??= (navigator.locks ? navigator.locks.request('trustweave-holder-init', loadOrCreateHolder) : loadOrCreateHolder()).finally(() => { holderInitialization = undefined }))
54:   // Never delete user data merely by opening the wallet; selection enforces holder binding.
55:   return { holder, credentials: loadCredentials() }
56: }
57: 
58: async function loadOrCreateHolder(): Promise<HolderIdentity> {
59:   const existing = loadHolder()
60:   if (existing) {

Open current source file · SHA-256: 9e8bdd4ed6400b1f…

P2TW10trustweave · Recovery UX

Current-schema data is cast without runtime validation

Source-confirmed

Observed behavior and impact

loadCredentials casts parsed JSON to StoredCredential[] and schema migration validates little beyond id and compact credential text. Valid JSON such as an object, or records missing preview/type arrays, can pass storage loading and fail during rendering instead of entering WalletRecovery. bootstrap can also migrate holder keys before discovering malformed credential JSON.

Required improvement

Validate the complete stored schema before migration or key mutation. Route invalid data into recovery without rewriting it; migrate supported record shapes explicitly.

Acceptance criteria

Current-version object roots, null records, missing previews and malformed holder metadata all open the recovery UI and preserve the original bytes.

reference-wallet/lib/storage.ts:85
83:   ensureSchemaVersion()
84:   const raw = window.localStorage.getItem(CREDENTIALS_KEY)
85:   return raw ? (JSON.parse(raw) as StoredCredential[]) : []
86: }
87: 
88: export function saveCredentials(creds: StoredCredential[]): void {
89:   if (!isBrowser()) throw new Error('saveCredentials requires a browser environment')
90:   ensureSchemaVersion()
91:   window.localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(creds))
92: }

Open current source file · SHA-256: c51184de067e647c…

reference-wallet/lib/storage.ts:57
55:     const migrated = credentials.map((credential: Record<string, unknown>) => {
56:       const compact = credential.credential ?? credential.vcJwt
57:       if (typeof compact !== 'string' || typeof credential.id !== 'string') throw new Error('Unsupported legacy credential. Your original data has been preserved.')
58:       return { ...credential, credential: compact, format: credential.format ?? 'vc+jwt', selectivelyDisclosable: credential.selectivelyDisclosable ?? [] }
59:     })
60:     // Save version last: an interrupted upgrade can be safely repeated. Never rotate the holder.
61:     window.localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(migrated))
62:     window.localStorage.setItem(VERSION_KEY, String(CURRENT_VERSION))
63:   } else if (existing !== String(CURRENT_VERSION)) {
64:     throw new Error(`Wallet schema ${existing} is not supported by this version. Open the version that created it or export your credentials.`)

Open current source file · SHA-256: c51184de067e647c…

P2TW11trustweave · Capability architecture

Capability checks and the catalog remain separate sources of truth

Source-confirmed

Observed behavior and impact

The JSON catalog covers seven modules. PluginRegistry checks requiredCapabilities only when the plugin’s own configuration supplies them, against its own feature list; it does not consult the catalog or require an application-level capability request. Only selected integration paths call requireOperations. Generated documentation therefore does not prove every startup path fails early for a stub.

Required improvement

Use one typed capability model in registry/factories and docs. Let the application declare required operations/formats, validate before constructing providers, and enumerate unassessed modules explicitly.

Acceptance criteria

Every public factory/provider path for assessed stubs fails before use; catalog and runtime metadata cannot drift; unassessed capabilities never imply support.

common/src/main/kotlin/org/trustweave/core/plugin/PluginRegistry.kt:203
201:             }
202: 
203:             val required = metadata.configuration["requiredCapabilities"]
204:             if (required != null) {
205:                 require(
206:                     required is Collection<*> && required.all { it is String },
207:                 ) { "requiredCapabilities must be a collection of feature names" }
208:                 require(
209:                     metadata.maturity != PluginMaturity.STUB &&
210:                         metadata.capabilities.features.containsAll(required.filterIsInstance<String>()),

Open current source file · SHA-256: 7711bc05cfff25ec…

common/src/main/resources/trustweave-capabilities.json:3
1: {
2:   "anchors:plugins:starknet": {"maturity":"stub", "operations":[], "formats":[]},
3:   "did:plugins:threebox": {"maturity":"stub", "operations":[], "formats":[]},
4:   "did:plugins:tezos": {"maturity":"stub", "operations":[], "formats":[]},
5:   "did:plugins:btcr": {"maturity":"stub", "operations":[], "formats":[]},
6:   "credentials:plugins:oidc4vci": {"maturity":"experimental", "operations":["receive", "deferred-receive"], "formats":["ldp_vc"]},
7:   "wallet:plugins:file": {"maturity":"experimental", "operations":["store", "get", "list", "list-records", "delete", "query"], "formats":["json-vc"]},
8:   "wallet:plugins:database": {"maturity":"experimental", "operations":["store", "get", "list", "list-records", "delete", "query", "tags", "collections"], "formats":["json-vc"]}
9: }

Open current source file · SHA-256: 3c7f58a7d60838e1…

P2TW12trustweave · Testing / custody evidence

Browser custody and cross-profile flows lack end-to-end coverage

Coverage gap; no hardware-custody requirement inferred for a demo

Observed behavior and impact

The new six-test gate is useful but runs with Node WebCrypto and fake IndexedDB. It does not exercise browser CryptoKey persistence/reopen behavior, multiple tabs, encrypted disclosure verification, consent UI, or a SaaS round trip. The new probes found issues despite the gate. Non-extractable keys do not themselves provide user verification or prevent same-origin scripts from signing.

Required improvement

Add browser tests for the supported demo scope and real issuer/holder/verifier contracts. Document the production custody boundary; only require hardware/user-presence features if production wallet scope is adopted.

Acceptance criteria

Clean browser profiles can issue, import, reopen, select claims, share and recover with real signatures. Cross-profile incompatibilities are tested and clearly presented.

reference-wallet/vitest.config.ts:3
1: import { defineConfig } from 'vitest/config'
2: 
3: export default defineConfig({ test: { include: ['tests/**/*.test.ts'], environment: 'node' } })

Open current source file · SHA-256: 1142a51d09c747b8…

reference-wallet/tests/custody.test.ts:3
1: import { beforeEach, describe, expect, it, vi } from 'vitest'
2: import { webcrypto } from 'node:crypto'
3: import 'fake-indexeddb/auto'
4: import { generateEd25519KeyPair, publicKeyToDidKey, b64uEncode, verifyJws, signJws } from '../lib/crypto'
5: import { clearHolderKeys, importHolderKeys, loadHolderKeys, signHolderJws } from '../lib/key-store'
6: import { loadCredentials, loadHolder, exportWalletData } from '../lib/storage'
7: import { bootstrap, store, createPresentation } from '../lib/wallet'
8: 
9: class MemoryStorage {
10:   values = new Map<string, string>()

Open current source file · SHA-256: 32795ca61cd5c1de…

reference-wallet/lib/key-store.ts:69
67:   const header = { alg: 'EdDSA', typ, kid: `${did}#${did.slice('did:key:'.length)}` }
68:   const input = `${b64uEncodeString(JSON.stringify(header))}.${b64uEncodeString(JSON.stringify(payload))}`
69:   const signature = await crypto.subtle.sign('Ed25519', signing, new TextEncoder().encode(input))
70:   return `${input}.${b64uEncode(new Uint8Array(signature))}`
71: }
72: 
73: export async function holderSharedSecret(did: string, publicKey: string): Promise<Uint8Array> {
74:   const { agreement } = await loadHolderKeys(did)
75:   const publicCryptoKey = await crypto.subtle.importKey('raw', b64uDecode(publicKey), 'X25519', false, [])
76:   return new Uint8Array(await crypto.subtle.deriveBits({ name: 'X25519', public: publicCryptoKey }, agreement, 256))

Open current source file · SHA-256: b0f3bd82cab702ca…

P1TW13trustweave · Dependency maintenance

Reference-wallet dependency graph has unresolved advisories

Fresh npm audit; exploitability requires feature-specific triage

Observed behavior and impact

Fresh npm audit reported 6 affected package entries: 2 critical, 2 high and 2 moderate. next is pinned to 14.2.5 and vitest to the 1.x line. Audit severity is not proof that every advisory is reachable: several concern optional framework features or a running development/UI server, and no exploit was attempted.

Required improvement

Triage every advisory against actual enabled features, update to maintained patched versions, regenerate the lockfile and add an advisory policy. Do not assume the audit’s suggested patch resolves every listed advisory.

Acceptance criteria

No unaccepted reachable high/critical issue remains; build/test/browser checks pass on the chosen versions; documented exceptions have owners and expiry.

reference-wallet/package.json:23
21:     "html5-qrcode": "^2.3.8",
22:     "jose": "5.6.3",
23:     "next": "14.2.5",
24:     "react": "18.3.1",
25:     "react-dom": "18.3.1",
26:     "react-qr-code": "^2.0.15"
27:   },
28:   "devDependencies": {
29:     "@types/node": "20.14.10",
30:     "@types/react": "18.3.3",

Open current source file · SHA-256: 0802fbfdac94bcbc…

reference-wallet/package.json:34
32:     "fake-indexeddb": "^6.2.4",
33:     "typescript": "5.5.3",
34:     "vitest": "^1.6.1"
35:   },
36:   "engines": {
37:     "node": ">=20.0.0"
38:   }
39: }

Open current source file · SHA-256: 0802fbfdac94bcbc…

Primary references: GHSA-7m27-7ghc-44w9, GHSA-5xrq-8626-4rwp

P1SA01trustweave-saas · Confidentiality

Public legacy redemption exposes credentials without proof of possession

Reproduced: SaaS two-step controller probe

Observed behavior and impact

Given a known credential ID, a request with the wrong subject gets expectedSubjectDid in the response. A second request using that DID returns the full raw credential. The controller performs no holder signature or authenticated ownership check. The two-step controller probe reproduced this. IDs may be unguessable, but identifiers already shared in credentials must not double as permanent download secrets.

Required improvement

Remove ID-only legacy redemption or require a separate expiring single-use capability plus holder proof. Do not reveal the expected holder to an unauthorized caller. Apply the same policy to all redemption paths.

Acceptance criteria

Knowledge of credential ID and holder DID alone never returns a raw credential; legacy migration has a scoped, expiring proof-bound route.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/controller/PublicCredentialOfferController.kt:62
60: 
61:         // Legacy: credential was issued before the holder scanned (direct issuance + offer QR).
62:         val cred = issuedCredentialRepository.findByCredentialId(code)
63:             ?: return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
64:                 mapOf("error" to "Unknown or expired offer code"),
65:             )
66: 
67:         if (cred.subjectDid != body.subjectDid) {
68:             return ResponseEntity.status(HttpStatus.FORBIDDEN).body(
69:                 mapOf(

Open current source file · SHA-256: 3c0d7d2974429531…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/controller/PublicCredentialOfferController.kt:72
70:                     "error" to "This credential offer is bound to a different holder. " +
71:                         "Re-issue the credential in the issuer portal with subject ${body.subjectDid}.",
72:                     "expectedSubjectDid" to cred.subjectDid,
73:                 ),
74:             )
75:         }
76: 
77:         val now = Instant.now()
78:         if (cred.status == CredentialStatus.REVOKED) {
79:             return ResponseEntity.status(HttpStatus.GONE).body(mapOf("error" to "Credential has been revoked"))

Open current source file · SHA-256: 3c0d7d2974429531…

P1SA02trustweave-saas · Issuance concurrency

An offer can be redeemed concurrently through multiple grants

Source-confirmed

Observed behavior and impact

redeemOffer reads PENDING, performs issuance, then saves REDEEMED without an offer version, row claim or conditional transition. Token consumption is atomic per token, but /oauth/token can issue multiple tokens for one offer; a legacy endpoint also calls redemption directly. Two callers can both issue credentials for one invitation. No production race was executed; the reachable interleaving is established by the source.

Required improvement

Claim the offer atomically before issuance and make issuance idempotent for that offer. Bind grant creation to offer lifecycle and define retry/recovery for signing failures.

Acceptance criteria

Two different holder proofs/tokens for one offer result in exactly one issuance and one stable winner, including through legacy and OID4VCI paths.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/services/CredentialOfferService.kt:263
261:     ): Result<IssuedCredential> {
262: 
263:         val offer = credentialOfferRepository.findByOfferCode(offerCode.trim())
264: 
265:             ?: return Result.failure(IllegalArgumentException("Unknown or expired offer code"))
266: 
267: 
268: 
269:         val now = Instant.now()
270: 

Open current source file · SHA-256: a20185c6ddf3fad4…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/services/CredentialOfferService.kt:309
307:         val issued = runBlocking {
308: 
309:             credentialIssuanceService.issue(
310: 
311:                 trustSpaceId = offer.trustSpaceId,
312: 
313:                 organizationId = offer.organizationId,
314: 
315:                 createdBy = redeemedByUserId ?: offer.createdBy,
316: 

Open current source file · SHA-256: a20185c6ddf3fad4…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vci/Oid4VciControllers.kt:89
87:         }
88: 
89:         val grant = accessTokenStore.issue(offer.offerCode, offer.trustSpaceId)
90:         logger.info("Issued OID4VCI access token for offer {}", offer.offerCode)
91:         return ResponseEntity.ok(
92:             TokenResponse(
93:                 accessToken = grant.token,
94:                 expiresIn = TOKEN_TTL_SECONDS,
95:                 cNonce = grant.cNonce,
96:                 cNonceExpiresIn = TOKEN_TTL_SECONDS,

Open current source file · SHA-256: 51366e9385823a9c…

P1SA03trustweave-saas · Authentication

azp bypasses the intended API audience requirement

Reproduced: validator probe

Observed behavior and impact

AudienceValidator accepts a JWT whose aud targets another API if azp matches the configured client. A direct validator probe confirms this. azp identifies the requesting/authorized client; it is not a replacement for resource audience. A valid realm-signed token is still required, so this is token-confusion exposure rather than unsigned-token acceptance. Blank audience configuration also disables this check entirely.

Required improvement

Require the API audience in aud, configure the Keycloak audience mapper, validate token purpose where appropriate, and fail startup for missing production audience configuration.

Acceptance criteria

A realm-signed token for another audience is rejected even with matching azp; the correct API token succeeds.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/security/AudienceValidator.kt:25
23:         val audienceMatches = jwt.audience?.contains(expectedAudience) == true
24:         val azpMatches = jwt.getClaimAsString("azp") == expectedAudience
25:         return if (audienceMatches || azpMatches) {
26:             OAuth2TokenValidatorResult.success()
27:         } else {
28:             OAuth2TokenValidatorResult.failure(error)
29:         }
30:     }
31: }

Open current source file · SHA-256: 7f36a455df9a5f2e…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/security/KeycloakSecurityConfig.kt:113
111:             logger.info("JWT audience validation enabled for audience '{}'", expectedAudience)
112:         } else {
113:             logger.warn("JWT audience validation DISABLED (security.jwt.expected-audience is blank). Set it in production.")
114:         }
115:         decoder.setJwtValidator(DelegatingOAuth2TokenValidator(validators))
116:         return decoder
117:     }
118: 
119:     @Bean
120:     fun keycloakJwtAuthenticationConverter(): JwtAuthenticationConverter = KeycloakJwtAuthenticationConverter()

Open current source file · SHA-256: 34524bff5ee63135…

Primary references: rfc8725, token-exchange

P2SA04trustweave-saas · Verification correctness

Adding a trusted issuer changes disabled checks back to enabled

Source-confirmed

Observed behavior and impact

The no-trust branch passes explicit policy booleans to TrustWeave.verify. The allowlist branch uses the DSL and only enables flags conditionally, although VerificationBuilder defaults both revocation and expiration to true. Standard policy can therefore change behavior merely because an issuer is configured, contradicting its checkRevocation=false/checkExpiration=false policy.

Required improvement

Set both true and false branches explicitly with check/skip methods, or use one verification-options path for all policies.

Acceptance criteria

Parameterized tests capture actual verification options for Basic/Standard/Strict with empty/nonempty allowlists; flags always match the declared policy.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:79
77:         return trustWeave.verify {
78:             credential(vc)
79:             if (policy.checkRevocation) checkRevocation()
80:             if (policy.checkExpiration) checkExpiration()
81:             withTrustPolicy(allowlist)
82:         }
83:     }
84: 
85:     /**
86:      * Verify a Verifiable Presentation.

Open current source file · SHA-256: 069526bef9d7ac18…

trustweave/trust/src/main/kotlin/org/trustweave/trust/dsl/credential/VerificationBuilder.kt:43
43:     private var checkRevocation: Boolean = true
44:     private var checkExpiration: Boolean = true
45:     private var validateSchema: Boolean = false
46:     private var schemaId: String? = null
47:     private var validateProofPurpose: Boolean = false
48:     private var trustPolicy: CredentialTrustPolicy? = null

Open current source file · SHA-256: 44f6d10f971cfd62…

P1SA05trustweave-saas · Trust-result accuracy

Unchecked expiry and revocation are reported as passing checks

Source-confirmed

Observed behavior and impact

toResponse receives only trustEvaluated, not the policy or actual executed-check set. Any VerificationResult.Valid produces Expiration=true and Revocation=true with “Not revoked”, including paths that disabled these checks. A user or API consumer can mistake a signature-only result for current credential validity.

Required improvement

Represent passed/failed/not-evaluated/unknown explicitly and map results from actual executed checks. Ensure overall status communicates policy-limited verification.

Acceptance criteria

Disabled revocation/expiry render as not evaluated; an offline/unknown status cannot become a green “Not revoked” result.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:355
353:      *   false, the "Issuer trust" check is reported honestly as "not evaluated" instead of implying trust.
354:      */
355:     private fun toResponse(result: VerificationResult, trustEvaluated: Boolean): VerificationResultResponse {
356:         val checks = mutableListOf<VerificationCheck>()
357:         val errors = result.allErrors
358:         val warnings = result.allWarnings
359: 
360:         when (result) {
361:             is VerificationResult.Valid -> {
362:                 checks += VerificationCheck("Proof", true, "Cryptographic signature verified")

Open current source file · SHA-256: 069526bef9d7ac18…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:367
365:                 // Revocation was requested (checkRevocation()); a revoked credential would have produced
366:                 // Invalid.Revoked. So a Valid result genuinely means "not revoked" — not a hardcoded pass.
367:                 checks += VerificationCheck("Revocation", true, "Not revoked")
368:                 checks += if (trustEvaluated) {
369:                     VerificationCheck("Issuer trust", true, "Issuer ${result.issuerIri.value} is an active trust anchor")
370:                 } else {
371:                     VerificationCheck("Issuer trust", true, "Not evaluated (no organization trust context)")
372:                 }
373:             }
374:             is VerificationResult.Invalid.InvalidProof ->

Open current source file · SHA-256: 069526bef9d7ac18…

P1SA06trustweave-saas · Trust policy

Strict verification silently relaxes issuer trust when the list is empty

Reproduced: policy probe; downstream acceptance source-confirmed

Observed behavior and impact

An empty issuer list produces checkIssuerTrust=false even at STRICT. The policy marks trustNotEvaluated, but verification still runs without an allowlist and can return valid; the response represents issuer trust as a passed check with a note. The policy probe reproduced the disabled flag. A deployment configuring Strict can unintentionally accept any otherwise-valid issuer until trust configuration exists.

Required improvement

Make missing required trust configuration a distinct not-ready/rejected outcome at Strict. If permissive bootstrap is desired, require an explicit named policy rather than deriving it from an empty list.

Acceptance criteria

Strict plus no trusted issuers cannot produce a VERIFIED decision; setup and result screens explain what is missing.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/verification/VerificationPolicy.kt:47
45:     ): PolicyChecks {
46:         val wantsTrust = level != VerificationLevel.BASIC
47:         val canEvaluateTrust = wantsTrust && trustedIssuers.isNotEmpty()
48:         val strict = level == VerificationLevel.STRICT
49: 
50:         return PolicyChecks(
51:             level = level,
52:             trustModel = trustModel,
53:             // A credential whose signature does not verify is not a credential, at any level.
54:             checkSignature = true,

Open current source file · SHA-256: 0d02e3fe6930f208…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:371
369:                     VerificationCheck("Issuer trust", true, "Issuer ${result.issuerIri.value} is an active trust anchor")
370:                 } else {
371:                     VerificationCheck("Issuer trust", true, "Not evaluated (no organization trust context)")
372:                 }
373:             }
374:             is VerificationResult.Invalid.InvalidProof ->
375:                 checks += VerificationCheck("Proof", false, result.reason)
376:             is VerificationResult.Invalid.Expired ->
377:                 checks += VerificationCheck("Expiration", false, "Expired at ${result.expiredAt}")
378:             is VerificationResult.Invalid.NotYetValid ->

Open current source file · SHA-256: 069526bef9d7ac18…

P2SA07trustweave-saas · Privacy / interoperability

The request requires selective disclosure while accepting whole JSON credentials

Source-confirmed

Observed behavior and impact

Requests advertise limit_disclosure=required when claims are selected, but the supported production profile accepts whole JSON VCs and over-disclosure is only logged. Ordinary signed JSON credentials cannot simply remove extra fields without invalidating their proof. Request metadata promises stronger privacy semantics than the implementation enforces, and the reference demo uses a different protocol.

Required improvement

Align advertised constraints with a genuinely supported selective-disclosure profile, or make minimization a non-required preference with explicit holder consent. Publish an executable supported-wallet contract.

Acceptance criteria

A requested subset can be shared and verified without extra claims, or the UI and request clearly state that full disclosure is required; no false required guarantee remains.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/VerificationRequestService.kt:129
127:             val constraints = buildMap<String, Any?> {
128:                 if (fields.isNotEmpty()) put("fields", fields)
129:                 if (session.requestedClaims.isNotEmpty()) put("limit_disclosure", "required")
130:             }
131: 
132:             mapOf(
133:                 "id" to type,
134:                 "name" to type,
135:                 "purpose" to (session.purpose ?: "Confirm this credential"),
136:                 "constraints" to constraints,

Open current source file · SHA-256: cc7211f9a9a1de47…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/VerificationRequestService.kt:210
208:         val unrequested = if (session.requestedClaims.isEmpty()) emptyList() else disclosed - session.requestedClaims.toSet()
209:         if (unrequested.isNotEmpty()) {
210:             // Not a failure: the presentation is valid and the holder's wallet chose to send more
211:             // than was asked for. Worth saying out loud, because the verifier is now holding data
212:             // it has no reason to keep.
213:             logger.warn(
214:                 "Presentation for {} disclosed {} which were not requested",
215:                 session.id,
216:                 unrequested,
217:             )

Open current source file · SHA-256: cc7211f9a9a1de47…

P2SA08trustweave-saas · Scalability / retention

Verification history causes unbounded N+1 read/write work

Source-confirmed

Observed behavior and impact

listFor loads all sessions and calls get for every row. get performs an expiration UPDATE and another SELECT, so history grows to roughly 1+2N statements and unbounded response size, including terminal rows. No retention/page boundary appears in this flow.

Required improvement

Expire pending rows in a bounded set-based operation and return paginated tenant-scoped history. Define retention for holder identifiers, claim names and detailed results.

Acceptance criteria

A large history request has a bounded page and bounded statement count; expired rows and pagination are consistent under concurrent completion.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/VerificationRequestService.kt:295
293: 
294:     fun listFor(trustSpaceId: Long): List<VerificationSession> =
295:         sessions.findByTrustSpaceIdOrderByCreatedAtDesc(trustSpaceId).mapNotNull { get(it.id) }
296: 
297:     private fun complete(session: VerificationSession): VerificationSession {
298:         val now = Instant.now()
299:         val outcome = if (session.status != VerificationSessionStatus.EXPIRED && !session.expiresAt.isAfter(now))
300:             session.copy(status = VerificationSessionStatus.EXPIRED, holderDid = null, resultJson = errorJson("Verification request expired"), completedAt = now)
301:             else session
302:         if (sessions.complete(outcome.id, outcome.status, outcome.resultJson, outcome.holderDid,

Open current source file · SHA-256: cc7211f9a9a1de47…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/VerificationRequestService.kt:290
288: 
289:     fun get(sessionId: String): VerificationSession? {
290:         sessions.expire(sessionId, Instant.now())
291:         return sessions.findById(sessionId).orElse(null)
292:     }
293: 
294:     fun listFor(trustSpaceId: Long): List<VerificationSession> =
295:         sessions.findByTrustSpaceIdOrderByCreatedAtDesc(trustSpaceId).mapNotNull { get(it.id) }
296: 
297:     private fun complete(session: VerificationSession): VerificationSession {

Open current source file · SHA-256: cc7211f9a9a1de47…

P2SA09trustweave-saas · UX / lifecycle

Cancel only dismisses the operator’s local view

Source-confirmed

Observed behavior and impact

The Cancel button calls reset, which sets session to null. There is no server cancellation transition in the controller. A holder with the QR can still submit successfully until the original expiry, while the operator believes the check was cancelled. Repeated restarts leave pending requests behind.

Required improvement

Add an authenticated tenant-scoped conditional cancellation transition and call it from Cancel. Keep “close view” distinct if the session should remain usable.

Acceptance criteria

After cancellation, the old QR/request/response cannot complete verification; races with completion have one documented winner.

frontend/src/pages/CheckCredential.tsx:106
104: 
105:   const reset = () => {
106:     setSession(null)
107:     start.reset()
108:   }
109: 
110:   return (
111:     <div>
112:       <DomainSubnav spaceId={spaceIdNum} />
113: 

Open current source file · SHA-256: ce996e4d36b5f41b…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/Oid4VpControllers.kt:88
86: @RestController
87: @RequestMapping("/api/trust-spaces/{trustSpaceId}/verification-requests")
88: class VerificationRequestController(
89:     private val requests: VerificationRequestService,
90:     private val tenantContext: TenantContext,
91:     private val objectMapper: ObjectMapper,
92: ) {
93: 
94:     data class CreateRequest(
95:         @field:Size(max = 20, message = "At most 20 credential types")

Open current source file · SHA-256: fa8bc55fd687fe29…

P2SA10trustweave-saas · Abuse resistance

Presentation cost is not bounded by credential count or concurrency

Missing work bounds confirmed; no load/DoS experiment run

Observed behavior and impact

The public response budget limits request count, but one parsed VP may contain arbitrarily many embedded credentials. Verification loops over them and can invoke issuer/status resolution for each; the whole operation has no explicit per-session concurrency claim or total work deadline. Concurrent valid-looking submissions can duplicate expensive work before the final conditional completion. Container body limits alone do not bound verifier cost.

Required improvement

Enforce byte/count/depth limits and a total verification deadline, deduplicate in-flight work per session, and apply fair tenant/instance work budgets in addition to request limits.

Acceptance criteria

Oversized/many-credential requests fail before cryptographic/network work; bounded concurrency and deadline tests prove workload limits.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vp/Oid4VpControllers.kt:61
59:         request: HttpServletRequest,
60:     ): ResponseEntity<Any> {
61:         rateLimiter.check("oid4vp-response:global", maxRequests = 200, windowSeconds = 60)
62:         rateLimiter.check("oid4vp-response:" + clientIp(request), maxRequests = 20, windowSeconds = 60)
63: 
64:         val session = requests.submitPresentation(sessionId, vpToken, presentationSubmission)
65:             ?: return ResponseEntity.notFound().build()
66: 
67:         return when (session.status) {
68:             VerificationSessionStatus.VERIFIED -> ResponseEntity.ok(mapOf("status" to "accepted"))

Open current source file · SHA-256: fa8bc55fd687fe29…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/trustweave/CredentialVerifierService.kt:155
153:             var allValid = true
154:             val errors = mutableListOf<String>()
155:             credentials.forEachIndexed { index, credJson ->
156:                 val label = "Credential #${index + 1}"
157:                 val result = verifyCredential(credJson, policy)
158:                 result.fold(
159:                     onSuccess = { vr ->
160:                         val response = toResponse(vr, trustEvaluated = policy.checkIssuerTrust)
161:                         response.checks.forEach { c ->
162:                             checks += VerificationCheck("$label — ${c.name}", c.passed, c.detail)

Open current source file · SHA-256: 069526bef9d7ac18…

P2SA11trustweave-saas · Billing event reliability

Undeserializable subscription events are permanently acknowledged

Source-confirmed

Observed behavior and impact

Stripe subscription handlers return normally when deserialization fails. processOnce has already inserted the dedup marker and commits when the handler returns; the controller responds 200. A real subscription change can be lost permanently and later redelivery remains suppressed. Logging avoids a retry storm but is not recovery.

Required improvement

Persist a retryable/dead-letter event state and reconciliation job. Only mark applied after processing, or explicitly track unsupported events for operator replay after schema/version repair.

Acceptance criteria

An unsupported subscription payload is durably recoverable, visible to operators, and can be applied once after the parser is fixed.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/webhook/StripeWebhookController.kt:109
107:             logger.warn(
108:                 "Could not deserialize subscription from event {} ({}) — skipping (likely a Stripe " +
109:                     "API version mismatch). Acking to stop redelivery.",
110:                 event.id, event.type,
111:             )
112:             return
113:         }
114: 
115:         val status = SubscriptionStatus.fromStripe(subscription.status)
116: 

Open current source file · SHA-256: 5d883790416e0705…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/webhook/WebhookDedupService.kt:44
42:         // redelivery won the race, and catching it inside this transaction would only poison the
43:         // transaction (rollback-only) while pretending to continue.
44:         processedWebhookEvents.saveAndFlush(ProcessedWebhookEvent(provider = provider, eventId = eventId))
45:         handler()
46:         return true
47:     }
48: }

Open current source file · SHA-256: 1a5b35e2652b4041…

P1SA12trustweave-saas · Dependency maintenance

Frontend dependency graph includes vulnerable spreadsheet parsing

Fresh npm audit and reachable file parser; no exploit attempted

Observed behavior and impact

Fresh npm audit reported 28 affected package entries (3 critical, 18 high, 5 moderate, 2 low), including runtime and development dependencies. xlsx 0.18.x is directly used to parse operator-provided workbooks, and audit lists prototype-pollution/ReDoS advisories with no npm fixAvailable result. Other entries require feature-specific triage; aggregate counts are not a count of exploitable application bugs.

Required improvement

Replace/update the spreadsheet parser through a supported distribution, isolate heavy parsing from the UI thread, upgrade affected dependencies and track advisory exceptions. Keep development-server exposure separate from production risk.

Acceptance criteria

Malicious/malformed workbook tests remain bounded; no unaccepted reachable high/critical advisory remains; tests, lint and bundle budgets pass after upgrades.

frontend/package.json:29
27:     "react-dom": "^18.3.1",
28:     "react-router-dom": "^6.26.0",
29:     "xlsx": "^0.18.5",
30:     "zod": "^4.4.3",
31:     "zustand": "^4.5.5"
32:   },
33:   "devDependencies": {
34:     "@testing-library/jest-dom": "^6.1.5",
35:     "@testing-library/react": "^14.1.2",
36:     "@testing-library/user-event": "^14.5.1",

Open current source file · SHA-256: 9ef02ba9cf10011e…

frontend/src/utils/credentialImport/parseFile.ts:90
88: async function parseXlsx(buffer: ArrayBuffer): Promise<Record<string, string>[]> {
89:   const XLSX = await import('xlsx')
90:   const workbook = XLSX.read(buffer, { type: 'array' })
91:   const sheetName = workbook.SheetNames[0]
92:   if (!sheetName) throw new Error('Workbook has no sheets.')
93:   const sheet = workbook.Sheets[sheetName]
94:   const matrix = XLSX.utils.sheet_to_json<(string | number | boolean | null)[]>(sheet, {
95:     header: 1,
96:     defval: '',
97:     raw: false,

Open current source file · SHA-256: 1b25035c6b943833…

Primary references: security, GHSA-5xrq-8626-4rwp

P2SA13trustweave-saas · Release reproducibility

CI does not yet validate the remediated SDK revision

Release-readiness gap; no deployment changed

Observed behavior and impact

The immutable pin still points to the original reviewed SDK commit, whereas the successful local backend run used the modified sibling checkout. Reproducibility is improved, but those two validation inputs are not the same release artifact. Trusted-proxy/shared-limit behavior and Linux runner execution also remain documented operational work rather than validated deployment evidence.

Required improvement

Publish the SDK remediation commit, update the pin explicitly, and run the complete SaaS pipeline against that exact revision. Validate the production profile, ingress boundary and shared limits in staging.

Acceptance criteria

The release manifest records matching SaaS/SDK hashes with green Linux integration checks and staging trusted-proxy tests.

.trustweave-revision:1
1: 6c62fa0e9a9a589e359a81db902087c873e98884

Open current source file · SHA-256: 54a8a165a740f30a…

docs/review-remediation-operations.md:13
11: ## Reproducible library builds
12: 
13: `.trustweave-revision` records the immutable library commit CI checks out. Manual overrides also require a full commit SHA. CI publishes both source revisions in its job summary. After committing/publishing the corresponding library remediation, update this pin to that commit and rerun the SaaS full backend suite before release. A local composite build includes uncommitted sibling changes; that is development behavior, not a release artifact.
14: 
15: ## Verification profile change
16: 
17: Old holder signatures over only nonce/audience are refused. Sign the complete JSON presentation in the JWT `vp` claim; `holder`, verification method and every credential subject must agree. Requested types/claims are enforced. Unsupported embedded formats are rejected explicitly. Read the library's `docs/api-reference/credential-format-matrix.md` before integrating a wallet; the reference wallet's demo QR protocol is a separate interface.
18: 
19: Repeated submissions return conflict. Expired requests return expired status even without a wallet submission. A database conditional transition prevents a concurrent response from overwriting the winning terminal result.

Open current source file · SHA-256: 02d8cc9d0249be5f…

P2SA14trustweave-saas · OID4VCI protocol / lifecycle

Token creation ignores revoked/redeemed offer status

Source-confirmed

Observed behavior and impact

The token endpoint comment promises invalid_grant for revoked and redeemed offers, but its conditional checks only existence and expiry. AccessTokenStore.issue always creates a fresh row. Revoked or redeemed offers can continue yielding tokens until offer expiry. Redemption may refuse later, so this does not independently prove unauthorized issuance, but it produces misleading success and unnecessary retained grants.

Required improvement

Check offer state atomically at token issuance and define pre-authorized-code consumption/retry semantics. Return a uniform invalid_grant for unusable offers.

Acceptance criteria

PENDING, REVOKED, EXPIRED and REDEEMED offer-state tests prove token issuance follows the documented lifecycle; concurrent exchanges respect the chosen single-use policy.

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vci/Oid4VciControllers.kt:83
81:         // distinguishing them would let anyone probe which offer codes exist.
82:         val offer = offers.findByOfferCode(code)
83:         if (offer == null || offer.offerExpiresAt.isBefore(java.time.Instant.now())) {
84:             return ResponseEntity.badRequest().body(
85:                 OAuthError("invalid_grant", "The pre-authorized code is not valid."),
86:             )
87:         }
88: 
89:         val grant = accessTokenStore.issue(offer.offerCode, offer.trustSpaceId)
90:         logger.info("Issued OID4VCI access token for offer {}", offer.offerCode)

Open current source file · SHA-256: 51366e9385823a9c…

server/src/main/kotlin/com/geoknoesis/trustweave/saas/server/oid4vci/AccessTokenStore.kt:43
41: 
42:     @Transactional
43:     fun issue(offerCode: String, trustSpaceId: Long, ttl: Duration = DEFAULT_TTL): Grant {
44:         val entity = Oid4VciAccessToken(
45:             token = randomToken(),
46:             offerCode = offerCode,
47:             trustSpaceId = trustSpaceId,
48:             cNonce = randomToken(),
49:             expiresAt = Instant.now().plus(ttl),
50:         )

Open current source file · SHA-256: dde03f2a66ec642d…

Acceptance gates for a full score in this scope

Fixing these findings is necessary, but a checklist cannot establish that no other bugs exist. A future full score should be awarded only after the following evidence is reviewed against an explicitly agreed product scope.

  1. Trust decisions: all public retrieval/issuance routes enforce the same authorization and lifecycle rules; negative proofs cannot produce verified-looking outcomes; unchecked and unknown status are explicit.
  2. Credential integrity: real issuer→wallet→verifier tests cover each advertised format, selective disclosure, encrypted claims, tampering, holder mismatch and unsupported combinations.
  3. Data safety: storage providers have consistent record/error/status contracts; transaction, cross-tab, corruption and concurrent store/delete tests pass.
  4. Bounded operations: history and wallet APIs have measured pagination/memory/query budgets; verification work has limits, deadlines and fair concurrency.
  5. User experience: cancel is real cancellation, privacy promises match behavior, invalid storage opens recovery, and results make the scope of verification unmistakable. Browser keyboard/focus and mobile flows are exercised.
  6. Delivery: exact SDK/SaaS revision pairing passes Linux CI and staging checks; dependency findings are patched or explicitly accepted; supported capabilities are generated from the runtime source of truth.

Production hardware custody, every DID method and every credential format are not automatic requirements for a reference demo. If those become product commitments, they need separate design and evidence before being advertised.

Validation, provenance and limits

Fresh checkObserved result
Reference-wallet probes3 passed: corrupted signature import, multi-SD-JWT full disclosure, changed encrypted-claim digest. Node WebCrypto / fake IndexedDB. Log
SaaS controller/policy probes3 passed: public legacy two-step retrieval, wrong-audience acceptance with matching azp, Strict empty-allowlist downgrade. Mocked repository/service boundaries; no live HTTP attack. Log
Cloud wallet probe1 passed: download authorization failure returns an empty list. Log
Dependency auditsReference wallet: 6 affected package entries. SaaS frontend: 28. Includes transitive and development packages; not an exploit count. Wallet audit · SaaS audit
Prior suite resultsThe remediation report records SDK affected-module tests/lint, SaaS full backend/coverage and frontend checks. These were not all rerun for this review. The fresh 3-test backend probe run explicitly excluded the aggregate JaCoCo gate.
Source identityReviewed current working trees, including uncommitted remediation. Evidence excerpts carry line numbers and full source hashes in findings.json. HEADs and test metadata are in validation.json.
Changes made during reviewReport/evidence artifacts only. Temporary runnable probes were removed after execution; their sources are preserved under probes. No application fixes, commit or deployment.

This is a fresh, focused review of auth, verification/issuance, wallet storage/custody, selected UI flows, webhooks, dependencies and delivery. It is not exhaustive formal verification of the entire SDK/plugin ecosystem or every SaaS tenant route. No production, live-browser, load or database-race experiment was performed. JVM dependency advisory scanning, cryptographic side-channel analysis, full protocol conformance and disaster-recovery exercises remain outside the evidence collected here.