Build a Wallet with Flutter
The Partisia Holder SDK, partisia_holder_sdk, lets you build a wallet of your own in Flutter, for Android and
iOS. It implements the holder's side of the two protocols a wallet speaks:
OID4VCI to receive a credential, and
OID4VP to present one. It handles credentials in two formats: W3C
verifiable credentials secured as SD-JWTs, and IETF SD-JWT VCs.
Choose the plugin when you need a wallet designed from scratch, or credentials inside an app you already ship. If the Partisia ID Wallet under your own brand is enough, a white-label wallet needs no development.
The plugin does the protocol work, and leaves everything the holder sees and keeps to your app:
| The plugin | Your app |
|---|---|
| Talks OID4VCI to issuers and OID4VP to verifiers | Scans QR codes and handles deep links |
| Checks the verifier's signed request | Asks the holder to approve each issuance and presentation |
| Finds the stored credentials a verifier's query matches | Stores the credentials |
| Cuts a credential down to the claims the verifier asked for | Creates and keeps the holder's keys, and signs with them |
| Reads a credential's claims for display | Shows the screens |
How it fits together
Two rules shape every call:
- Keys stay in your app. The plugin never sees a private key. Whenever a protocol step needs a signature, the
plugin calls back into a
JwtSigneryour app registers, which may keep its keys in the Android Keystore, the Secure Enclave, or anywhere else. Only public keys and signatures cross into the plugin. - The plugin keeps no state. Each call returns everything the next one needs, and your app hands it back: the
issuance session to
requestCredential, the authorization request topresentCredentials. There is nothing to open, close or release.
Signing is a callback rather than an argument because the plugin can only assemble what it signs part-way through a request. The proof sent with a credential request, for example, has to include a nonce the issuer hands out during the request:
Presenting a credential works the same way: the plugin builds the presentation, and calls JwtSigner to sign it.
Install
Requirements
- Flutter with Dart 3.11.5 or later.
- Android 10 (API level 29) or later, built with JDK 17 or later.
- iOS 16 or later. The plugin is a Swift package and ships no CocoaPods spec, so Flutter's Swift Package
Manager support must be on. Flutter 3.44 and later turn it on by default; on an older one, run
flutter config --enable-swift-package-manager. - Access to the plugin's repository. Please book a call with us to arrange it.
Add the plugin to your app's pubspec.yaml, pinned to a release tag:
dependencies:
partisia_holder_sdk:
git:
url: https://gitlab.com/secata/platform/did/release
path: partisia_holder_sdk
ref: v1.0.0
Then run flutter pub get. The plugin carries the DID library with it, as Android artifacts in a bundled repository
it adds to your app's build itself, and as a prebuilt iOS framework.
Everything is imported from one library:
import 'package:partisia_holder_sdk/partisia_holder_sdk.dart';
Sign with your own keys
Implement JwtSigner, and register it with setJwtSigner. The plugin calls it for every signature it needs:
- the proof of possession sent when a credential is issued, which binds the credential to the holder's key
- the signature on a presentation, which shows the verifier the holder has the key the credential is bound to
A signer has two methods. Both name their key with a keyId, an id your app chooses:
import 'dart:typed_data';
import 'package:partisia_holder_sdk/partisia_holder_sdk.dart';
class KeystoreJwtSigner extends JwtSigner {
@override
Future<Map<String, Object?>> publicKeyJwk(String keyId) async { // (1)!
// ...
}
@override
Future<Uint8List> sign(String keyId, Uint8List message) async { // (2)!
// ...
}
}
-
Returns the public key named
keyIdas a JWK:ktyisEC,crvisP-256, andxandyare the 32-byte coordinates, base64url encoded without padding.Fail for an id you do not know rather than creating a key: a key made up on the spot is one no credential is bound to, and a presentation signed with it is rejected by the verifier. Create the key before you ask for the credential instead, as in Request the credential.
-
Signs
messagewith the key namedkeyId, and returns the signature as a JWS carries it for ES256: the 64 raw bytes ofrfollowed bys, each padded to 32 bytes. Platform keystores return the DER encoding instead, so convert it.
Register the signer once, for the lifetime of the app. The plugin calls back into it part-way through both flows, so it has to be in place before either starts:
import 'package:flutter/widgets.dart';
import 'package:partisia_holder_sdk/partisia_holder_sdk.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
setJwtSigner(KeystoreJwtSigner());
runApp(const MyWallet());
}
Keep these rules in mind when you implement the signer:
- ES256 only. Keys are P-256. The plugin has already assembled the JWT's header and payload, so sign the bytes exactly as they arrive.
- One key per credential, or one for all. A new
keyIdfor each credential keeps presentations of different credentials from being linked by their key; one fixed id binds every credential to the same key. - Keep a key for as long as its credential. Presenting a credential signs with the key it was bound to at issuance. If the key is lost, the credential can no longer be presented, and the holder has to have it issued again.
Receive a credential
An issuance starts when your issuer's application shows the holder a credential offer: as a QR code to scan from another device, or as a link that opens your app on the same one. The holder accepts the offer, and your app asks the issuer for the credential.
Read the offer
The QR code holds a deep link, with the address of the offer, the credential offer URI, percent-encoded in its
credential_offer_uri parameter:
haip-vci://?credential_offer_uri=https%3A%2F%2Fissuer.example.org%2Fissuance%2Foid4vci%2Fcredential-offer%2F7d4633b2...
The plugin takes the address itself, not the deep link. Hand it to getIssuanceSessionData, which fetches the
offer, along with the issuer's metadata and that of its authorization server:
final issuance = IssuanceApi();
final IssuanceSessionData session = await issuance.getIssuanceSessionData(offerAddress);
final metadata = session.credentialIssuerMetadata;
for (final String configurationId in session.credentialOffer.credentialConfigurationIds) { // (1)!
final CredentialConfiguration? configuration = metadata.credentialConfigurationsSupported[configurationId];
final String? name = configuration?.credentialMetadata?.display?.firstOrNull?.name; // (2)!
}
final String? issuerName = metadata.display?.firstOrNull?.name;
- The ids of the credentials this offer is for. The issuer's metadata describes each one under the same id, among every credential the issuer can issue.
- Each
displayentry may name alocale; pick the one that matches the holder's language. The display metadata is what the issuer configured in its credential configuration: a name, description, logo and colors for the card.
A CredentialConfiguration also tells what kind of credential it is by its format identifier. format is plain text
rather than a CredentialFormat, because an issuer may offer formats the plugin cannot hold; asking for one of those
fails before anything is sent.
The session is a bearer token
The session holds the offer's pre-authorized code, and whoever presents that code to the issuer gets the credential. Keep the session in memory only, for as long as the holder is deciding, and never log it.
Request the credential
Once the holder accepts, create the key the credential will be bound to, and ask for the credential:
final String keyId = await keyStorage.createP256Key(); // (1)!
final IssuedVc issued = await issuance.requestCredential(session, configurationId, keyId);
- Creates a P-256 key in your own key storage, where your
JwtSignerfinds it, and returns its id. The plugin fetches the public key and the proof's signature from yourJwtSignerby this id while the request runs.
The plugin completes the protocol: it redeems the pre-authorized code, has your signer sign the proof, and fetches the
credential. An IssuedVc holds:
| Field | What it is |
|---|---|
credential |
The credential, serialized as the issuer sent it, with every disclosure. |
format |
The CredentialFormat it was issued in. |
credentialMetadata |
The display metadata of the credential: its name, logo, colors and claim labels. |
issuerDisplay |
The issuer's own display metadata: its name and logo. |
Store the credential
The plugin hands the credential over and forgets it, so your app keeps everything it needs later:
| Keep | Why |
|---|---|
| An id of your own | To tell your credentials apart. |
issued.credential |
The credential, needed to show and present it. |
issued.format.identifier |
How to read and present the credential. |
The keyId |
Presenting the credential signs with this key. |
| The display metadata | The plugin does not fetch it again. Keep what your screens show. |
Store the identifier, not the enum value
Persist a format as its identifier, and read it back with credentialFormatFromIdentifier. The enum's index and
name may change between plugin versions; the identifiers are what issuers and verifiers use.
final String stored = issued.format.identifier; // "dc+sd-jwt"
final CredentialFormat format = credentialFormatFromIdentifier(stored);
The serialized credential holds every claim it was issued with, including those the holder only discloses when they choose to. Keep that in mind when you choose how to store it. Keep each credential with its key, and delete them together. Keys in the Android Keystore or the Secure Enclave are not backed up, and a credential restored without its key cannot be presented.
Present a credential
A presentation starts when a verifier's application shows the holder a presentation request: as a QR code to scan from another device, or as a link that opens your app on the same one. Your app fetches the request, finds the stored credentials that answer it, shows the holder what would be shared, and presents the one they choose.
Fetch the request
Like an offer, the request reaches your app as a deep link, with the request's address, the request URI,
percent-encoded in its request_uri parameter. Hand the address to getPresentationRequest:
final presentation = PresentationApi();
final AuthorizationRequest request = await presentation.getPresentationRequest(requestAddress);
The plugin checks the request before returning it: that it is signed by the first certificate in its x5c header,
and that its client_id is the hash of that certificate. The
trust model shows how a verifier derives its client_id that way.
Find the matching credentials
Hand the plugin your stored credentials, each as a HeldCredential of its id, credential and format, and it works
out which of them answer the request:
final List<EvaluatedCredentialQuery> matches = await presentation.computeApplicableCredentials(
request,
storedCredentials,
);
if (matches.isEmpty) {
// None of the holder's credentials answers the request. Say so, and present nothing.
} else {
// Let the holder choose which match to present, showing what each would share.
}
There is one match for each stored credential that answers the request's credential query. Credentials in a format
other than the one asked for are left out. Each match holds a single CredentialToPresent, in
credentialsToPresent.first: the id of your stored credential, and the credential cut down to only the information
the request asks for. Claims the issuer did not make selectively disclosable are always included. To show the holder
what a match would share, read that credential with jsonOf.
Present
Once the holder approves one of the matches, present it, signing with the key its credential was bound to:
final EvaluatedCredentialQuery chosen = matches[selected];
final String chosenCredentialId = chosen.credentialsToPresent.first.id;
final String keyId = findKeyIdOfStoredCredential(chosenCredentialId);
final PresentationResponse response = await presentation.presentCredentials(request, chosen, keyId);
final bool delivered = response.httpStatus >= 200 && response.httpStatus < 300;
The plugin signs the presentation through your JwtSigner, and posts it to the verifier. httpStatus is the status
the verifier answered with. It does not tell you whether the verifier accepted the presentation; the verifier reports
that to its own application.
Show a credential
CredentialClaimsApi.jsonOf reads a credential's claims, whatever its format, and returns them as a JSON object in
text:
import 'dart:convert';
final claimsApi = CredentialClaimsApi();
final Map<String, Object?> claims =
jsonDecode(await claimsApi.jsonOf(credential, format)) as Map<String, Object?>;
The claims hold the information about the holder that each credential configuration defines, along with generic
claims about the credential's validity, such as its expiry and status:
- For a W3C credential, the claims about the holder are inside
credentialSubject. - For an SD-JWT VC, they are at the top level, alongside the validity claims.
Show the holder the claims you find relevant.
Each entry in credentialMetadata.claims names a claim by its path, and its display gives that claim a readable
name in each of the issuer's locales. Use it to label the claims in the holder's language, "First Name" rather than
first_name. The issuer sets these names in its credential configuration.
jsonOf only decodes the credential; it does not check its signature. It fails with the code invalid-credential
if the credential cannot be read in the given format.
Handle errors
Every call fails with a PlatformException. For a failure in the library, its code is the same on Android and
iOS, so branch on that:
code |
Raised by | Meaning |
|---|---|---|
invalid-argument |
IssuanceApi, PresentationApi |
An argument or a response could not be used, such as an unknown credential configuration id or an unsupported format. |
issuance-failed |
IssuanceApi |
The issuance protocol failed, such as the issuer refusing the request. |
issuance-error |
IssuanceApi |
Anything else, such as a network failure. |
presentation-failed |
PresentationApi |
The presentation protocol failed, such as a request that does not match its certificate. |
presentation-error |
PresentationApi |
Anything else, such as a network failure. |
invalid-credential |
CredentialClaimsApi |
The credential cannot be read in the given format. |
A request also fails if your JwtSigner throws, or if no signer is registered. Those failures do not always carry
one of the codes above: on Android they arrive as error and channel-error.
import 'dart:developer';
import 'package:flutter/services.dart';
try {
final IssuedVc issued = await issuance.requestCredential(session, configurationId, keyId);
} on PlatformException catch (e) {
log('Issuance failed with ${e.code}: ${e.message}\n${e.details}');
showError('The credential could not be issued.');
}
message is the library's own description of what went wrong, written for developers rather than holders, and
details, when present, is the native stack trace. Log them, and show the holder a message of your own.
What's Next
- Issuance Overview: Where the offers your wallet scans come from.
- Verification Queries: What verifiers ask a wallet for.
- Trust Model: How issuers and verifiers prove who they are.
- OID4VCI and OID4VP: The protocols the plugin speaks, step by step.