diff --git a/packages/agent/src/connect.ts b/packages/agent/src/connect.ts index fb19b9983..38d850226 100644 --- a/packages/agent/src/connect.ts +++ b/packages/agent/src/connect.ts @@ -1,10 +1,11 @@ - import type { PushedAuthResponse } from './oidc.js'; -import type { DwnPermissionScope, DwnProtocolDefinition, Web5Agent, Web5ConnectAuthResponse } from './index.js'; +import type { + DwnPermissionScope, + DwnProtocolDefinition, + Web5ConnectAuthResponse, +} from './index.js'; -import { - Oidc, -} from './oidc.js'; +import { Oidc } from './oidc.js'; import { pollWithTtl } from './utils.js'; import { Convert, logger } from '@web5/common'; @@ -13,8 +14,95 @@ import { DidJwk } from '@web5/dids'; import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; /** - * Initiates the wallet connect process. Used when a client wants to obtain - * a did from a provider. + * Settings provided by users who wish to allow their DWA to connect to a wallet + * and either transfer their DID to that wallet (when `exported: true`) + * or transfer a DID from their wallet (without `exported: true`). + */ +export type WalletConnectOptions = { + /** The user friendly name of the app to be displayed when prompting end-user with permission requests. */ + displayName: string; + + /** The URL of the intermediary server which relays messages between the client and provider */ + connectServerUrl: string; + + /** + * The URI of the Provider (wallet).The `onWalletUriReady` will take this wallet + * uri and add a payload to it which will be used to obtain and decrypt from the `request_uri`. + * @example `web5://` or `http://localhost:3000/`. + */ + walletUri: string; + + /** + * The protocols of permissions requested, along with the definition and + * permission scopes for each protocol. + * If `exported` is true these will be created automatically. + */ + permissionRequests: ConnectUserPermissionRequest[]; + + /** + * Can be set to true if the DWA wants to transfer its identity to the wallet + * instead of get an identity from the wallet + */ + exported?: boolean; + + /** + * The Web5 API provides a URI to the wallet based on the `walletUri` plus a query params payload valid for 5 minutes. + * The link can either be used as a deep link on the same device or a QR code for cross device or both. + * The query params are `{ request_uri: string; encryption_key: string; }` + * The wallet will use the `request_uri to contact the intermediary server's `authorize` endpoint + * and pull down the {@link Web5ConnectAuthRequest} and use the `encryption_key` to decrypt it. + * + * @param uri - The URI returned by the web5 connect API to be passed to a provider. + */ + onWalletUriReady: (uri: string) => void; + + /** + * Function that must be provided to submit the pin entered by the user on the client. + * The pin is used to decrypt the {@link Web5ConnectAuthResponse} that was retrieved from the + * token endpoint by the client inside of web5 connect. + * + * @returns A promise that resolves to the PIN as a string. + */ + validatePin: () => Promise; +}; + +/** Used by the WalletConnect protocol to provision a Wallet for the exact permissions its needs */ +export type ConnectPermissionRequest = { + /** + * The definition of the protocol the permissions are being requested for. + * In the event that the protocol is not already installed, the wallet will install this given protocol definition. + */ + protocolDefinition: DwnProtocolDefinition; + + /** The scope of the permissions being requested for the given protocol */ + permissionScopes: DwnPermissionScope[]; +}; + +/** Convenience object passed in by users and normalized to the internally used {@link ConnectPermissionRequest} */ +export type ConnectUserPermissionRequest = Omit< + ConnectPermissionRequest, + 'permissionScopes' +> & { + /** + * Used to create a {@link DwnPermissionScope} for each option provided in this param. + * If undefined defaults to requesting all permissions. + * `configure` is not included by default, as this gives the application a lot of control over the protocol. + */ + permissions?: Permission[]; +}; + +/** Shorthand for the types of permissions that can be requested. */ +type Permission = + | 'write' + | 'read' + | 'delete' + | 'query' + | 'subscribe' + | 'configure'; + +/** + * Called by the DWA. In this workflow the wallet provisions a DID to the DWA. + * The DWA will have access to the data of the DID and be able to act as that DID. */ async function initClient({ displayName, @@ -24,9 +112,25 @@ async function initClient({ onWalletUriReady, validatePin, }: WalletConnectOptions) { - // ephemeral client did for ECDH, signing, verification - // TODO: use separate keys for ECDH vs. sign/verify. could maybe use secp256k1. - const clientDid = await DidJwk.create(); + const normalizedPermissionRequests = permissionRequests.map( + ({ protocolDefinition, permissions }) => + WalletConnect.createPermissionRequestForProtocol({ + definition : protocolDefinition, + permissions : permissions ?? [ + 'read', + 'write', + 'delete', + 'query', + 'subscribe', + ], + }) + ); + + // ephemeral did used for signing, verification + const clientSigningDid = await DidJwk.create(); + + // ephemeral did used for ECDH only + const clientEcdhDid = await DidJwk.create(); // TODO: properly implement PKCE. this implementation is lacking server side validations and more. // https://github.com/TBD54566975/web5-js/issues/829 @@ -40,33 +144,37 @@ async function initClient({ baseURL : connectServerUrl, endpoint : 'callback', }); + console.log('after callback endpoint'); // build the PAR request const request = await Oidc.createAuthRequest({ - client_id : clientDid.uri, + client_id : clientSigningDid.uri, scope : 'openid did:jwk', redirect_uri : callbackEndpoint, - // custom properties: + client_name : displayName, // code_challenge : codeChallengeBase64Url, // code_challenge_method : 'S256', - permissionRequests : permissionRequests, - displayName, + // custom properties: + permissionRequests : normalizedPermissionRequests, }); // Sign the Request Object using the Client DID's signing key. const requestJwt = await Oidc.signJwt({ - did : clientDid, + did : clientSigningDid, data : request, }); if (!requestJwt) { throw new Error('Unable to sign requestObject'); } - // Encrypt the Request Object JWT using the code challenge. + + // Encrypt with symmetric randomBytes and tell counterparty about the future ecdh pub did kid const requestObjectJwe = await Oidc.encryptAuthRequest({ - jwt: requestJwt, + jwt : requestJwt, + kid : clientEcdhDid.document.verificationMethod![0].id, encryptionKey, }); + console.log('after requestobjecjtwe'); // Convert the encrypted Request Object to URLSearchParams for form encoding. const formEncodedRequest = new URLSearchParams({ @@ -85,6 +193,7 @@ async function initClient({ 'Content-Type': 'application/x-www-form-urlencoded', }, }); + console.log('after par'); if (!parResponse.ok) { throw new Error(`${parResponse.status}: ${parResponse.statusText}`); @@ -101,6 +210,7 @@ async function initClient({ 'encryption_key', Convert.uint8Array(encryptionKey).toBase64Url() ); + console.log('after generatedwalleturi'); // call user's callback so they can send the URI to the wallet as they see fit onWalletUriReady(generatedWalletUri.toString()); @@ -119,11 +229,13 @@ async function initClient({ // get the pin from the user and use it as AAD to decrypt const pin = await validatePin(); - const jwt = await Oidc.decryptAuthResponse(clientDid, jwe, pin); + const jwt = await Oidc.decryptWithPin(clientEcdhDid, jwe, pin); const verifiedAuthResponse = (await Oidc.verifyJwt({ jwt, })) as Web5ConnectAuthResponse; + // TODO: export insertion point + return { delegateGrants : verifiedAuthResponse.delegateGrants, delegatePortableDid : verifiedAuthResponse.delegatePortableDid, @@ -133,88 +245,20 @@ async function initClient({ } /** - * Initiates the wallet connect process. Used when a client wants to obtain - * a did from a provider. - */ -export type WalletConnectOptions = { - /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ - displayName: string; - - /** The URL of the intermediary server which relays messages between the client and provider. */ - connectServerUrl: string; - - /** - * The URI of the Provider (wallet).The `onWalletUriReady` will take this wallet - * uri and add a payload to it which will be used to obtain and decrypt from the `request_uri`. - * @example `web5://` or `http://localhost:3000/`. - */ - walletUri: string; - - /** - * The protocols of permissions requested, along with the definition and - * permission scopes for each protocol. The key is the protocol URL and - * the value is an object with the protocol definition and the permission scopes. - */ - permissionRequests: ConnectPermissionRequest[]; - - /** - * The Web5 API provides a URI to the wallet based on the `walletUri` plus a query params payload valid for 5 minutes. - * The link can either be used as a deep link on the same device or a QR code for cross device or both. - * The query params are `{ request_uri: string; encryption_key: string; }` - * The wallet will use the `request_uri to contact the intermediary server's `authorize` endpoint - * and pull down the {@link Web5ConnectAuthRequest} and use the `encryption_key` to decrypt it. - * - * @param uri - The URI returned by the web5 connect API to be passed to a provider. - */ - onWalletUriReady: (uri: string) => void; - - /** - * Function that must be provided to submit the pin entered by the user on the client. - * The pin is used to decrypt the {@link Web5ConnectAuthResponse} that was retrieved from the - * token endpoint by the client inside of web5 connect. - * - * @returns A promise that resolves to the PIN as a string. - */ - validatePin: () => Promise; -}; - -/** - * The protocols of permissions requested, along with the definition and permission scopes for each protocol. - */ -export type ConnectPermissionRequest = { - /** - * The definition of the protocol the permissions are being requested for. - * In the event that the protocol is not already installed, the wallet will install this given protocol definition. - */ - protocolDefinition: DwnProtocolDefinition; - - /** The scope of the permissions being requested for the given protocol */ - permissionScopes: DwnPermissionScope[]; -}; - -/** - * Shorthand for the types of permissions that can be requested. - */ -export type Permission = 'write' | 'read' | 'delete' | 'query' | 'subscribe' | 'configure'; - -/** - * The options for creating a permission request for a given protocol. + * An internal utility that simplifies the API for permission requests by allowing + * users to pass simple strings (any of {@link Permission}) and will create the + * appropriate {@link DwnPermissionScope} for each string provided. */ -export type ProtocolPermissionOptions = { +function createPermissionRequestForProtocol({ + definition, + permissions, +}: { /** The protocol definition for the protocol being requested */ definition: DwnProtocolDefinition; /** The permissions being requested for the protocol */ permissions: Permission[]; -}; - -/** - * Creates a set of Dwn Permission Scopes to request for a given protocol. - * - * If no permissions are provided, the default is to request all relevant record permissions (write, read, delete, query, subscribe). - * 'configure' is not included by default, as this gives the application a lot of control over the protocol. - */ -function createPermissionRequestForProtocol({ definition, permissions }: ProtocolPermissionOptions): ConnectPermissionRequest { +}) { const requests: DwnPermissionScope[] = []; // Add the ability to query for the specific protocol @@ -225,19 +269,23 @@ function createPermissionRequestForProtocol({ definition, permissions }: Protoco }); // In order to enable sync, we must request permissions for `MessagesQuery`, `MessagesRead` and `MessagesSubscribe` - requests.push({ - protocol : definition.protocol, - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Read, - }, { - protocol : definition.protocol, - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Query, - }, { - protocol : definition.protocol, - interface : DwnInterfaceName.Messages, - method : DwnMethodName.Subscribe, - }); + requests.push( + { + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Read, + }, + { + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Query, + }, + { + protocol : definition.protocol, + interface : DwnInterfaceName.Messages, + method : DwnMethodName.Subscribe, + } + ); // We also request any additional permissions the user has requested for this protocol for (const permission of permissions) { @@ -293,4 +341,7 @@ function createPermissionRequestForProtocol({ definition, permissions }: Protoco }; } -export const WalletConnect = { initClient, createPermissionRequestForProtocol }; +export const WalletConnect = { + initClient, + createPermissionRequestForProtocol, +}; diff --git a/packages/agent/src/oidc.ts b/packages/agent/src/oidc.ts index 076a0b72a..25e9762c9 100644 --- a/packages/agent/src/oidc.ts +++ b/packages/agent/src/oidc.ts @@ -7,12 +7,18 @@ import { Sha256, X25519, CryptoUtils, + JweHeaderParams, } from '@web5/crypto'; import { concatenateUrl } from './utils.js'; import { xchacha20poly1305 } from '@noble/ciphers/chacha'; import type { ConnectPermissionRequest } from './connect.js'; -import { DidDocument, DidJwk, PortableDid, type BearerDid } from '@web5/dids'; -import { DwnDataEncodedRecordsWriteMessage, DwnInterface, DwnPermissionScope, DwnProtocolDefinition } from './types/dwn.js'; +import { DidDocument, DidJwk, DidResolutionResult, PortableDid, type BearerDid } from '@web5/dids'; +import { + DwnDataEncodedRecordsWriteMessage, + DwnInterface, + DwnPermissionScope, + DwnProtocolDefinition, +} from './types/dwn.js'; import { AgentPermissionsApi } from './permissions-api.js'; import type { Web5Agent } from './types/agent.js'; import { isRecordPermissionScope } from './dwn-api.js'; @@ -52,6 +58,9 @@ export type SIOPv2AuthRequest = { /** The DID of the client (RP) */ client_id: string; + /** The user friendly name of the client (RP) */ + client_name?: string; + /** The scope of the access request (e.g., `openid profile`). */ scope: string; @@ -128,11 +137,10 @@ export type SIOPv2AuthRequest = { * The contents of this are inserted into a JWT inside of the {@link PushedAuthRequest}. */ export type Web5ConnectAuthRequest = { - /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ - displayName: string; - /** PermissionGrants that are to be sent to the provider */ - permissionRequests: ConnectPermissionRequest[]; + permissionRequests?: ConnectPermissionRequest[]; + /** Instead of receiving a DID from the wallet the DWA will export its DID to the wallet */ + exported?: boolean; } & SIOPv2AuthRequest; /** The fields for an OIDC SIOPv2 Auth Repsonse */ @@ -171,12 +179,20 @@ export type Web5ConnectAuthResponse = { * 2. `authorize`: provider gets the {@link Web5ConnectAuthRequest} JWT that was stored by the PAR * 3. `callback`: provider sends {@link Web5ConnectAuthResponse} to this endpoint * 4. `token`: client gets {@link Web5ConnectAuthResponse} from this endpoint + * 5. `export`: (if `exported` is true) client will POST a {@link PortableDid} + * 6. `retrieve`: (if `exported` is true) wallet will GET the {@link PortableDid} + * 7. `export-token`: (if `exported` is true) wallet will POST the grants in order to finalize the flow. + * 8. `retrieve-token`: (if `exported` is true) client will GET the grants in order to finalize the flow. */ type OidcEndpoint = | 'pushedAuthorizationRequest' | 'authorize' | 'callback' - | 'token'; + | 'token' + | 'export' + | 'retrieve' + | 'export-token' + | 'retrieve-token'; /** * Gets the correct OIDC endpoint out of the {@link OidcEndpoint} options provided. @@ -213,13 +229,16 @@ function buildOidcUrl({ /** 3. provider sends {@link Web5ConnectAuthResponse} */ case 'callback': return concatenateUrl(baseURL, `callback`); - /** 4. client gets {@link Web5ConnectAuthResponse */ + /** 4. client gets {@link Web5ConnectAuthResponse} */ case 'token': if (!tokenParam) throw new Error( `tokenParam must be providied when building a token URL` ); return concatenateUrl(baseURL, `token/${tokenParam}.jwt`); + /** 5. (if `exported` is true) client will POST a {@link PortableDid} for import to the wallet. */ + case 'export': + return concatenateUrl(baseURL, `export`); // TODO: metadata endpoints? default: throw new Error(`No matches for endpoint specified: ${endpoint}`); @@ -245,7 +264,7 @@ async function generateCodeChallenge() { async function createAuthRequest( options: RequireOnly< Web5ConnectAuthRequest, - 'client_id' | 'scope' | 'redirect_uri' | 'permissionRequests' | 'displayName' + 'client_id' | 'scope' | 'redirect_uri' > ) { // Generate a random state value to associate the authorization request with the response. @@ -272,15 +291,18 @@ async function createAuthRequest( async function encryptAuthRequest({ jwt, encryptionKey, + kid }: { jwt: string; encryptionKey: Uint8Array; + kid: string; }) { - const protectedHeader = { + const protectedHeader: JweHeaderParams = { alg : 'dir', cty : 'JWT', enc : 'XC20P', typ : 'JWT', + kid }; const nonce = CryptoUtils.randomBytes(24); const additionalData = Convert.object(protectedHeader).toUint8Array(); @@ -400,6 +422,7 @@ async function verifyJwt({ jwt }: { jwt: string }) { } /** + * Called by the wallet first to get the authRequest. * Fetches the {@Web5ConnectAuthRequest} from the authorize endpoint and decrypts it * using the encryption key passed via QR code. */ @@ -414,7 +437,12 @@ const getAuthRequest = async (request_uri: string, encryption_key: string) => { jwt, })) as Web5ConnectAuthRequest; - return web5ConnectAuthRequest; + // get the pub DID that represents the client in ECDH and deriving a shared key + const header = Convert.base64Url(jwe.split('.')[0]).toObject() as JweHeaderParams; + + const clientEcdhDid = await DidJwk.resolve(header.kid!.split('#')[0]); + + return { authRequest: web5ConnectAuthRequest, clientEcdhDid }; }; /** Take the encrypted JWE, decrypt using the code challenge and return a JWT string which will need to be verified */ @@ -436,6 +464,7 @@ function decryptAuthRequest({ const encryptionKeyBytes = Convert.base64Url(encryption_key).toUint8Array(); const protectedHeader = Convert.base64Url(protectedHeaderB64U).toUint8Array(); const additionalData = protectedHeader; + const additionalDataObj = Convert.base64Url(protectedHeaderB64U).toObject(); const nonce = Convert.base64Url(nonceB64U).toUint8Array(); const ciphertext = Convert.base64Url(ciphertextB64U).toUint8Array(); const authenticationTag = Convert.base64Url( @@ -457,17 +486,8 @@ function decryptAuthRequest({ /** * The client uses to decrypt the jwe obtained from the auth server which contains * the {@link Web5ConnectAuthResponse} that was sent by the provider to the auth server. - * - * @async - * @param {BearerDid} clientDid - The did that was initially used by the client for ECDH at connect init. - * @param {string} jwe - The encrypted data as a jwe. - * @param {string} pin - The pin that was obtained from the user. */ -async function decryptAuthResponse( - clientDid: BearerDid, - jwe: string, - pin: string -) { +async function decryptWithPin(clientDid: BearerDid, jwe: string, pin: string) { const [ protectedHeaderB64U, , @@ -478,12 +498,18 @@ async function decryptAuthResponse( // get the delegatedid public key from the header const header = Convert.base64Url(protectedHeaderB64U).toObject() as Jwk; - const delegateResolvedDid = await DidJwk.resolve(header.kid!.split('#')[0]); + + // get ECDH pub did kid for provider encrypted jwe + const jweProviderEcdhDidKid = await DidJwk.resolve(header.kid!.split('#')[0]); + + if (!jweProviderEcdhDidKid.didDocument) { + throw new Error('Could not resolve provider\'s didd document for shared key derivation'); + } // derive ECDH shared key using the provider's public key and our clientDid private key const sharedKey = await Oidc.deriveSharedKey( clientDid, - delegateResolvedDid.didDocument! + jweProviderEcdhDidKid.didDocument ); // add the pin to the AAD @@ -557,26 +583,30 @@ async function deriveSharedKey( /** * Encrypts the auth response jwt. Requires a randomPin is added to the AAD of the * encryption algorithm in order to prevent man in the middle and eavesdropping attacks. - * The keyid of the delegate did is used to pass the public key to the client in order + * The keyid of the encrypting did is used to pass the public key to the client in order * for the client to derive the shared ECDH private key. */ -function encryptAuthResponse({ +function encryptWithPin({ jwt, encryptionKey, - delegateDidKeyId, + pubDidKid, randomPin, }: { + /** JWT of data to encrypt */ jwt: string; + /** the ECDH did priv key */ encryptionKey: Uint8Array; - delegateDidKeyId: string; + /** the DID URI of the encrypting DID, NOT the shared key */ + pubDidKid: string; + /** cryptographically secure pin */ randomPin: string; }) { - const protectedHeader = { + const protectedHeader: JweHeaderParams = { alg : 'dir', cty : 'JWT', enc : 'XC20P', typ : 'JWT', - kid : delegateDidKeyId, + kid : pubDidKid, }; const nonce = CryptoUtils.randomBytes(24); const additionalData = Convert.object({ @@ -626,12 +656,11 @@ async function createPermissionGrants( selectedDid: string, delegateBearerDid: BearerDid, agent: Web5Agent, - scopes: DwnPermissionScope[], + scopes: DwnPermissionScope[] ) { const permissionsApi = new AgentPermissionsApi({ agent }); // TODO: cleanup all grants if one fails by deleting them from the DWN: https://github.com/TBD54566975/web5-js/issues/849 - logger.log(`Creating permission grants for ${scopes.length} scopes given...`); const permissionGrants = await Promise.all( scopes.map((scope) => { // check if the scope is a records permission scope, or a protocol configure scope, if so it should use a delegated permission. @@ -698,7 +727,7 @@ async function prepareProtocol( messageParams : { filter: { protocol: protocolDefinition.protocol } }, }); - if ( queryMessage.reply.status.code !== 200) { + if (queryMessage.reply.status.code !== 200) { // if the query failed, throw an error throw new Error( `Could not fetch protocol: ${queryMessage.reply.status.detail}` @@ -724,9 +753,8 @@ async function prepareProtocol( author : selectedDid, target : selectedDid, messageType : DwnInterface.ProtocolsConfigure, - rawMessage : configureMessage + rawMessage : configureMessage, }); - } else { logger.log(`Protocol already exists: ${protocolDefinition.protocol}`); @@ -758,85 +786,100 @@ async function submitAuthResponse( selectedDid: string, authRequest: Web5ConnectAuthRequest, randomPin: string, + clientEcdhDid: DidResolutionResult, agent: Web5Agent ) { + // ephemeral provider did for signing + const providerSigningDid = await DidJwk.create(); + + // ephemeral provider did for ECDH + const providerEcdhDid = await DidJwk.create(); + + // delegate did for persistent use + // not used for signing or encryption during wallet connect const delegateBearerDid = await DidJwk.create(); const delegatePortableDid = await delegateBearerDid.export(); // TODO: roll back permissions and protocol configurations if an error occurs. Need a way to delete protocols to achieve this. - const delegateGrantPromises = authRequest.permissionRequests.map( - async (permissionRequest) => { - const { protocolDefinition, permissionScopes } = permissionRequest; - - // We validate that all permission scopes match the protocol uri of the protocol definition they are provided with. - const grantsMatchProtocolUri = permissionScopes.every(scope => 'protocol' in scope && scope.protocol === protocolDefinition.protocol); - if (!grantsMatchProtocolUri) { - throw new Error('All permission scopes must match the protocol uri they are provided with.'); + if (authRequest.permissionRequests) { + const delegateGrantPromises = authRequest.permissionRequests.map( + async (permissionRequest) => { + const { protocolDefinition, permissionScopes } = permissionRequest; + + // We validate that all permission scopes match the protocol uri of the protocol definition they are provided with. + const grantsMatchProtocolUri = permissionScopes.every( + (scope) => + 'protocol' in scope && + scope.protocol === protocolDefinition.protocol + ); + if (!grantsMatchProtocolUri) { + throw new Error( + 'All permission scopes must match the protocol uri they are provided with.' + ); + } + + await prepareProtocol(selectedDid, agent, protocolDefinition); + + const permissionGrants = await Oidc.createPermissionGrants( + selectedDid, + delegateBearerDid, + agent, + permissionScopes + ); + + return permissionGrants; } + ); - await prepareProtocol(selectedDid, agent, protocolDefinition); + const delegateGrants = (await Promise.all(delegateGrantPromises)).flat(); + const responseObject = await Oidc.createResponseObject({ + //* the IDP's did that was selected to be connected + iss : selectedDid, + //* the client's new identity + sub : delegateBearerDid.uri, + //* the client's temporary ephemeral did used for connect + aud : authRequest.client_id, + //* the nonce of the original auth request + nonce : authRequest.nonce, + delegateGrants, + delegatePortableDid, + }); - const permissionGrants = await Oidc.createPermissionGrants( - selectedDid, - delegateBearerDid, - agent, - permissionScopes - ); + // Sign using the signing key + const responseObjectJwt = await Oidc.signJwt({ + did : providerSigningDid, + data : responseObject, + }); - return permissionGrants; + if (!clientEcdhDid.didDocument?.verificationMethod?.[0].id) { + throw new Error('Unable to resolve the encryption DID used by the client for ECDH'); } - ); - const delegateGrants = (await Promise.all(delegateGrantPromises)).flat(); - - logger.log('Generating auth response object...'); - const responseObject = await Oidc.createResponseObject({ - //* the IDP's did that was selected to be connected - iss : selectedDid, - //* the client's new identity - sub : delegateBearerDid.uri, - //* the client's temporary ephemeral did used for connect - aud : authRequest.client_id, - //* the nonce of the original auth request - nonce : authRequest.nonce, - delegateGrants, - delegatePortableDid, - }); - - // Sign the Response Object using the ephemeral DID's signing key. - logger.log('Signing auth response object...'); - const responseObjectJwt = await Oidc.signJwt({ - did : delegateBearerDid, - data : responseObject, - }); - const clientDid = await DidJwk.resolve(authRequest.client_id); - - const sharedKey = await Oidc.deriveSharedKey( - delegateBearerDid, - clientDid?.didDocument! - ); + const sharedKey = await Oidc.deriveSharedKey( + providerEcdhDid, + clientEcdhDid?.didDocument + ); - logger.log('Encrypting auth response object...'); - const encryptedResponse = Oidc.encryptAuthResponse({ - jwt : responseObjectJwt!, - encryptionKey : sharedKey, - delegateDidKeyId : delegateBearerDid.document.verificationMethod![0].id, - randomPin, - }); + const encryptedResponse = Oidc.encryptWithPin({ + jwt : responseObjectJwt, + encryptionKey : sharedKey, + pubDidKid : providerEcdhDid.document.verificationMethod![0].id, + randomPin, + }); - const formEncodedRequest = new URLSearchParams({ - id_token : encryptedResponse, - state : authRequest.state, - }).toString(); - - logger.log(`Sending auth response object to Web5 Connect server: ${authRequest.redirect_uri}`); - await fetch(authRequest.redirect_uri, { - body : formEncodedRequest, - method : 'POST', - headers : { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); + const formEncodedRequest = new URLSearchParams({ + id_token : encryptedResponse, + state : authRequest.state, + }).toString(); + + await fetch(authRequest.redirect_uri, { + body : formEncodedRequest, + method : 'POST', + headers : { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + } } export const Oidc = { @@ -846,8 +889,8 @@ export const Oidc = { decryptAuthRequest, createPermissionGrants, createResponseObject, - encryptAuthResponse, - decryptAuthResponse, + encryptWithPin, + decryptWithPin, deriveSharedKey, signJwt, verifyJwt, diff --git a/packages/agent/tests/connect.spec.ts b/packages/agent/tests/connect.spec.ts index 9f34a8e10..4c3eee84c 100644 --- a/packages/agent/tests/connect.spec.ts +++ b/packages/agent/tests/connect.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { CryptoUtils } from '@web5/crypto'; -import { type BearerDid, DidDht, DidJwk, PortableDid } from '@web5/dids'; +import { type BearerDid, DidDht, DidJwk, type DidResolutionResult, type PortableDid } from '@web5/dids'; import { Convert } from '@web5/common'; import { Oidc, @@ -11,16 +11,25 @@ import { import { PlatformAgentTestHarness } from '../src/test-harness.js'; import { TestAgent } from './utils/test-agent.js'; import { testDwnUrl } from './utils/test-config.js'; -import { BearerIdentity, DwnInterface, DwnMessage, DwnProtocolDefinition, WalletConnect } from '../src/index.js'; +import { BearerIdentity, DwnInterface, DwnMessage, DwnProtocolDefinition, WalletConnect } from '../src/index.js'; import { RecordsPermissionScope } from '@tbd54566975/dwn-sdk-js'; import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; describe('web5 connect', function () { this.timeout(20000); - /** The temporary DID that web5 connect created on behalf of the client */ - let clientEphemeralBearerDid: BearerDid; - let clientEphemeralPortableDid: PortableDid; + let clientSigningBearerDid: BearerDid; + let clientSigningPortableDid: PortableDid; + + let clientEcdhBearerDid: BearerDid; + let clientEcdhPortableDid: PortableDid; + let clientEcdhDidAsResolvedByWallet: DidResolutionResult; + + let providerSigningBearerDid: BearerDid; + let providerSigningPortableDid: PortableDid; + + let providerEcdhBearerDid: BearerDid; + let providerEcdhPortableDid: PortableDid; /** The real tenant (identity) of the DWN that the provider had chosen to connect */ let providerIdentity: BearerIdentity; @@ -189,8 +198,13 @@ describe('web5 connect', function () { testDwnUrls : [testDwnUrl], }); - clientEphemeralBearerDid = await DidJwk.create(); - clientEphemeralPortableDid = await clientEphemeralBearerDid.export(); + clientEcdhBearerDid = await DidJwk.create(); + providerEcdhBearerDid = await DidJwk.create(); + + clientSigningBearerDid = await DidJwk.create(); + clientSigningPortableDid = await clientSigningBearerDid.export(); + + providerSigningBearerDid = await DidJwk.create(); delegateBearerDid = await DidJwk.create(); delegatePortableDid = await delegateBearerDid.export(); @@ -225,7 +239,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -243,16 +257,17 @@ describe('web5 connect', function () { it('should construct a signed jwt of an authrequest', async () => { authRequestJwt = await Oidc.signJwt({ - did : clientEphemeralBearerDid, + did : clientSigningBearerDid, data : authRequest, }); expect(authRequestJwt).to.be.a('string'); }); - it('should encrypt an authrequest using the code challenge', async () => { + it('should encrypt an authrequest using the random static encryptionKey', async () => { authRequestJwe = await Oidc.encryptAuthRequest({ jwt : authRequestJwt, - encryptionKey : authRequestEncryptionKey + encryptionKey : authRequestEncryptionKey, + kid : clientEcdhBearerDid.document.verificationMethod![0].id }); expect(authRequestJwe).to.be.a('string'); expect(authRequestJwe.split('.')).to.have.lengthOf(5); @@ -282,7 +297,9 @@ describe('web5 connect', function () { authorizeUrl, Convert.uint8Array(authRequestEncryptionKey).toBase64Url() ); - expect(result).to.deep.equal(authRequest); + expect(result.authRequest).to.deep.equal(authRequest); + expect(result.clientEcdhDid.didDocument?.id).to.equal(clientEcdhBearerDid.uri); + clientEcdhDidAsResolvedByWallet = result.clientEcdhDid; }); // TODO: waiting for DWN feature complete @@ -317,20 +334,21 @@ describe('web5 connect', function () { it('should sign the authresponse with its provider did', async () => { authResponseJwt = await Oidc.signJwt({ - did : delegateBearerDid, + did : providerSigningBearerDid, data : authResponse, }); + expect(authResponseJwt).to.be.a('string'); }); it('should derive a valid ECDH private key for both provider and client which is identical', async () => { const providerECDHDerivedPrivateKey = await Oidc.deriveSharedKey( - delegateBearerDid, - clientEphemeralBearerDid.document + providerEcdhBearerDid, + clientEcdhBearerDid.document ); const clientECDHDerivedPrivateKey = await Oidc.deriveSharedKey( - clientEphemeralBearerDid, - delegateBearerDid.document + clientEcdhBearerDid, + providerEcdhBearerDid.document ); expect(providerECDHDerivedPrivateKey).to.be.instanceOf(Uint8Array); @@ -350,11 +368,12 @@ describe('web5 connect', function () { const randomBytesStub = sinon .stub(CryptoUtils, 'randomBytes') .returns(encryptionNonce); - authResponseJwe = Oidc.encryptAuthResponse({ - jwt : authResponseJwt, - encryptionKey : sharedECDHPrivateKey, + + authResponseJwe = Oidc.encryptWithPin({ + jwt : authResponseJwt, + encryptionKey : sharedECDHPrivateKey, randomPin, - delegateDidKeyId : delegateBearerDid.document.verificationMethod![0].id, + pubDidKid : providerEcdhBearerDid.document.verificationMethod![0].id, }); expect(authResponseJwe).to.be.a('string'); expect(randomBytesStub.calledOnce).to.be.true; @@ -363,7 +382,11 @@ describe('web5 connect', function () { it('should send the encrypted jwe authresponse to the server', async () => { sinon.stub(Oidc, 'createPermissionGrants').resolves(permissionGrants as any); sinon.stub(CryptoUtils, 'randomBytes').returns(encryptionNonce); - sinon.stub(DidJwk, 'create').resolves(delegateBearerDid); + + const didJwkStub = sinon.stub(DidJwk, 'create'); + didJwkStub.onFirstCall().resolves(providerSigningBearerDid); + didJwkStub.onSecondCall().resolves(providerEcdhBearerDid); + didJwkStub.onThirdCall().resolves(delegateBearerDid); const formEncodedRequest = new URLSearchParams({ id_token : authResponseJwe, @@ -392,6 +415,7 @@ describe('web5 connect', function () { selectedDid, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); expect(fetchSpy.calledOnce).to.be.true; @@ -400,8 +424,8 @@ describe('web5 connect', function () { describe('client pin entry final phase', function () { it('should get the authresponse from server and decrypt the jwe using the pin', async () => { - const result = await Oidc.decryptAuthResponse( - clientEphemeralBearerDid, + const result = await Oidc.decryptWithPin( + clientEcdhBearerDid, authResponseJwe, randomPin ); @@ -411,8 +435,8 @@ describe('web5 connect', function () { it('should fail decrypting the jwe if the wrong pin is entered', async () => { try { - await Oidc.decryptAuthResponse( - clientEphemeralBearerDid, + await Oidc.decryptWithPin( + clientEcdhBearerDid, authResponseJwe, '87383837583757835737537734783' ); @@ -435,7 +459,9 @@ describe('web5 connect', function () { it('should complete the whole connect flow with the correct pin', async function () { const fetchStub = sinon.stub(globalThis, 'fetch'); const onWalletUriReadySpy = sinon.spy(); - sinon.stub(DidJwk, 'create').resolves(clientEphemeralBearerDid); + const didJwkStub = sinon.stub(DidJwk, 'create'); + didJwkStub.onFirstCall().resolves(clientSigningBearerDid); + didJwkStub.onSecondCall().resolves(clientEcdhBearerDid); const par = { expires_in : 3600000, @@ -463,8 +489,7 @@ describe('web5 connect', function () { connectServerUrl : 'http://localhost:3000/connect', permissionRequests : [ { - protocolDefinition : {} as any, - permissionScopes : {} as any, + protocolDefinition: {} as any, }, ], onWalletUriReady : (uri) => onWalletUriReadySpy(uri), @@ -508,7 +533,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -535,6 +560,7 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); @@ -564,7 +590,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -588,6 +614,7 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); @@ -612,6 +639,7 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); @@ -637,7 +665,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -663,6 +691,7 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); @@ -685,7 +714,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -714,6 +743,7 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); @@ -737,7 +767,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -763,6 +793,7 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); @@ -789,7 +820,7 @@ describe('web5 connect', function () { const options = { displayName : 'Sample App', - client_id : clientEphemeralPortableDid.uri, + client_id : clientSigningPortableDid.uri, scope : 'openid did:jwk', // code_challenge : Convert.uint8Array(codeChallenge).toBase64Url(), // code_challenge_method : 'S256' as const, @@ -804,6 +835,7 @@ describe('web5 connect', function () { providerIdentity.did.uri, authRequest, randomPin, + clientEcdhDidAsResolvedByWallet, testHarness.agent ); diff --git a/packages/api/src/web5.ts b/packages/api/src/web5.ts index bc5ee95bd..40bc4662c 100644 --- a/packages/api/src/web5.ts +++ b/packages/api/src/web5.ts @@ -8,16 +8,14 @@ import type { BearerIdentity, DwnDataEncodedRecordsWriteMessage, DwnMessagesPermissionScope, - DwnProtocolDefinition, DwnRecordsPermissionScope, HdIdentityVault, - Permission, WalletConnectOptions, Web5Agent, } from '@web5/agent'; import { Web5UserAgent } from '@web5/user-agent'; -import { DwnInterface, DwnRegistrar, WalletConnect } from '@web5/agent'; +import { DwnRegistrar, WalletConnect } from '@web5/agent'; import { DidApi } from './did-api.js'; import { DwnApi } from './dwn-api.js'; @@ -36,37 +34,6 @@ export type DidCreateOptions = { dwnEndpoints?: string[]; } -/** - * Represents a permission request for a protocol definition. - */ -export type ConnectPermissionRequest = { - /** - * The protocol definition for the protocol being requested. - */ - protocolDefinition: DwnProtocolDefinition; - - /** - * The permissions being requested for the protocol. If none are provided, the default is to request all permissions. - */ - permissions?: Permission[]; -} - -/** - * Options for connecting to a Web5 agent. This includes the ability to connect to an external wallet. - * - * NOTE: the returned `ConnectPermissionRequest` type is different to the `ConnectPermissionRequest` type in the `@web5/agent` package. - */ -export type ConnectOptions = Omit & { - /** The user friendly name of the client/app to be displayed when prompting end-user with permission requests. */ - displayName: string; - - /** - * The permissions that are being requested for the connected DID. - * This is used to create the {@link ConnectPermissionRequest} for the wallet connect flow. - */ - permissionRequests: ConnectPermissionRequest[]; -} - /** Optional overrides that can be provided when calling {@link Web5.connect}. */ export type Web5ConnectOptions = { /** @@ -74,7 +41,7 @@ export type Web5ConnectOptions = { * This param currently will not work in apps that are currently connected. * It must only be invoked at registration with a reset and empty DWN and agent. */ - walletConnectOptions?: ConnectOptions; + walletConnectOptions?: WalletConnectOptions; /** * Provide a {@link Web5Agent} implementation. Defaults to creating a local @@ -290,15 +257,20 @@ export class Web5 { recoveryPhrase = await userAgent.initialize({ password, recoveryPhrase, dwnEndpoints: serviceEndpointNodes }); } await userAgent.start({ password }); - // Attempt to retrieve the connected Identity if it exists. - const connectedIdentity: BearerIdentity = await userAgent.identity.connectedIdentity(); + + // Attempt to retrieve the connected Identity if it exists + const connectedIdentity = await userAgent.identity.connectedIdentity(); let identity: BearerIdentity; let connectedProtocols: string[] = []; + + const isWalletConnect = walletConnectOptions && !walletConnectOptions.exported; + const isWalletExportedConnect = walletConnectOptions && walletConnectOptions.exported; + if (connectedIdentity) { - // if a connected identity is found, use it // TODO: In the future, implement a way to re-connect an already connected identity and apply additional grants/protocols identity = connectedIdentity; - } else if (walletConnectOptions) { + } else if (isWalletConnect) { + console.log('IN walletConnect (non export) case'); if (sync === 'off') { // Currently we require sync to be enabled when using WalletConnect // This is to ensure a connected app is not in a disjointed state from any other clients/app using the connectedDid @@ -310,18 +282,9 @@ export class Web5 { // No connected identity found and connectOptions are provided, attempt to import a delegated DID from an external wallet try { - const { permissionRequests, ...connectOptions } = walletConnectOptions; - const walletPermissionRequests = permissionRequests.map(({ protocolDefinition, permissions }) => WalletConnect.createPermissionRequestForProtocol({ - definition : protocolDefinition, - permissions : permissions ?? [ - 'read', 'write', 'delete', 'query', 'subscribe' - ]} - )); - - const { delegatePortableDid, connectedDid, delegateGrants } = await WalletConnect.initClient({ - ...connectOptions, - permissionRequests: walletPermissionRequests, - }); + console.log('before initclient'); + const { delegatePortableDid, connectedDid, delegateGrants } = await WalletConnect.initClient(walletConnectOptions); + console.log('after initclient'); // Import the delegated DID as an Identity in the User Agent. // Setting the connectedDID in the metadata applies a relationship between the signer identity and the one it is impersonating. @@ -334,6 +297,7 @@ export class Web5 { tenant : agent.agentDid.uri, } }}); + console.log('does have an identity'); // Attempts to process the connected grants to be used by the delegateDID // If the process fails, we want to clean up the identity @@ -345,7 +309,28 @@ export class Web5 { await this.cleanUpIdentity({ identity, userAgent }); throw new Error(`Failed to connect to wallet: ${error.message}`); } + } else if (isWalletExportedConnect) { + console.log('IN EXPORT case'); + if (sync === 'off') { + // sync must be enabled when using WalletConnect to ensure a connected app + // is not in a disjointed state from any other clients using the connectedDid + throw new Error('Sync must not be disabled when using WalletConnect'); + } + + // Since we are connecting a new identity, we will want to register sync for the connectedDid + registerSync = true; + + try { + // TODO: do the exported connect + } catch (error:any) { + // clean up the DID and Identity if import fails and throw + // TODO: Implement the ability to purge all of our messages as a tenant + await this.cleanUpIdentity({ identity, userAgent }); + throw new Error(`Failed to connect to wallet: ${error.message}`); + } + // else connecting to a locally held DID } else { + console.log('IN else case'); // No connected identity found and no connectOptions provided, use local Identities // Query the Agent's DWN tenant for identity records. const identities = await userAgent.identity.list(); @@ -436,7 +421,7 @@ export class Web5 { } }); - if(walletConnectOptions !== undefined) { + if (walletConnectOptions !== undefined) { // If we are using WalletConnect, we should do a one-shot sync to pull down any messages that are associated with the connectedDid await userAgent.sync.sync('pull'); } @@ -484,9 +469,8 @@ export class Web5 { } /** - * A static method to process connected grants for a delegate DID. - * - * This will store the grants as the DWN owner to be used later when impersonating the connected DID. + * Processes connected grants for a delegate DID. + * Stores the grants as the DWN owner to be used later when impersonating the connected DID. */ static async processConnectedGrants({ grants, agent, delegateDid }: { grants: DwnDataEncodedRecordsWriteMessage[], diff --git a/packages/api/tests/web5.spec.ts b/packages/api/tests/web5.spec.ts index 4576989c4..7a5e61ab0 100644 --- a/packages/api/tests/web5.spec.ts +++ b/packages/api/tests/web5.spec.ts @@ -798,12 +798,7 @@ describe('web5 api', () => { sinon.stub(Web5UserAgent, 'create').resolves(testHarness.agent as Web5UserAgent); - // spy on the WalletConnect createPermissionRequestForProtocol method - const requestPermissionsSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); - - // We throw and spy on the initClient method to avoid the actual WalletConnect initialization - // but to still be able to spy on the passed parameters - sinon.stub(WalletConnect, 'initClient').throws('Error'); + const createPermissionRequestForProtocolSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); // stub the cleanUpIdentity method to avoid actual cleanup sinon.stub(Web5 as any, 'cleanUpIdentity').resolves(); @@ -837,12 +832,11 @@ describe('web5 api', () => { expect.fail('Should have thrown an error'); } catch(error: any) { - // we expect an error because we stubbed the initClient method to throw it - expect(error.message).to.include('Sinon-provided Error'); + // we expect an error because we aren't testing the whole e2e flow + expect(error.message).to.include('Failed to connect to wallet'); - // The `createPermissionRequestForProtocol` method should have been called once for the provided protocol - expect(requestPermissionsSpy.callCount).to.equal(1); - const call = requestPermissionsSpy.getCall(0); + expect(createPermissionRequestForProtocolSpy.callCount).to.equal(1); + const call = createPermissionRequestForProtocolSpy.getCall(0); // since no explicit permissions were provided, all permissions should be requested expect(call.args[0].permissions).to.have.members([ @@ -858,10 +852,6 @@ describe('web5 api', () => { // spy on the WalletConnect createPermissionRequestForProtocol method const requestPermissionsSpy = sinon.spy(WalletConnect, 'createPermissionRequestForProtocol'); - // We throw and spy on the initClient method to avoid the actual WalletConnect initialization - // but to still be able to spy on the passed parameters - sinon.stub(WalletConnect, 'initClient').throws('Error'); - // stub the cleanUpIdentity method to avoid actual cleanup sinon.stub(Web5 as any, 'cleanUpIdentity').resolves(); @@ -912,8 +902,8 @@ describe('web5 api', () => { expect.fail('Should have thrown an error'); } catch(error: any) { - // we expect an error because we stubbed the initClient method to throw it - expect(error.message).to.include('Sinon-provided Error'); + // we expect an error because we aren't testing the whole e2e flow + expect(error.message).to.include('Failed to connect to wallet'); // The `createPermissionRequestForProtocol` method should have been called once for each provided request expect(requestPermissionsSpy.callCount).to.equal(2);