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.
Dimension
Points
Security & trust
22 / 30
Correctness & data integrity
19 / 25
Architecture & maintainability
17 / 20
Testing & delivery
13 / 15
Developer / user experience
8 / 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.
Dimension
Points
Security & trust
20 / 30
Correctness & data integrity
19 / 25
Architecture & maintainability
16 / 20
Testing & delivery
12 / 15
Developer / user experience
8 / 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.
Dimension
SDK deductions chiefly supported by
SaaS deductions chiefly supported by
Security / trust
TW01–03, TW13
SA01, SA03, SA05–07, SA12
Correctness / integrity
TW02–05, TW07–10
SA02, SA04–06, SA11, SA14
Architecture / maintenance
TW06, TW08, TW11
SA07–08, SA10–11
Testing / delivery
TW12–13; probes found contract gaps
SA13; policy/race/legacy-route test gaps
Experience
TW01, TW05, TW07, TW10
SA05, SA07–09
First priorities
Close public credential disclosure (SA01). A known credential ID leads to holder discovery and raw credential retrieval without holder proof.
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.
Serialize issuance by offer (SA02). Atomic access-token consumption does not prevent two different grants redeeming the same invitation.
Repair wallet proof and disclosure handling (TW01–03). Import authenticity and issuer-bound disclosure integrity need executable negative and round-trip tests.
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.
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
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
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.
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.
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.
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.
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.
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) }
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: )
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.
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)
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.
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,
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: }
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.`)
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.
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.
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.
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.
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"))
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.
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.
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.
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.
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 ->
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.
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.
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: )
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.
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.
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.
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.
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: }
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.
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.
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.
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.
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)
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.
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.
Credential integrity: real issuer→wallet→verifier tests cover each advertised format, selective disclosure, encrypted claims, tampering, holder mismatch and unsupported combinations.
Data safety: storage providers have consistent record/error/status contracts; transaction, cross-tab, corruption and concurrent store/delete tests pass.
Bounded operations: history and wallet APIs have measured pagination/memory/query budgets; verification work has limits, deadlines and fair concurrency.
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.
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.
3 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 probe
1 passed: download authorization failure returns an empty list. Log
Dependency audits
Reference wallet: 6 affected package entries. SaaS frontend: 28. Includes transitive and development packages; not an exploit count. Wallet audit · SaaS audit
Prior suite results
The 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 identity
Reviewed 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 review
Report/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.