From 7f735a50e7049774ae9843a1af87c11864fef9ab Mon Sep 17 00:00:00 2001
From: Nicolas Mattia
Date: Mon, 23 Oct 2023 17:50:41 +0200
Subject: [PATCH] Add basic demo flow
---
package-lock.json | 50 ++++
package.json | 6 +-
src/frontend/generated/vc_issuer_idl.js | 79 +++++++
src/frontend/generated/vc_issuer_types.d.ts | 63 +++++
.../src/flows/verifiableCredentials/allow.ts | 71 ++++++
.../src/flows/verifiableCredentials/index.ts | 219 ++++++++++++++++++
.../postMessageInterface.ts | 122 ++++++++++
.../src/flows/verifiableCredentials/prompt.ts | 131 -----------
.../src/flows/verifiableCredentials/select.ts | 124 ----------
.../flows/verifiableCredentials/stepper.ts | 27 ---
.../flows/verifiableCredentials/vcIssuer.ts | 83 +++++++
src/frontend/src/index.ts | 10 +-
src/frontend/src/utils/iiConnection.ts | 73 ++++++
src/frontend/vc-flow/index.html | 16 ++
src/showcase/src/pages/[page].astro | 3 +-
src/showcase/src/pages/vc-test-app.astro | 185 +++++++++++++++
src/showcase/src/showcase.ts | 49 +---
vite.config.ts | 1 +
18 files changed, 984 insertions(+), 328 deletions(-)
create mode 100644 src/frontend/generated/vc_issuer_idl.js
create mode 100644 src/frontend/generated/vc_issuer_types.d.ts
create mode 100644 src/frontend/src/flows/verifiableCredentials/allow.ts
create mode 100644 src/frontend/src/flows/verifiableCredentials/index.ts
create mode 100644 src/frontend/src/flows/verifiableCredentials/postMessageInterface.ts
delete mode 100644 src/frontend/src/flows/verifiableCredentials/prompt.ts
delete mode 100644 src/frontend/src/flows/verifiableCredentials/select.ts
delete mode 100644 src/frontend/src/flows/verifiableCredentials/stepper.ts
create mode 100644 src/frontend/src/flows/verifiableCredentials/vcIssuer.ts
create mode 100644 src/frontend/vc-flow/index.html
create mode 100644 src/showcase/src/pages/vc-test-app.astro
diff --git a/package-lock.json b/package-lock.json
index 9f16d51323..f10fd85da6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17,6 +17,7 @@
"bip39": "^3.0.4",
"buffer": "^6.0.3",
"idb-keyval": "^6.2.1",
+ "jose": "^4.15.2",
"lit-html": "^2.7.2",
"process": "^0.11.10",
"qr-creator": "^1.0.0",
@@ -25,6 +26,7 @@
"zod": "^3.22.3"
},
"devDependencies": {
+ "@dfinity/auth-client": "^0.19.2",
"@types/html-minifier-terser": "^7.0.0",
"@types/selenium-standalone": "^7.0.1",
"@types/ua-parser-js": "^0.7.36",
@@ -761,6 +763,20 @@
"@dfinity/principal": "^0.19.2"
}
},
+ "node_modules/@dfinity/auth-client": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@dfinity/auth-client/-/auth-client-0.19.2.tgz",
+ "integrity": "sha512-aQQ60Y6fuV8849ZzXDwSfJlHO5mWEnzscYVEqveCSDTbRCMw0RV/PKGmbNuM2mIes3ep+LWpq3IQRR56lYZWUA==",
+ "dev": true,
+ "dependencies": {
+ "idb": "^7.0.2"
+ },
+ "peerDependencies": {
+ "@dfinity/agent": "^0.19.2",
+ "@dfinity/identity": "^0.19.2",
+ "@dfinity/principal": "^0.19.2"
+ }
+ },
"node_modules/@dfinity/candid": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@dfinity/candid/-/candid-0.19.2.tgz",
@@ -5879,6 +5895,12 @@
"node": ">=12.20.0"
}
},
+ "node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true
+ },
"node_modules/idb-keyval": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
@@ -6501,6 +6523,14 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
+ "node_modules/jose": {
+ "version": "4.15.4",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz",
+ "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-string-escape": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz",
@@ -12567,6 +12597,15 @@
"simple-cbor": "^0.4.1"
}
},
+ "@dfinity/auth-client": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@dfinity/auth-client/-/auth-client-0.19.2.tgz",
+ "integrity": "sha512-aQQ60Y6fuV8849ZzXDwSfJlHO5mWEnzscYVEqveCSDTbRCMw0RV/PKGmbNuM2mIes3ep+LWpq3IQRR56lYZWUA==",
+ "dev": true,
+ "requires": {
+ "idb": "^7.0.2"
+ }
+ },
"@dfinity/candid": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@dfinity/candid/-/candid-0.19.2.tgz",
@@ -16402,6 +16441,12 @@
"integrity": "sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ==",
"dev": true
},
+ "idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true
+ },
"idb-keyval": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz",
@@ -16807,6 +16852,11 @@
}
}
},
+ "jose": {
+ "version": "4.15.4",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.4.tgz",
+ "integrity": "sha512-W+oqK4H+r5sITxfxpSU+MMdr/YSWGvgZMQDIsNoBDGGy4i7GBPTtvFKibQzW06n3U3TqHjhvBJsirShsEJ6eeQ=="
+ },
"js-string-escape": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/js-string-escape/-/js-string-escape-1.0.1.tgz",
diff --git a/package.json b/package.json
index bb71982cd1..6de708257e 100644
--- a/package.json
+++ b/package.json
@@ -15,9 +15,11 @@
"watch:showcase": "astro check --root ./src/showcase --watch",
"watch": "npm run check -- --watch",
"opts": "NODE_OPTIONS='--loader ts-node/esm --experimental-specifier-resolution=node' \"$@\"",
- "generate": "npm run generate:types && npm run generate:js",
+ "generate": "npm run generate:types && npm run generate:js && npm run generate:types-issuer && npm run generate:js-issuer",
"generate:types": "didc bind ./src/internet_identity/internet_identity.did -t ts > src/frontend/generated/internet_identity_types.d.ts",
"generate:js": "didc bind ./src/internet_identity/internet_identity.did -t js > src/frontend/generated/internet_identity_idl.js",
+ "generate:types-issuer": "didc bind ./demos/vc_issuer/vc_issuer.did -t ts > src/frontend/generated/vc_issuer_types.d.ts",
+ "generate:js-issuer": "didc bind ./demos/vc_issuer/vc_issuer.did -t js > src/frontend/generated/vc_issuer_idl.js",
"build:showcase": "tsc --noEmit && astro check --root ./src/showcase && astro build --root ./src/showcase",
"preview:showcase": "astro preview --root ./src/showcase",
"screenshots": "npm run opts -- ./src/frontend/screenshots.ts",
@@ -30,6 +32,7 @@
"format-check": "prettier --check src/showcase src/frontend tsconfig.json .eslintrc.json vite.config.ts vite.plugins.ts vitest.config.ts demos"
},
"devDependencies": {
+ "@dfinity/auth-client": "^0.19.2",
"@types/html-minifier-terser": "^7.0.0",
"@types/selenium-standalone": "^7.0.1",
"@types/ua-parser-js": "^0.7.36",
@@ -60,6 +63,7 @@
"bip39": "^3.0.4",
"buffer": "^6.0.3",
"idb-keyval": "^6.2.1",
+ "jose": "^4.15.2",
"lit-html": "^2.7.2",
"process": "^0.11.10",
"qr-creator": "^1.0.0",
diff --git a/src/frontend/generated/vc_issuer_idl.js b/src/frontend/generated/vc_issuer_idl.js
new file mode 100644
index 0000000000..4617408ac0
--- /dev/null
+++ b/src/frontend/generated/vc_issuer_idl.js
@@ -0,0 +1,79 @@
+export const idlFactory = ({ IDL }) => {
+ const Icrc21ConsentPreferences = IDL.Record({ 'language' : IDL.Text });
+ const Icrc21ConsentMessageRequest = IDL.Record({
+ 'arg' : IDL.Vec(IDL.Nat8),
+ 'method' : IDL.Text,
+ 'preferences' : Icrc21ConsentPreferences,
+ });
+ const Icrc21ConsentInfo = IDL.Record({
+ 'consent_message' : IDL.Text,
+ 'language' : IDL.Text,
+ });
+ const Icrc21ErrorInfo = IDL.Record({
+ 'description' : IDL.Text,
+ 'error_code' : IDL.Nat64,
+ });
+ const Icrc21Error = IDL.Variant({
+ 'GenericError' : Icrc21ErrorInfo,
+ 'MalformedCall' : Icrc21ErrorInfo,
+ 'NotSupported' : Icrc21ErrorInfo,
+ 'Forbidden' : Icrc21ErrorInfo,
+ });
+ const Icrc21ConsentMessageResponse = IDL.Variant({
+ 'Ok' : Icrc21ConsentInfo,
+ 'Err' : Icrc21Error,
+ });
+ const SignedIdAlias = IDL.Record({
+ 'credential_jws' : IDL.Text,
+ 'id_alias' : IDL.Principal,
+ 'id_dapp' : IDL.Principal,
+ });
+ const CredentialSpec = IDL.Record({ 'info' : IDL.Text });
+ const GetCredentialRequest = IDL.Record({
+ 'signed_id_alias' : SignedIdAlias,
+ 'prepared_context' : IDL.Opt(IDL.Vec(IDL.Nat8)),
+ 'credential_spec' : CredentialSpec,
+ });
+ const IssuedCredentialData = IDL.Record({ 'vc_jws' : IDL.Text });
+ const IssueCredentialError = IDL.Variant({
+ 'Internal' : IDL.Text,
+ 'SignatureNotFound' : IDL.Text,
+ 'InvalidIdAlias' : IDL.Text,
+ 'UnauthorizedSubject' : IDL.Text,
+ 'UnknownSubject' : IDL.Text,
+ });
+ const GetCredentialResponse = IDL.Variant({
+ 'Ok' : IssuedCredentialData,
+ 'Err' : IssueCredentialError,
+ });
+ const PrepareCredentialRequest = IDL.Record({
+ 'signed_id_alias' : SignedIdAlias,
+ 'credential_spec' : CredentialSpec,
+ });
+ const PreparedCredentialData = IDL.Record({
+ 'prepared_context' : IDL.Opt(IDL.Vec(IDL.Nat8)),
+ });
+ const PrepareCredentialResponse = IDL.Variant({
+ 'Ok' : PreparedCredentialData,
+ 'Err' : IssueCredentialError,
+ });
+ return IDL.Service({
+ 'add_employee' : IDL.Func([IDL.Principal], [IDL.Text], []),
+ 'consent_message' : IDL.Func(
+ [Icrc21ConsentMessageRequest],
+ [Icrc21ConsentMessageResponse],
+ [],
+ ),
+ 'get_credential' : IDL.Func(
+ [GetCredentialRequest],
+ [GetCredentialResponse],
+ ['query'],
+ ),
+ 'prepare_credential' : IDL.Func(
+ [PrepareCredentialRequest],
+ [PrepareCredentialResponse],
+ [],
+ ),
+ });
+};
+export const init = ({ IDL }) => { return []; };
diff --git a/src/frontend/generated/vc_issuer_types.d.ts b/src/frontend/generated/vc_issuer_types.d.ts
new file mode 100644
index 0000000000..600f7f4944
--- /dev/null
+++ b/src/frontend/generated/vc_issuer_types.d.ts
@@ -0,0 +1,63 @@
+import type { Principal } from '@dfinity/principal';
+import type { ActorMethod } from '@dfinity/agent';
+
+export interface CredentialSpec { 'info' : string }
+export interface GetCredentialRequest {
+ 'signed_id_alias' : SignedIdAlias,
+ 'prepared_context' : [] | [Uint8Array | number[]],
+ 'credential_spec' : CredentialSpec,
+}
+export type GetCredentialResponse = { 'Ok' : IssuedCredentialData } |
+ { 'Err' : IssueCredentialError };
+export interface Icrc21ConsentInfo {
+ 'consent_message' : string,
+ 'language' : string,
+}
+export interface Icrc21ConsentMessageRequest {
+ 'arg' : Uint8Array | number[],
+ 'method' : string,
+ 'preferences' : Icrc21ConsentPreferences,
+}
+export type Icrc21ConsentMessageResponse = { 'Ok' : Icrc21ConsentInfo } |
+ { 'Err' : Icrc21Error };
+export interface Icrc21ConsentPreferences { 'language' : string }
+export type Icrc21Error = { 'GenericError' : Icrc21ErrorInfo } |
+ { 'MalformedCall' : Icrc21ErrorInfo } |
+ { 'NotSupported' : Icrc21ErrorInfo } |
+ { 'Forbidden' : Icrc21ErrorInfo };
+export interface Icrc21ErrorInfo {
+ 'description' : string,
+ 'error_code' : bigint,
+}
+export type IssueCredentialError = { 'Internal' : string } |
+ { 'SignatureNotFound' : string } |
+ { 'InvalidIdAlias' : string } |
+ { 'UnauthorizedSubject' : string } |
+ { 'UnknownSubject' : string };
+export interface IssuedCredentialData { 'vc_jws' : string }
+export interface PrepareCredentialRequest {
+ 'signed_id_alias' : SignedIdAlias,
+ 'credential_spec' : CredentialSpec,
+}
+export type PrepareCredentialResponse = { 'Ok' : PreparedCredentialData } |
+ { 'Err' : IssueCredentialError };
+export interface PreparedCredentialData {
+ 'prepared_context' : [] | [Uint8Array | number[]],
+}
+export interface SignedIdAlias {
+ 'credential_jws' : string,
+ 'id_alias' : Principal,
+ 'id_dapp' : Principal,
+}
+export interface _SERVICE {
+ 'add_employee' : ActorMethod<[Principal], string>,
+ 'consent_message' : ActorMethod<
+ [Icrc21ConsentMessageRequest],
+ Icrc21ConsentMessageResponse
+ >,
+ 'get_credential' : ActorMethod<[GetCredentialRequest], GetCredentialResponse>,
+ 'prepare_credential' : ActorMethod<
+ [PrepareCredentialRequest],
+ PrepareCredentialResponse
+ >,
+}
diff --git a/src/frontend/src/flows/verifiableCredentials/allow.ts b/src/frontend/src/flows/verifiableCredentials/allow.ts
new file mode 100644
index 0000000000..54b67cb966
--- /dev/null
+++ b/src/frontend/src/flows/verifiableCredentials/allow.ts
@@ -0,0 +1,71 @@
+import { mainWindow } from "$src/components/mainWindow";
+import { mount, renderPage } from "$src/utils/lit-html";
+import { TemplateResult, html } from "lit-html";
+
+/* VC credential allow/deny screen */
+
+const allowTemplate = ({
+ relyingOrigin,
+ providerOrigin,
+ onAllow,
+ onCancel,
+ scrollToTop = false,
+}: {
+ relyingOrigin: string;
+ providerOrigin: string;
+ onAllow: () => void;
+ onCancel: () => void;
+ /* put the page into view */
+ scrollToTop?: boolean;
+}): TemplateResult => {
+ const slot = html`
+ window.scrollTo(0, 0)) : undefined}>
+ Credential Access Request
+
+
+ Allow verifying credential
+ ${providerOrigin} with
+ ${relyingOrigin}?
+
+
+
+
+
+
+ `;
+
+ return mainWindow({
+ showFooter: false,
+ showLogo: false,
+ slot,
+ });
+};
+
+export const allowPage = renderPage(allowTemplate);
+
+// Prompt to allow verifying credentials
+export const allow = ({
+ relyingOrigin,
+ providerOrigin,
+}: {
+ relyingOrigin: string;
+ providerOrigin: string;
+}): Promise<"allowed" | "canceled"> => {
+ return new Promise((resolve) =>
+ allowPage({
+ relyingOrigin,
+ providerOrigin,
+ onAllow: () => resolve("allowed"),
+ onCancel: () => resolve("canceled"),
+ scrollToTop: true,
+ })
+ );
+};
diff --git a/src/frontend/src/flows/verifiableCredentials/index.ts b/src/frontend/src/flows/verifiableCredentials/index.ts
new file mode 100644
index 0000000000..210932632e
--- /dev/null
+++ b/src/frontend/src/flows/verifiableCredentials/index.ts
@@ -0,0 +1,219 @@
+import { SignedIdAlias } from "$generated/internet_identity_types";
+import { IssuedCredentialData } from "$generated/vc_issuer_types";
+import { authenticateBox } from "$src/components/authenticateBox";
+import { withLoader } from "$src/components/loader";
+import { showSpinner } from "$src/components/spinner";
+import { toast } from "$src/components/toast";
+import { getDapps } from "$src/flows/dappsExplorer/dapps";
+import { authnTemplateManage } from "$src/flows/manage";
+import { I18n } from "$src/i18n";
+import { AuthenticatedConnection, Connection } from "$src/utils/iiConnection";
+import { base64url } from "jose";
+import { allow } from "./allow";
+import { VcVerifiablePresentation, vcProtocol } from "./postMessageInterface";
+import { VcIssuer } from "./vcIssuer";
+
+const dapps = getDapps();
+
+// XXX the VC flow currently only supports the happy path
+const giveUp = async (message?: string): Promise => {
+ console.error(message);
+ toast.error("Error was encountered, giving up: " + message);
+ return await new Promise((_) => {
+ /* halt forever */
+ });
+};
+
+// The "verifiable credentials" approval flow
+export const vcFlow = async ({ connection }: { connection: Connection }) => {
+ await vcProtocol({
+ /* Show some spinners while we wait for more data or for an action to complete */
+ onProgress: (x) => {
+ if (x === "waiting") {
+ return showSpinner({
+ message: "Waiting for info",
+ });
+ }
+
+ if (x === "verifying") {
+ return showSpinner({
+ message: "Verifying",
+ });
+ }
+ x satisfies never;
+ },
+
+ /* How the credentials are actually verified */
+ verifyCredentials: async ({
+ request: {
+ credentialSubject: givenP_RP,
+ issuer: { issuerOrigin, credentialId },
+ },
+ rpOrigin,
+ }) => {
+ // Go through the login flow, potentially creating an anchor.
+ const { connection: authenticatedConnection } = await authenticateBox({
+ connection,
+ i18n: new I18n(),
+ templates: authnTemplateManage({ dapps }),
+ });
+
+ // Compute the user's principal on the RP and ensure it matches what the RP sent us
+ const computedP_RP = await authenticatedConnection.getPrincipal({
+ origin: rpOrigin,
+ });
+
+ const pAliasPending = getAliasCredentials({
+ rpOrigin,
+ issuerOrigin,
+ authenticatedConnection,
+ });
+ if (computedP_RP.compareTo(givenP_RP) !== "eq") {
+ return giveUp(
+ [
+ "bad principals",
+ computedP_RP.toString(),
+ givenP_RP.toString(),
+ ].join(", ")
+ );
+ }
+
+ // Ask user to confirm the verification of credentials
+ const allowed = await allow({
+ relyingOrigin: rpOrigin,
+ providerOrigin: issuerOrigin,
+ });
+ if (allowed === "canceled") {
+ return giveUp("canceled");
+ }
+ allowed satisfies "allowed";
+
+ // Grab the credentials from the issuer
+ const [issuedCredential, pAlias] = await withLoader(async () => {
+ const issuerCanisterId = lookupCanister({ origin: issuerOrigin });
+ const pAlias = await pAliasPending;
+
+ const issuedCredential = await issueCredential({
+ issuerCanisterId,
+ issuerAliasCredential: pAlias.issuerAliasCredential,
+ credentialId,
+ });
+ return [issuedCredential, pAlias];
+ });
+
+ // Create the presentation and return it to the RP
+ return createPresentation({
+ rpAliasCredential: pAlias.rpAliasCredential,
+ issuedCredential,
+ });
+ },
+ });
+};
+
+const issuerCanisterId: string = "bw4dl-smaaa-aaaaa-qaacq-cai";
+
+const lookupCanister = ({ origin: _origin }: { origin: string }): string => {
+ // XXX: my locally installed issuer
+ return issuerCanisterId;
+};
+
+const getAliasCredentials = async ({
+ authenticatedConnection,
+ issuerOrigin,
+ rpOrigin,
+}: {
+ issuerOrigin: string;
+ rpOrigin: string;
+ authenticatedConnection: AuthenticatedConnection;
+}): Promise<{
+ rpAliasCredential: SignedIdAlias;
+ issuerAliasCredential: SignedIdAlias;
+}> => {
+ const preparedIdAlias = await authenticatedConnection.prepareIdAlias({
+ issuerOrigin,
+ rpOrigin,
+ });
+
+ if ("error" in preparedIdAlias) {
+ return giveUp("Could not prepare alias");
+ }
+
+ const result = await authenticatedConnection.getIdAlias({
+ preparedIdAlias,
+ issuerOrigin,
+ rpOrigin,
+ });
+
+ if ("error" in result) {
+ return giveUp("Could not get alias");
+ }
+
+ const {
+ rp_id_alias_credential: rpAliasCredential,
+ issuer_id_alias_credential: issuerAliasCredential,
+ } = result;
+
+ return { rpAliasCredential, issuerAliasCredential };
+};
+
+const issueCredential = async ({
+ issuerCanisterId,
+ issuerAliasCredential,
+ credentialId,
+}: {
+ issuerCanisterId: string;
+ issuerAliasCredential: SignedIdAlias;
+ credentialId: string;
+}): Promise => {
+ const vcIssuer = new VcIssuer(issuerCanisterId);
+ const args = {
+ signedIdAlias: issuerAliasCredential,
+ credentialSpec: { info: credentialId },
+ };
+
+ const preparedCredential = await vcIssuer.prepareCredential(args);
+
+ if ("error" in preparedCredential) {
+ return giveUp("Could not prepare credential");
+ }
+
+ const issuedCredential = await vcIssuer.getCredential({
+ ...args,
+ preparedCredential,
+ });
+
+ if ("error" in issuedCredential) {
+ return giveUp("Could not issue credential");
+ }
+
+ return issuedCredential;
+};
+
+const createPresentation = ({
+ rpAliasCredential,
+ issuedCredential,
+}: {
+ rpAliasCredential: SignedIdAlias;
+ issuedCredential: IssuedCredentialData;
+}): VcVerifiablePresentation["result"] => {
+ // The simplest JWT header, with no algorithm specified since we don't sign the payload
+ const headerObj = { typ: "JWT", alg: "none" };
+
+ const payloadObj = {
+ iss: `did:icp:${issuerCanisterId}` /* JWT Issuer is set to the issuer's canister ID as per spec */,
+ vp: {
+ "@context": "https://www.w3.org/2018/credentials/v1",
+ type: "VerifiablePresentation",
+ verifiableCredential: [
+ rpAliasCredential.credential_jws satisfies string,
+ issuedCredential.vc_jws satisfies string,
+ ] /* spec dictates first the alias creds, then the VC */,
+ },
+ };
+
+ const header = base64url.encode(JSON.stringify(headerObj));
+ const payload = base64url.encode(JSON.stringify(payloadObj));
+ const signature = "";
+
+ return { verifiablePresentation: [header, payload, signature].join(".") };
+};
diff --git a/src/frontend/src/flows/verifiableCredentials/postMessageInterface.ts b/src/frontend/src/flows/verifiableCredentials/postMessageInterface.ts
new file mode 100644
index 0000000000..c4d963885b
--- /dev/null
+++ b/src/frontend/src/flows/verifiableCredentials/postMessageInterface.ts
@@ -0,0 +1,122 @@
+import { toast } from "$src/components/toast";
+import { Principal } from "@dfinity/principal";
+import { z } from "zod";
+
+export const VcFlowReady = {
+ jsonrpc: "2.0",
+ method: "vc-flow-ready",
+};
+
+const zodPrincipal = z.string().transform((val, ctx) => {
+ let principal;
+ try {
+ principal = Principal.fromText(val);
+ } catch {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, message: "Not a principal " });
+ return z.NEVER;
+ }
+
+ return principal;
+});
+
+// https://www.jsonrpc.org/specification
+// https://github.com/dfinity/internet-identity/blob/vc-mvp/docs/vc-spec.md#identity-provider-api
+export const VcFlowRequest = z.object({
+ id: z.union([
+ z.number(),
+ z.string(),
+ ]) /* Slightly lax; in principle jsonrpc does not allow fractional numbers as id */,
+ jsonrpc: z.literal("2.0"),
+ method: z.literal("request_credential"),
+ params: z.object({
+ issuer: z.object({
+ issuerOrigin: z
+ .string()
+ .url() /* XXX: we limit to URLs, but in practice should even be an origin */,
+ credentialId: z.string(),
+ }),
+ credentialSubject: zodPrincipal,
+ }),
+});
+
+// The wire format of a VC flow request
+export type VcFlowRequestWire = z.input;
+export type VcFlowRequest = z.output;
+
+export type VcVerifiablePresentation = {
+ id: VcFlowRequest["id"];
+ jsonrpc: "2.0";
+ result: {
+ verifiablePresentation: string;
+ };
+};
+
+export const vcProtocol = async ({
+ onProgress,
+ verifyCredentials,
+}: {
+ onProgress: (state: "waiting" | "verifying") => void;
+ verifyCredentials: (args: {
+ request: VcFlowRequest["params"];
+ rpOrigin: string;
+ }) => Promise;
+}) => {
+ if (window.opener === null) {
+ // If there's no `window.opener` a user has manually navigated to "/vc-flow".
+ // Signal that there will never be an authentication request incoming.
+ return "orphan";
+ }
+
+ // Send a message to indicate we're ready.
+ // NOTE: Because `window.opener.origin` cannot be accessed, this message
+ // is sent with "*" as the target origin. This is safe as no sensitive
+ // information is being communicated here.
+ window.opener.postMessage(VcFlowReady, "*");
+
+ onProgress("waiting");
+
+ const { origin, request } = await waitForRequest();
+ const reqId = request.id;
+
+ onProgress("verifying");
+
+ const result = await verifyCredentials({
+ request: request.params,
+ rpOrigin: origin,
+ });
+
+ window.opener.postMessage(
+ {
+ id: reqId,
+ jsonrpc: "2.0",
+ result,
+ } satisfies VcVerifiablePresentation,
+ origin
+ );
+};
+
+const waitForRequest = (): Promise<{
+ request: VcFlowRequest;
+ origin: string;
+}> => {
+ return new Promise((resolve) => {
+ const messageEventHandler = (evnt: MessageEvent) => {
+ const message: unknown = evnt.data;
+ const result = VcFlowRequest.safeParse(message);
+
+ if (!result.success) {
+ const message = `Unexpected error: flow request ` + result.error;
+ console.error(message);
+ toast.error(message);
+ return; // XXX: this just waits further; correct?
+ }
+
+ window.removeEventListener("message", messageEventHandler);
+
+ resolve({ request: result.data, origin: evnt.origin });
+ };
+
+ // Set up an event listener for receiving messages from the client.
+ window.addEventListener("message", messageEventHandler);
+ });
+};
diff --git a/src/frontend/src/flows/verifiableCredentials/prompt.ts b/src/frontend/src/flows/verifiableCredentials/prompt.ts
deleted file mode 100644
index 1100cb09eb..0000000000
--- a/src/frontend/src/flows/verifiableCredentials/prompt.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import { caretDownIcon, verifyIcon } from "$src/components/icons";
-import { mainWindow } from "$src/components/mainWindow";
-import { KnownDapp } from "$src/flows/dappsExplorer/dapps";
-import { I18n } from "$src/i18n";
-import { IIWebAuthnIdentity } from "$src/utils/iiConnection";
-import { mount, renderPage } from "$src/utils/lit-html";
-import { Chan } from "$src/utils/utils";
-import { TemplateResult, html } from "lit-html";
-import { asyncReplace } from "lit-html/directives/async-replace.js";
-import { vcStepper } from "./stepper";
-
-/* Anchor construction component (for creating WebAuthn credentials) */
-
-const promptTemplate = ({
- _i18n,
- cancel,
- userNumber,
- knownDapp,
- scrollToTop = false,
-}: {
- _i18n: I18n;
- cancel: () => void;
- userNumber: bigint;
- knownDapp: KnownDapp;
- /* put the page into view */
- scrollToTop?: boolean;
-}): TemplateResult => {
- const slot = html`
- ${vcStepper({ current: "prompt" })}
- window.scrollTo(0, 0)) : undefined}>
- ${userNumber},
-
- ${mkChasm({
- image: knownDapp.logoSrc,
- name: knownDapp.name,
- message: html`Some context about ${knownDapp.name}`,
- })}
-
-
- This app would like to:
-
-
-
-
-
-
-
- `;
-
- return mainWindow({
- showFooter: false,
- showLogo: false,
- slot,
- });
-};
-
-export const promptPage = renderPage(promptTemplate);
-
-// Prompt the user to create a WebAuthn identity
-export const prompt = ({
- userNumber,
- knownDapp,
-}: {
- userNumber: bigint;
- knownDapp: KnownDapp;
-}): Promise => {
- return new Promise((resolve) =>
- promptPage({
- _i18n: new I18n(),
- userNumber,
- knownDapp,
- cancel: () => resolve("canceled"),
- scrollToTop: true,
- })
- );
-};
-
-/** Options to display a "chasm" in the authbox */
-type ChasmOpts = {
- image: string;
- name: string;
- message: TemplateResult;
-};
-
-const mkChasm = ({ image, name, message }: ChasmOpts): TemplateResult => {
- /* Toggle the chasm open/closed */
- const ariaExpanded = new Chan(false);
- const chasmToggle = () => ariaExpanded.send(!ariaExpanded.latest);
- const btnFlipped = ariaExpanded.map((expanded) =>
- expanded ? "c-chasm__button--flipped" : undefined
- );
-
- return html`
- chasmToggle()}
- >
-
-
-
${name}
-
${caretDownIcon}
-
-
-
- `;
-};
diff --git a/src/frontend/src/flows/verifiableCredentials/select.ts b/src/frontend/src/flows/verifiableCredentials/select.ts
deleted file mode 100644
index a3829e9fab..0000000000
--- a/src/frontend/src/flows/verifiableCredentials/select.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import { hackerIcon } from "$src/components/icons";
-import { mainWindow } from "$src/components/mainWindow";
-import { innerSpinnerTemplate } from "$src/components/spinner";
-import { KnownDapp } from "$src/flows/dappsExplorer/dapps";
-import { I18n } from "$src/i18n";
-import { mount, renderPage } from "$src/utils/lit-html";
-import { Chan, NonEmptyArray } from "$src/utils/utils";
-import { TemplateResult, html } from "lit-html";
-import { asyncReplace } from "lit-html/directives/async-replace.js";
-import { vcStepper } from "./stepper";
-
-/* Anchor construction component (for creating WebAuthn credentials) */
-
-const selectTemplate = ({
- _i18n,
- userNumber,
- relying,
- providers,
- verify,
- onContinue,
- scrollToTop = false,
-}: {
- _i18n: I18n;
- userNumber: bigint;
- relying: { dapp: KnownDapp; reason: string };
- providers: NonEmptyArray;
- verify: (dapp: KnownDapp) => Promise;
- onContinue: (result: T) => void;
- /* put the page into view */
- scrollToTop?: boolean;
-}): TemplateResult => {
- const verifying = new Chan(false);
- const slot = html`
- window.scrollTo(0, 0)) : undefined}>
- ${userNumber},
- ${hackerIcon}
-
- select the service that you want to certify that ${relying.reason}.
- ${relying.dapp.name} will know which service you choose.
-
-
-
- ${providers.map(
- (provider) => html`
- -
- ${provider.name}
-
-
- `
- )}
-
-
- `;
-
- const spinner = html`
-
- ${innerSpinnerTemplate()}
-
- Hang tight, getting verification
-
-
- `;
-
- const content = verifying.map((verifying) => (verifying ? spinner : slot));
-
- return mainWindow({
- showFooter: false,
- showLogo: false,
- slot: html` ${vcStepper({ current: "select" })} ${asyncReplace(content)} `,
- });
-};
-
-export const selectPage = (
- props: Parameters>[0],
- container?: HTMLElement
-) => renderPage(selectTemplate)(props, container);
-
-// Prompt the user to create a WebAuthn identity
-export const select = ({
- userNumber,
- relying,
- providers,
- verify,
-}: {
- userNumber: bigint;
- relying: { dapp: KnownDapp; reason: string };
- providers: NonEmptyArray;
- verify: (dapp: KnownDapp) => Promise;
-}): Promise => {
- return new Promise((resolve) =>
- selectPage({
- _i18n: new I18n(),
- userNumber,
- relying,
- providers,
- verify,
- onContinue: (result: T) => resolve(result),
- scrollToTop: true,
- })
- );
-};
diff --git a/src/frontend/src/flows/verifiableCredentials/stepper.ts b/src/frontend/src/flows/verifiableCredentials/stepper.ts
deleted file mode 100644
index 59c5a57423..0000000000
--- a/src/frontend/src/flows/verifiableCredentials/stepper.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { checkmarkIcon } from "$src/components/icons";
-import { html } from "lit-html";
-
-export const vcStepper = ({
- current,
-}: {
- current: "prompt" | "select" | "finish";
-}) => html`
-
-
-
-
- -
- ${checkmarkIcon}
-
-
-
-`;
diff --git a/src/frontend/src/flows/verifiableCredentials/vcIssuer.ts b/src/frontend/src/flows/verifiableCredentials/vcIssuer.ts
new file mode 100644
index 0000000000..ffa5e0f70c
--- /dev/null
+++ b/src/frontend/src/flows/verifiableCredentials/vcIssuer.ts
@@ -0,0 +1,83 @@
+import { SignedIdAlias } from "$generated/internet_identity_types";
+import { idlFactory as vc_issuer_idl } from "$generated/vc_issuer_idl";
+import {
+ CredentialSpec,
+ IssuedCredentialData,
+ PreparedCredentialData,
+ _SERVICE,
+} from "$generated/vc_issuer_types";
+import { features } from "$src/features";
+import { Actor, ActorSubclass, HttpAgent } from "@dfinity/agent";
+
+import { inferHost } from "$src/utils/iiConnection";
+
+export class VcIssuer {
+ public constructor(readonly canisterId: string) {}
+
+ // Create an actor representing the backend
+ createActor = async (): Promise> => {
+ const agent = new HttpAgent({
+ // TODO: should the agent ever be authenticated?
+ host: inferHost(),
+ });
+
+ // Only fetch the root key when we're not in prod
+ if (features.FETCH_ROOT_KEY) {
+ await agent.fetchRootKey();
+ }
+ const actor = Actor.createActor<_SERVICE>(vc_issuer_idl, {
+ agent,
+ canisterId: this.canisterId,
+ });
+ return actor;
+ };
+
+ prepareCredential = async ({
+ signedIdAlias,
+ credentialSpec,
+ }: {
+ signedIdAlias: SignedIdAlias;
+ credentialSpec: CredentialSpec;
+ }): Promise => {
+ const actor = await this.createActor();
+
+ const result = await actor.prepare_credential({
+ signed_id_alias: signedIdAlias,
+ credential_spec: credentialSpec,
+ });
+
+ // TODO: proper error handling
+ if ("Err" in result) {
+ console.error("wops");
+ return { error: "wops" };
+ }
+
+ return result.Ok;
+ };
+
+ getCredential = async ({
+ signedIdAlias,
+ preparedCredential,
+ credentialSpec,
+ }: {
+ signedIdAlias: SignedIdAlias;
+ credentialSpec: CredentialSpec;
+ preparedCredential: PreparedCredentialData;
+ }): Promise => {
+ const actor = await this.createActor();
+
+ const result = await actor.get_credential({
+ signed_id_alias: signedIdAlias,
+ prepared_context: preparedCredential.prepared_context,
+ credential_spec: credentialSpec,
+ });
+
+ // TODO: proper error handling
+ if ("Err" in result) {
+ console.error("wops");
+ return { error: "wops" };
+ }
+
+ return result.Ok;
+ };
+}
diff --git a/src/frontend/src/index.ts b/src/frontend/src/index.ts
index 3256908244..2f5b3d351e 100644
--- a/src/frontend/src/index.ts
+++ b/src/frontend/src/index.ts
@@ -6,6 +6,7 @@ import { registerTentativeDevice } from "./flows/addDevice/welcomeView/registerT
import { authFlowAuthorize } from "./flows/authorize";
import { compatibilityNotice } from "./flows/compatibilityNotice";
import { authFlowManage, renderManageWarmup } from "./flows/manage";
+import { vcFlow } from "./flows/verifiableCredentials";
import "./styles/main.css";
import { getAddDeviceAnchor } from "./utils/addDeviceLink";
import { checkRequiredFeatures } from "./utils/featureDetection";
@@ -87,10 +88,12 @@ const init = async () => {
// https://github.com/dfinity/internet-identity#build-features
showWarningIfNecessary();
+ const [path] = window.location.pathname.split("/").filter(Boolean);
+
// Redirect to the FAQ
// The canister should already be handling this with a 301 when serving "/faq", this is just a safety
// measure.
- if (window.location.pathname === "/faq") {
+ if (path === "faq") {
const faqUrl = "https://identitysupport.dfinity.org/hc/en-us";
window.location.replace(faqUrl);
}
@@ -103,6 +106,11 @@ const init = async () => {
// Prepare the actor/connection to talk to the canister
const connection = new Connection(readCanisterId());
+ // Check for VC flow
+ if (path === "vc-flow") {
+ return vcFlow({ connection });
+ }
+
// Figure out if user is trying to add a device. If so, use the anchor from the URL.
const addDeviceAnchor = getAddDeviceAnchor();
if (nonNullish(addDeviceAnchor)) {
diff --git a/src/frontend/src/utils/iiConnection.ts b/src/frontend/src/utils/iiConnection.ts
index f3dece5ce1..8286fc2742 100644
--- a/src/frontend/src/utils/iiConnection.ts
+++ b/src/frontend/src/utils/iiConnection.ts
@@ -12,8 +12,10 @@ import {
DeviceKey,
FrontendHostname,
GetDelegationResponse,
+ IdAliasCredentials,
IdentityAnchorInfo,
KeyType,
+ PreparedIdAlias,
PublicKey,
Purpose,
RegisterResponse,
@@ -469,6 +471,11 @@ export class AuthenticatedConnection extends Connection {
return await actor.get_anchor_info(this.userNumber);
};
+ getPrincipal = async ({ origin }: { origin: string }): Promise => {
+ const actor = await this.getActor();
+ return await actor.get_principal(this.userNumber, origin);
+ };
+
enterDeviceRegistrationMode = async (): Promise => {
const actor = await this.getActor();
return await actor.enter_device_registration_mode(this.userNumber);
@@ -567,6 +574,72 @@ export class AuthenticatedConnection extends Connection {
return { error: e };
}
};
+
+ prepareIdAlias = async ({
+ issuerOrigin,
+ rpOrigin,
+ }: {
+ issuerOrigin: string;
+ rpOrigin: string;
+ }): Promise => {
+ const actor = await this.getActor();
+ const userNumber = this.userNumber;
+ const [result] = await actor.prepare_id_alias({
+ issuer: issuerOrigin,
+ relying_party: rpOrigin,
+ identity_number: userNumber,
+ });
+
+ if (isNullish(result)) {
+ console.error("canister is drunk");
+ return { error: "canister is drunk" };
+ }
+
+ // TODO: proper error handling
+ if ("authentication_failed" in result) {
+ console.error("wops");
+ return { error: "wops" };
+ }
+
+ return result.ok;
+ };
+
+ getIdAlias = async ({
+ preparedIdAlias,
+ issuerOrigin,
+ rpOrigin,
+ }: {
+ preparedIdAlias: PreparedIdAlias;
+ issuerOrigin: string;
+ rpOrigin: string;
+ }): Promise => {
+ const actor = await this.getActor();
+ const userNumber = this.userNumber;
+
+ const [result] = await actor.get_id_alias({
+ issuer: issuerOrigin,
+ relying_party: rpOrigin,
+ identity_number: userNumber,
+ ...preparedIdAlias,
+ });
+
+ if (isNullish(result)) {
+ console.error("canister is drunk");
+ return { error: "canister is drunk" };
+ }
+
+ // TODO: proper error handling
+ if ("authentication_failed" in result) {
+ console.error("wops");
+ return { error: "wops" };
+ }
+ if ("no_such_credentials" in result) {
+ console.error("wops");
+ return { error: "wops" };
+ }
+
+ return result.ok;
+ };
}
// Reads the "origin" used to infer what domain a FIDO device is available on.
diff --git a/src/frontend/vc-flow/index.html b/src/frontend/vc-flow/index.html
new file mode 100644
index 0000000000..6480a91e53
--- /dev/null
+++ b/src/frontend/vc-flow/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+ Internet Identity
+
+
+
+
+
+
+
+
+
diff --git a/src/showcase/src/pages/[page].astro b/src/showcase/src/pages/[page].astro
index ae8d258c2b..d8d7a670fd 100644
--- a/src/showcase/src/pages/[page].astro
+++ b/src/showcase/src/pages/[page].astro
@@ -54,8 +54,7 @@ export const iiPageNames = [
"showMessage",
"showSpinner",
"addDeviceSuccess",
- "vcPrompt",
- "vcSelect",
+ "vcAllow",
];
export function getStaticPaths() {
diff --git a/src/showcase/src/pages/vc-test-app.astro b/src/showcase/src/pages/vc-test-app.astro
new file mode 100644
index 0000000000..d2b303bbc7
--- /dev/null
+++ b/src/showcase/src/pages/vc-test-app.astro
@@ -0,0 +1,185 @@
+---
+import Layout from "../layouts/Layout.astro";
+---
+
+
+
+
+
+
diff --git a/src/showcase/src/showcase.ts b/src/showcase/src/showcase.ts
index 046a036440..d11ebf2017 100644
--- a/src/showcase/src/showcase.ts
+++ b/src/showcase/src/showcase.ts
@@ -58,8 +58,7 @@ import { NonEmptyArray } from "$src/utils/utils";
import { TemplateResult, html, render } from "lit-html";
import { asyncReplace } from "lit-html/directives/async-replace.js";
-import { promptPage } from "$src/flows/verifiableCredentials/prompt";
-import { selectPage } from "$src/flows/verifiableCredentials/select";
+import { allowPage } from "$src/flows/verifiableCredentials/allow";
const identityBackground = loadIdentityBackground();
@@ -640,46 +639,12 @@ export const iiPages: Record void> = {
deviceAlias: chromeDevice.alias,
onContinue: () => console.log("Continue"),
}),
- vcPrompt: () =>
- promptPage({
- _i18n: i18n,
- userNumber: BigInt(1234),
- knownDapp: openChat,
- cancel: () => console.log("cancel"),
- }),
- vcSelect: () =>
- selectPage({
- _i18n: i18n,
- userNumber: BigInt(1234),
- relying: { dapp: openChat, reason: "you hold an 8 year neuron" },
- verify: async (dapp) => {
- console.log("Verifying through " + dapp.name + "...");
-
- // Hacky button to resolve the fake promise
- await new Promise((resolve) => {
- const closeBtn = document.createElement("button");
- closeBtn.onclick = () => {
- closeBtn.remove();
- resolve();
- };
- closeBtn.classList.add("c-button");
- closeBtn.style.position = "absolute";
- closeBtn.style.inset = "0 0 auto auto";
- closeBtn.style.width = "fit-content";
- closeBtn.style.fontSize = "10px";
- closeBtn.style.padding = "2px 4px";
- closeBtn.style.marginTop = "0";
- closeBtn.innerText = "done";
- closeBtn.dataset.role = "done";
- document.body.appendChild(closeBtn);
- });
- console.log("Done.");
- },
-
- providers: [nnsDapp, juno],
- onContinue: (res) => {
- console.log("Received result", res);
- },
+ vcAllow: () =>
+ allowPage({
+ relyingOrigin: "https://oc.app",
+ providerOrigin: "https://nns.ic0.app",
+ onAllow: () => toast.info(html`Allowed`),
+ onCancel: () => toast.info(html`Canceled`),
}),
};
diff --git a/vite.config.ts b/vite.config.ts
index 8c1183dd60..d1879d1d7d 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -43,6 +43,7 @@ export default defineConfig(({ mode }: UserConfig): UserConfig => {
rollupOptions: {
// Bundle only english words in bip39.
external: /.*\/wordlists\/(?!english).*\.json/,
+ input: ["src/frontend/index.html", "src/frontend/vc-flow/index.html"],
output: {
entryFileNames: `[name].js`,
// II canister only supports resources that contains a single dot in their filenames. qr-creator.js.gz = ok. qr-creator.min.js.gz not ok. qr-creator.es6.min.js.gz no ok.