Vault Iota
Affinidi TDK - Vault Iota for Dart implements the holder (wallet) side of the OpenID for Verifiable Presentations (OID4VP) sharing flow used by the Affinidi Iota Framework. It takes a verifier’s OID4VP request URI, matches it against the credentials stored in your Vault, and builds and submits the resulting Verifiable Presentation (VP). This covers the full presentation exchange, from request ingestion to VP submission.
The package exposes four main services that together implement the share flow:
Parses and validates the verifier’s OID4VP request URI.
Matches vault credentials against the request, for both Presentation Exchange (PEX) and Digital Credentials Query Language (DCQL) queries.
Builds and submits the signed VP, or sends a rejection, to the verifier’s callback endpoint.
Persists consent records and re-submits automatically for previously approved verifiers.
Key Features
- Parse and validate Iota OID4VP request URIs, including JWT signature verification and nonce replay protection.
- Match vault credentials against a verifier’s request through one unified API, regardless of whether it uses PEX or DCQL.
- Build and submit a signed Verifiable Presentation, or send a rejection, directly to the verifier’s callback endpoint.
- Restrict VP submission to a caller-supplied list of trusted verifier hosts.
- Persist consent records and support automatic, silent re-consent for previously approved verifiers.
- Storage-agnostic: bring your own backend for consent records and nonce replay tracking.
Installation
Package: affinidi_tdk_vault_iota
dart pub add affinidi_tdk_vault_iotaCheck the latest version on pub.dev or view the source on GitHub.
Parse and Validate the OID4VP Request
The OID4VP request URI typically arrives via a QR code scan, a deep link, or an API response. ShareFlowService.validateOid4vpRequest decodes the JWT from its request query parameter, verifies the signature and expiry, checks nonce uniqueness to prevent replay, and returns a structured Oid4vpShareRequest.
Treat the returned Oid4vpShareRequest as an opaque handle. Pass the same instance to CredentialMatcherService and IotaShareResponseService regardless of whether the verifier used a Presentation Definition or a DCQL query. The package routes between the two protocols internally.
Import
import 'package:affinidi_tdk_cryptography/affinidi_tdk_cryptography.dart';
import 'package:affinidi_tdk_vault_iota/affinidi_tdk_vault_iota.dart';Parameters
uri
Uri
Requiredrequest JWT query parameter.walletDid
String
Optionalaud claim is validated against it.Example
final cryptography = CryptographyService();
final service = ShareFlowService(cryptography: cryptography);
// The request URI typically arrives via a QR code scan, deep link, or API response.
final uri = Uri.parse('openid4vp://authorize?request=<your-request-jwt>');
try {
// Pass walletDid to validate the JWT's aud claim against your wallet's DID.
final shareRequest = await service.validateOid4vpRequest(
uri,
walletDid: 'did:key:z6Mk...',
);
print('Verifier DID: ${shareRequest.request.clientId}');
print('Accept response URI: ${shareRequest.request.acceptResponseUri}');
} on TdkException catch (e) {
print('OID4VP validation failed [${e.code}]: ${e.message}');
}Match Credentials Against the Vault
Once the request is validated, match it against the credentials stored in the user’s Vault. CredentialMatcherService.match accepts the Oid4vpShareRequest from the previous step and the full list of vault credentials, and returns a MatchedCredentialsResult with the same shape whether the verifier used PEX or DCQL.
Import
import 'package:affinidi_tdk_vault_iota/affinidi_tdk_vault_iota.dart';
import 'package:ssi/ssi.dart';Parameters
shareRequest
Oid4vpShareRequest
RequiredShareFlowService.validateOid4vpRequest.allVCs
List<VerifiableCredential>
RequiredExample
// allVaultCredentials comes from your own persistence layer.
final allVaultCredentials = <VerifiableCredential>[];
final matcher = CredentialMatcherService();
final result = await matcher.match(shareRequest, allVaultCredentials);
if (!result.hasEnoughVCsAvailableToShare) {
print('Not enough credentials available to satisfy the request.');
return;
}
// A sensible default selection to present to the user, or submit directly.
final selectedVcs = result.recommendedMaximumVCs;The MatchedCredentialsResult exposes:
hasEnoughVCsAvailableToShare- whether every requested credential group has at least its minimum required number of matches.recommendedMaximumVCs- the recommended credentials to share across all groups, capped at each group’s maximum.availableCredentials- every vault credential that satisfies any group.groups- the requested credential groups (MatchedCredentialGroup), each withminimumVCsCountToShare,maximumVCsCountToShare,availableCredentials,recommendedCredentials, andallowsMultiple. Use this to build a selection UI that enforces per-group minimum and maximum counts.credentialSetOptions- the DCQLcredential_setalternatives, ornullwhen the request does not use credential sets (PEX, or DCQL withoutcredential_sets).
Submit or Reject the Verifiable Presentation
The IotaShareResponseService builds the VP from the selected credentials and posts it to the verifier’s callback endpoint, or sends a rejection instead. It requires a DidSigner that controls the holder’s signing key, and a trustedVerifiersList of plain host names the VP is allowed to be posted to. See Security Considerations for the validation rules enforced on this list.
Submit the Verifiable Presentation
Import
import 'package:affinidi_tdk_vault_iota/affinidi_tdk_vault_iota.dart';
import 'package:ssi/ssi.dart';Parameters
shareRequest
Oid4vpShareRequest
RequiredShareFlowService.validateOid4vpRequest.selectedCredentials
List<VerifiableCredential>
RequiredacceptResponseUri
String
RequiredshareRequest.request.acceptResponseUri.Example
// Create a DidSigner that controls the holder's signing key.
// In a real application this comes from your wallet integration.
final wallet = PersistentWallet(InMemoryKeyStore());
final keyPair = await wallet.generateKey(keyType: KeyType.ed25519);
final didManager = DidKeyManager(wallet: wallet, store: InMemoryDidStore());
await didManager.addVerificationMethod(keyPair.id);
final signer = await didManager.getSigner(
didManager.assertionMethod.first,
signatureScheme: SignatureScheme.ed25519,
);
final responseService = IotaShareResponseService(
signer: signer,
// Only these hosts may be used as VP submission targets.
trustedVerifiersList: ['verifier.example.com'],
);
try {
final redirectUri = await responseService.submitShareResponse(
shareRequest: shareRequest,
selectedCredentials:
selectedVcs.cast<ParsedVerifiableCredential<dynamic>>(),
acceptResponseUri: shareRequest.request.acceptResponseUri,
);
// When non-null, navigate the user to this URL to complete the flow on
// the verifier's side, for example back to the verifier's web app.
if (redirectUri != null) {
print('Redirect to: $redirectUri');
}
} on TdkException catch (e) {
print('VP submission failed [${e.code}]: ${e.message}');
}Reject the Verifiable Presentation
Call rejectShareResponse instead of submitShareResponse when the user declines the request. It notifies the verifier without building or sending a VP.
Import
import 'package:affinidi_tdk_vault_iota/affinidi_tdk_vault_iota.dart';Parameters
shareRequest
Oid4vpShareRequest
RequiredrejectResponseUri
String
RequiredshareRequest.request.rejectResponseUri.Example
final redirectUri = await responseService.rejectShareResponse(
shareRequest: shareRequest,
rejectResponseUri: shareRequest.request.rejectResponseUri,
);Persist and Reuse Consent
After a successful VP submission, persist a consent record so a future share to the same verifier can be recognised and, if the user opted in, submitted automatically without prompting again. IotaConsentRecordService is storage-agnostic: implement ConsentStorage with any persistence technology, such as SQLite, Drift, or a remote API, to back it. If your app already depends on affinidi_tdk_vault_flutter_utils, use its FlutterSecureConsentStorage implementation instead of writing your own.
Implement ConsentStorage
The three methods have distinct contracts: saveOrUpdate must upsert by IotaConsentRecord.hash, findByRequestHash returns the most recently saved record matching a request fingerprint, and findAllByRequestHash returns every matching record for tryAutomaticConsent to evaluate.
Import
import 'package:affinidi_tdk_vault_iota/affinidi_tdk_vault_iota.dart';Example
// Replace the in-memory map with your real database, for example
// sqflite, Drift, Isar, or a remote API.
class MyConsentStorage implements ConsentStorage {
final Map<String, IotaConsentRecord> _records = {};
@override
Future<void> saveOrUpdate(IotaConsentRecord record) async {
_records[record.hash] = record;
}
@override
Future<IotaConsentRecord?> findByRequestHash(String requestHash) async {
final matches = _records.values
.where((r) => r.requestHash == requestHash)
.toList()
..sort((a, b) => b.sharedAt.compareTo(a.sharedAt));
return matches.firstOrNull;
}
@override
Future<List<IotaConsentRecord>> findAllByRequestHash(
String requestHash,
) async {
return _records.values.where((r) => r.requestHash == requestHash).toList();
}
}
final myConsentStorage = MyConsentStorage();Resolve Verifier Metadata
Both consent methods take a VerifierClientMetadata describing the verifier’s display name, logo, and origin, typically shown on the consent screen alongside the credential selection. Resolve it with VerifierMetadataService, which reads inline client_metadata from the request when present, or fetches it from the Affinidi login configuration API otherwise.
Import
import 'package:affinidi_tdk_vault_iota/affinidi_tdk_vault_iota.dart';Parameters
clientId
String
Requiredclient_id, normally shareRequest.request.clientId.clientMetadataUri
String
OptionalclientMetadata
Map<String, dynamic>
OptionalExample
final metadataService = VerifierMetadataService(
baseUrl: 'https://apse1.api.affinidi.io',
);
final verifierMetadata = await metadataService.fetchVerifierMetadata(
clientId: shareRequest.request.clientId,
clientMetadataUri: shareRequest.request.clientMetadataUri,
clientMetadata: shareRequest.request.clientMetadata,
);
// Release the underlying HTTP client once you're done with the service.
metadataService.dispose();Save a Consent Record
Persist a consent record once the VP has been submitted, keyed by an internal fingerprint the SDK derives from the request and the vault ID. Set isAutoShareEnabled: true when the user opts in to skip the consent screen on their next share to this verifier.
Import
import 'package:affinidi_tdk_vault_iota/affinidi_tdk_vault_iota.dart';Parameters
shareRequest
Oid4vpShareRequest
Requiredclient_id from it.verifierMetadata
VerifierClientMetadata
RequiredVerifierMetadataService.fetchVerifierMetadata.profileId
String
RequiredprofileName
String
RequiredvaultId
String
RequiredsharedVcs
List<VerifiableCredential>
RequiredclaimedVcTypesCsv
String
RequiredisAutoShareEnabled
bool
RequiredhistorySharedData
Map<String, String>
OptionalisConsentManagementEnabled
bool
Optionalfalse.Example
final consentService = IotaConsentRecordService(
store: myConsentStorage,
cryptography: cryptography,
shareResponseService: responseService,
);
// These normally come from the wallet/profile currently in use, not literals.
const holderVaultId = 'did:key:z6MkHolder456';
const profileId = 'profile-abc';
const profileName = 'Personal';
await consentService.saveConsentRecord(
shareRequest: shareRequest,
verifierMetadata: verifierMetadata,
profileId: profileId,
profileName: profileName,
vaultId: holderVaultId,
sharedVcs: selectedVcs,
claimedVcTypesCsv: 'EmailV1VC,PhoneNumberV1VC',
isAutoShareEnabled: true, // user opted in to auto-share for this verifier
historySharedData: {
'Email address': 'user@example.com',
},
);Attempt Automatic Consent
Before showing the consent screen, call tryAutomaticConsent to check whether a prior approved record covers the current request, and if so, submit the VP silently. It re-validates every security-sensitive field against the live request, including the verifier’s clientId, the credential count, and the full share fingerprint, before submitting, so a stale or tampered record can never be replayed as consent.
If reading the stored records fails, tryAutomaticConsent does not throw. It logs a warning and returns AutoConsentDeclined, so a storage fault falls back to the interactive consent screen instead of blocking the user. Do not wrap this call in a try/catch expecting a storage-error code; check the returned AutoConsentResult instead.
Import
import 'package:affinidi_tdk_vault_iota/affinidi_tdk_vault_iota.dart';Parameters
shareRequest
Oid4vpShareRequest
Requiredstate, nonce, and clientId.matchedCredentials
MatchedCredentialsResult
RequiredCredentialMatcherService.match.verifierMetadata
VerifierClientMetadata
RequiredvaultId
String
RequiredExample
final autoResult = await consentService.tryAutomaticConsent(
shareRequest: shareRequest,
matchedCredentials: result, // from CredentialMatcherService.match
verifierMetadata: verifierMetadata,
vaultId: holderVaultId,
);
switch (autoResult) {
case AutoConsentApproved(:final redirectUri):
print('Auto-consent approved. Redirect: ${redirectUri ?? "(none)"}');
case AutoConsentDeclined():
print('Auto-consent declined. Show the share approval UI.');
}Security Considerations
Replay Attack Protection
The ShareFlowService prevents replay attacks by rejecting a request whose nonce has already been seen. Calling validateOid4vpRequest a second time with the same JWT nonce, while the JWT is still within its exp window, throws a TdkException with code replay_detected.
By default, nonces are tracked in an in-memory cache scoped to the ShareFlowService instance, so replay protection does not survive a process restart. Implement NonceReplayStore with persistent storage and inject it through the replayCache constructor parameter for replay protection that survives app restarts.
Example
class MyPersistentNonceStore implements NonceReplayStore {
@override
Future<bool> record(String nonce, int expEpochSeconds) async {
// Atomically check and store; return false if the nonce was already seen.
return myDb.recordNonce(nonce, expEpochSeconds);
}
}
final service = ShareFlowService(
cryptography: cryptography,
replayCache: MyPersistentNonceStore(),
);Trusted Verifier Hosts
The IotaShareResponseService requires a non-empty trustedVerifiersList of plain host names, and enforces the following rules:
- Entries must be plain host names, with no scheme, port, path, query string, or userinfo. An empty list, or an entry that fails this check, throws immediately when the service is constructed.
- Matching against the list is case-insensitive.
- Both
acceptResponseUriandrejectResponseUrimust be well-formed HTTPS URIs with no IP-address hostname, or the call throwsinvalid_response_uri. If the URI is well-formed but its host is not in the list, the call throwsuntrusted_response_uriinstead. Both checks run before any network request is made.
The rules above apply to acceptResponseUri and rejectResponseUri. The verifier’s own redirect_uri is checked the same way, but on failure the SDK logs a warning and drops it instead of throwing, so a null return means no redirect was followed, not that none was attempted.
Example
final responseService = IotaShareResponseService(
signer: mySigner,
trustedVerifiersList: ['verifier.example.com', 'other-verifier.example.com'],
);Error Handling
All errors are thrown as TdkException, exposing the code that identifies the failure.
| Code | Thrown by | Description |
|---|---|---|
parse_failure | validateOid4vpRequest | The request query parameter is missing, the JWT could not be decoded, or a required payload field is missing |
invalid_or_expired_jwt | validateOid4vpRequest | The JWT signature is invalid, the token has expired, or its iat claim is in the future |
invalid_client_id_scheme | validateOid4vpRequest | client_id_scheme is not did |
invalid_audience | validateOid4vpRequest | The JWT aud claim is present but does not match walletDid, or walletDid was omitted |
missing_client_id | validateOid4vpRequest | The client_id field is missing from the request |
invalid_response_mode | validateOid4vpRequest | response_mode is not direct_post |
invalid_response_type | validateOid4vpRequest | response_type is not vp_token |
replay_detected | validateOid4vpRequest | The request nonce has already been consumed within its expiry window |
invalid_presentation_definition | validateOid4vpRequest, match | The Presentation Definition is structurally invalid |
invalid_dcql_query | validateOid4vpRequest | The DCQL query is structurally invalid |
unsupported_multiple_idv_types | match | A single input descriptor requests more than two verified-identity-document credential types |
unsupported_credential_format | submitShareResponse | A selected credential is not JSON-LD (for example JWT-VC or SD-JWT-VC), which the current VP builder cannot embed |
empty_trusted_verifiers_list | IotaShareResponseService(...) constructor | trustedVerifiersList was empty |
invalid_response_uri | IotaShareResponseService(...) constructor, submitShareResponse, rejectShareResponse | A trustedVerifiersList entry was not a plain host name, or acceptResponseUri/rejectResponseUri was not a valid HTTPS URI |
untrusted_response_uri | submitShareResponse, rejectShareResponse | The callback host is not in trustedVerifiersList |
submission_failed | submitShareResponse, rejectShareResponse | Posting to the verifier’s callback endpoint failed, due to a network error or a non-2xx response |
invalid_client_id | fetchVerifierMetadata | An empty clientId was passed to the verifier metadata service |
failed_to_fetch_verifier_metadata | fetchVerifierMetadata | The verifier’s client metadata could not be fetched, or the response was not a JSON object |
failed_to_persist_consent_record | saveConsentRecord | The underlying ConsentStorage.saveOrUpdate call failed |
For complete runnable examples covering each step in this guide, see the examples on GitHub:
- Parsing and validating an OID4VP request.
- Matching credentials and submitting a share response.
- Saving a consent record and using automatic consent.
Related
Glad to hear it! Please tell us how we can improve more.
Sorry to hear that. Please tell us how we can improve.
Thank you for sharing your feedback so we can improve your experience.