From ad3cd442ce542b24b42a3411827005b54eb2c48a 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 | 61 +++++ .../src/flows/verifiableCredentials/allow.ts | 71 ++++++ .../src/flows/verifiableCredentials/index.ts | 209 ++++++++++++++++++ .../postMessageInterface.ts | 122 ++++++++++ .../src/flows/verifiableCredentials/prompt.ts | 2 +- .../flows/verifiableCredentials/vcIssuer.ts | 83 +++++++ src/frontend/src/index.ts | 9 + src/frontend/src/utils/iiConnection.ts | 73 ++++++ src/frontend/vc-flow/index.html | 23 ++ src/showcase/src/pages/[page].astro | 1 + src/showcase/src/pages/vc-test-app.astro | 185 ++++++++++++++++ src/showcase/src/showcase.ts | 8 + vite.config.ts | 1 + 16 files changed, 981 insertions(+), 2 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 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..d1ff82581b --- /dev/null +++ b/src/frontend/generated/vc_issuer_types.d.ts @@ -0,0 +1,61 @@ +import type { Principal } from '@dfinity/principal'; +export interface CredentialSpec { 'info' : string } +export interface GetCredentialRequest { + 'signed_id_alias' : SignedIdAlias, + 'prepared_context' : [] | [Array], + 'credential_spec' : CredentialSpec, +} +export type GetCredentialResponse = { 'Ok' : IssuedCredentialData } | + { 'Err' : IssueCredentialError }; +export interface Icrc21ConsentInfo { + 'consent_message' : string, + 'language' : string, +} +export interface Icrc21ConsentMessageRequest { + 'arg' : Array, + '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' : [] | [Array], +} +export interface SignedIdAlias { + 'credential_jws' : string, + 'id_alias' : Principal, + 'id_dapp' : Principal, +} +export interface _SERVICE { + 'add_employee' : (arg_0: Principal) => Promise, + 'consent_message' : (arg_0: Icrc21ConsentMessageRequest) => Promise< + Icrc21ConsentMessageResponse + >, + 'get_credential' : (arg_0: GetCredentialRequest) => Promise< + GetCredentialResponse + >, + 'prepare_credential' : (arg_0: PrepareCredentialRequest) => Promise< + 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..01d631153d --- /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 sharing VerifiedEmployee credential issued by + ${providerOrigin} with + ${relyingOrigin}? +

+ +
+ + +
+ `; + + return mainWindow({ + showFooter: false, + showLogo: false, + slot, + }); +}; + +export const allowPage = renderPage(allowTemplate); + +// Prompt the 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..a1c2ea24d3 --- /dev/null +++ b/src/frontend/src/flows/verifiableCredentials/index.ts @@ -0,0 +1,209 @@ +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(); + +const giveUp = async (message?: string): Promise => { + console.error("Nope " + message); + toast.error("Nope " + message); + return await new Promise((_) => { + /* halt */ + }); +}; + +export const vcFlow = async ({ connection }: { connection: Connection }) => { + await vcProtocol({ + onProgress: (x) => { + if (x === "waiting") { + return showSpinner({ + message: "Waiting for info", + }); + } + + if (x === "verifying") { + return showSpinner({ + message: "Verifying", + }); + } + x satisfies never; + }, + + verifyCredentials: async ({ request, rpOrigin }) => { + // Go through the login flow, potentially creating an anchor. + const { connection: authenticatedConnection } = await authenticateBox({ + connection, + i18n: new I18n(), + templates: authnTemplateManage({ dapps }), + }); + + const { issuerOrigin } = request.issuer; + + const computedP_RP = await authenticatedConnection.getPrincipal({ + origin: rpOrigin, + }); + + const pAliasPending = getAliasCredentials({ + rpOrigin, + issuerOrigin, + authenticatedConnection, + }); + + const givenP_RP = request.credentialSubject; + if (computedP_RP.compareTo(givenP_RP) !== "eq") { + return giveUp( + [ + "bad principals", + computedP_RP.toString(), + givenP_RP.toString(), + ].join(", ") + ); + } + + const allowed = await allow({ + relyingOrigin: rpOrigin, + providerOrigin: issuerOrigin, + }); + if (allowed === "canceled") { + return giveUp("canceled"); + } + allowed satisfies "allowed"; + + const [issuedCredential, pAlias] = await withLoader(async () => { + const issuerCanisterId = lookupCanister({ origin: issuerOrigin }); + const pAlias = await pAliasPending; + + const issuedCredential = await issueCredential({ + issuerCanisterId, + issuerAliasCredential: pAlias.issuerAliasCredential, + credentialId: request.issuer.credentialId, + }); + return [issuedCredential, pAlias]; + }); + + return createPresentation({ + rpAliasCredential: pAlias.rpAliasCredential, + issuedCredential, + }); + }, + }); +}; + +const lookupCanister = ({ origin: _origin }: { origin: string }): string => { + // XXX: my locally installed issuer + return "bw4dl-smaaa-aaaaa-qaacq-cai"; +}; + +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"] => { + // TODO: figure out if this is all that's needed + const headerObj = { typ: "JWT", alg: "none" }; + + // TODO: figure out who's the issue + // TODO: does the order of credentials matter? + const payloadObj = { + iss: "did:icp:bephe-imsta-66z5n-f555b-qqtmh-uom5q-gnr44-ukpid-6oaoe-b5muo-jae", + vp: { + "@context": "https://www.w3.org/2018/credentials/v1", + type: "VerifiablePresentation", + verifiableCredential: [ + rpAliasCredential.credential_jws satisfies string, + issuedCredential.vc_jws satisfies string, + ], + }, + }; + + 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..0ee2f82d33 --- /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.custom((val) => { + if (typeof val !== "string") { + return false; + } + let principal; + try { + principal = Principal.fromText(val); + } catch { + return false; + } + + return principal; +}, "expected 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, + }), +}); + +export type VcFlowRequest = z.infer; + +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 index 1100cb09eb..9adf3cb639 100644 --- a/src/frontend/src/flows/verifiableCredentials/prompt.ts +++ b/src/frontend/src/flows/verifiableCredentials/prompt.ts @@ -66,7 +66,7 @@ const promptTemplate = ({ export const promptPage = renderPage(promptTemplate); -// Prompt the user to create a WebAuthn identity +// TODO: change name export const prompt = ({ userNumber, knownDapp, 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..b763ec6382 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"; @@ -103,6 +104,14 @@ const init = async () => { // Prepare the actor/connection to talk to the canister const connection = new Connection(readCanisterId()); + // Check for VC flow + if ( + window.location.pathname === "/vc-flow" || + window.location.pathname === "/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..e7d4100131 --- /dev/null +++ b/src/frontend/vc-flow/index.html @@ -0,0 +1,23 @@ + + + + + + + + + Internet Identity + + + + + +
+
+ + + + + + diff --git a/src/showcase/src/pages/[page].astro b/src/showcase/src/pages/[page].astro index ae8d258c2b..1ad57777a7 100644 --- a/src/showcase/src/pages/[page].astro +++ b/src/showcase/src/pages/[page].astro @@ -55,6 +55,7 @@ export const iiPageNames = [ "showSpinner", "addDeviceSuccess", "vcPrompt", + "vcAllow", "vcSelect", ]; 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..20048f04bd --- /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..af1ba18276 100644 --- a/src/showcase/src/showcase.ts +++ b/src/showcase/src/showcase.ts @@ -58,6 +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 { allowPage } from "$src/flows/verifiableCredentials/allow"; import { promptPage } from "$src/flows/verifiableCredentials/prompt"; import { selectPage } from "$src/flows/verifiableCredentials/select"; @@ -647,6 +648,13 @@ export const iiPages: Record void> = { knownDapp: openChat, cancel: () => console.log("cancel"), }), + vcAllow: () => + allowPage({ + relyingOrigin: "https://oc.app", + providerOrigin: "https://nns.ic0.app", + onAllow: () => toast.info(html`Allowed`), + onCancel: () => toast.info(html`Canceled`), + }), vcSelect: () => selectPage({ _i18n: i18n, 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.