Skip to content

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 JwtSigner your 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 to presentCredentials. 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:

sequenceDiagram participant App as Your app participant Plugin participant Issuer as Issuance service App ->>+ Plugin: Request the credential Plugin ->> Issuer: Redeem the pre-authorized code Issuer -->> Plugin: Access token Plugin ->> Issuer: Ask for a nonce Issuer -->> Plugin: Nonce Plugin ->>+ App: Ask for the holder's public key App -->>- Plugin: Public key Plugin ->>+ App: Ask to sign the proof App -->>- Plugin: Signature Plugin ->> Issuer: Credential request, with the signed proof Issuer -->> Plugin: Credential Plugin -->>- App: Issued credential

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:

pubspec.yaml
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:

keystore_jwt_signer.dart
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)!
    // ...
  }
}
  1. Returns the public key named keyId as a JWK: kty is EC, crv is P-256, and x and y are 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.

  2. Signs message with the key named keyId, and returns the signature as a JWS carries it for ES256: the 64 raw bytes of r followed by s, 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:

main.dart
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 keyId for 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;
  1. 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.
  2. Each display entry may name a locale; 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);
  1. Creates a P-256 key in your own key storage, where your JwtSigner finds it, and returns its id. The plugin fetches the public key and the proof's signature from your JwtSigner by 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