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: -

    -
  • -
    ${verifyIcon} Anonymously verify that you hold an 8 year neuron
    -
  • -
-

-
-
- - -
- `; - - 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} -
-
-
-
-
-
-

${message}

-
-
-
- `; -}; 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` -
-
    -
  1. -
  2. -
  3. - ${checkmarkIcon} -
  4. -
-
-`; 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.