UNPKG

@web5/agent

Version:
507 lines 25.9 kB
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; import { Convert, logger } from '@web5/common'; import { Ed25519, EdDsaAlgorithm, Sha256, X25519, CryptoUtils, } from '@web5/crypto'; import { concatenateUrl } from './utils.js'; import { xchacha20poly1305 } from '@noble/ciphers/chacha'; import { DidJwk } from '@web5/dids'; import { DwnInterface } from './types/dwn.js'; import { AgentPermissionsApi } from './permissions-api.js'; import { isRecordPermissionScope } from './dwn-api.js'; import { DwnInterfaceName, DwnMethodName } from '@tbd54566975/dwn-sdk-js'; /** * Gets the correct OIDC endpoint out of the {@link OidcEndpoint} options provided. * Handles a trailing slash on baseURL * * @param {Object} options the options object * @param {string} options.baseURL for example `http://foo.com/connect/ * @param {OidcEndpoint} options.endpoint the OIDC endpoint desired * @param {string} options.authParam this is the unique id which must be provided when getting the `authorize` endpoint * @param {string} options.tokenParam this is the random state as b64url which must be provided with the `token` endpoint */ function buildOidcUrl({ baseURL, endpoint, authParam, tokenParam, }) { switch (endpoint) { /** 1. client sends {@link PushedAuthRequest} & client receives {@link PushedAuthResponse} */ case 'pushedAuthorizationRequest': return concatenateUrl(baseURL, 'par'); /** 2. provider gets {@link Web5ConnectAuthRequest} */ case 'authorize': if (!authParam) throw new Error(`authParam must be providied when building a token URL`); return concatenateUrl(baseURL, `authorize/${authParam}.jwt`); /** 3. provider sends {@link Web5ConnectAuthResponse} */ case 'callback': return concatenateUrl(baseURL, `callback`); /** 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`); // TODO: metadata endpoints? default: throw new Error(`No matches for endpoint specified: ${endpoint}`); } } /** * Generates a cryptographically random "code challenge" in * accordance with the RFC 7636 PKCE specification. * * @see {@link https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 | RFC 7636 } */ function generateCodeChallenge() { return __awaiter(this, void 0, void 0, function* () { const codeVerifierBytes = CryptoUtils.randomBytes(32); const codeChallengeBytes = yield Sha256.digest({ data: codeVerifierBytes }); const codeChallengeBase64Url = Convert.uint8Array(codeChallengeBytes).toBase64Url(); return { codeChallengeBytes, codeChallengeBase64Url }; }); } /** Client creates the {@link Web5ConnectAuthRequest} */ function createAuthRequest(options) { return __awaiter(this, void 0, void 0, function* () { // Generate a random state value to associate the authorization request with the response. const stateBytes = CryptoUtils.randomBytes(16); // Generate a random nonce value to associate the ID Token with the authorization request. const nonceBytes = CryptoUtils.randomBytes(16); const requestObject = Object.assign(Object.assign({}, options), { nonce: Convert.uint8Array(nonceBytes).toBase64Url(), response_type: 'id_token', response_mode: 'direct_post', state: Convert.uint8Array(stateBytes).toBase64Url(), client_metadata: { subject_syntax_types_supported: ['did:dht', 'did:jwk'], } }); return requestObject; }); } /** Encrypts the auth request with the key which will be passed through QR code */ function encryptAuthRequest({ jwt, encryptionKey, }) { return __awaiter(this, void 0, void 0, function* () { const protectedHeader = { alg: 'dir', cty: 'JWT', enc: 'XC20P', typ: 'JWT', }; const nonce = CryptoUtils.randomBytes(24); const additionalData = Convert.object(protectedHeader).toUint8Array(); const jwtBytes = Convert.string(jwt).toUint8Array(); const chacha = xchacha20poly1305(encryptionKey, nonce, additionalData); const ciphertextAndTag = chacha.encrypt(jwtBytes); /** The cipher output concatenates the encrypted data and tag * so we need to extract the values for use in the JWE. */ const ciphertext = ciphertextAndTag.subarray(0, -16); const authenticationTag = ciphertextAndTag.subarray(-16); const compactJwe = [ Convert.object(protectedHeader).toBase64Url(), '', Convert.uint8Array(nonce).toBase64Url(), Convert.uint8Array(ciphertext).toBase64Url(), Convert.uint8Array(authenticationTag).toBase64Url(), ].join('.'); return compactJwe; }); } /** Create a response object compatible with Web5 Connect and OIDC SIOPv2 */ function createResponseObject(options) { return __awaiter(this, void 0, void 0, function* () { const currentTimeInSeconds = Math.floor(Date.now() / 1000); const responseObject = Object.assign(Object.assign({}, options), { iat: currentTimeInSeconds, exp: currentTimeInSeconds + 600 }); return responseObject; }); } /** sign an object and transform it into a jwt using a did */ function signJwt({ did, data, }) { return __awaiter(this, void 0, void 0, function* () { const header = Convert.object({ alg: 'EdDSA', kid: did.document.verificationMethod[0].id, typ: 'JWT', }).toBase64Url(); const payload = Convert.object(data).toBase64Url(); // signs using ed25519 EdDSA const signer = yield did.getSigner(); const signature = yield signer.sign({ data: Convert.string(`${header}.${payload}`).toUint8Array(), }); const signatureBase64Url = Convert.uint8Array(signature).toBase64Url(); const jwt = `${header}.${payload}.${signatureBase64Url}`; return jwt; }); } /** Take the decrypted JWT and verify it was signed by its public DID. Return parsed object. */ function verifyJwt({ jwt }) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { const [headerB64U, payloadB64U, signatureB64U] = jwt.split('.'); // Convert the header back to a JOSE object and verify that the 'kid' header value is present. const header = Convert.base64Url(headerB64U).toObject(); if (!header.kid) throw new Error(`OIDC: Object could not be verified due to missing 'kid' header value.`); // Resolve the Client DID document. const { didDocument } = yield DidJwk.resolve(header.kid.split('#')[0]); if (!didDocument) throw new Error('OIDC: Object could not be verified due to Client DID resolution issue.'); // Get the public key used to sign the Object from the DID document. const { publicKeyJwk } = (_b = (_a = didDocument.verificationMethod) === null || _a === void 0 ? void 0 : _a.find((method) => { return method.id === header.kid; })) !== null && _b !== void 0 ? _b : {}; if (!publicKeyJwk) throw new Error('OIDC: Object could not be verified due to missing public key in DID document.'); const EdDsa = new EdDsaAlgorithm(); const isValid = yield EdDsa.verify({ key: publicKeyJwk, signature: Convert.base64Url(signatureB64U).toUint8Array(), data: Convert.string(`${headerB64U}.${payloadB64U}`).toUint8Array(), }); if (!isValid) throw new Error('OIDC: Object failed verification due to invalid signature.'); const object = Convert.base64Url(payloadB64U).toObject(); return object; }); } /** * Fetches the {@Web5ConnectAuthRequest} from the authorize endpoint and decrypts it * using the encryption key passed via QR code. */ const getAuthRequest = (request_uri, encryption_key) => __awaiter(void 0, void 0, void 0, function* () { const authRequest = yield fetch(request_uri); const jwe = yield authRequest.text(); const jwt = decryptAuthRequest({ jwe, encryption_key, }); const web5ConnectAuthRequest = (yield verifyJwt({ jwt, })); return web5ConnectAuthRequest; }); /** Take the encrypted JWE, decrypt using the code challenge and return a JWT string which will need to be verified */ function decryptAuthRequest({ jwe, encryption_key, }) { const [protectedHeaderB64U, , nonceB64U, ciphertextB64U, authenticationTagB64U,] = jwe.split('.'); const encryptionKeyBytes = Convert.base64Url(encryption_key).toUint8Array(); const protectedHeader = Convert.base64Url(protectedHeaderB64U).toUint8Array(); const additionalData = protectedHeader; const nonce = Convert.base64Url(nonceB64U).toUint8Array(); const ciphertext = Convert.base64Url(ciphertextB64U).toUint8Array(); const authenticationTag = Convert.base64Url(authenticationTagB64U).toUint8Array(); // The cipher expects the encrypted data and tag to be concatenated. const ciphertextAndTag = new Uint8Array([ ...ciphertext, ...authenticationTag, ]); const chacha = xchacha20poly1305(encryptionKeyBytes, nonce, additionalData); const decryptedJwtBytes = chacha.decrypt(ciphertextAndTag); const jwt = Convert.uint8Array(decryptedJwtBytes).toString(); return jwt; } /** * 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. */ function decryptAuthResponse(clientDid, jwe, pin) { return __awaiter(this, void 0, void 0, function* () { const [protectedHeaderB64U, , nonceB64U, ciphertextB64U, authenticationTagB64U,] = jwe.split('.'); // get the delegatedid public key from the header const header = Convert.base64Url(protectedHeaderB64U).toObject(); const delegateResolvedDid = yield DidJwk.resolve(header.kid.split('#')[0]); // derive ECDH shared key using the provider's public key and our clientDid private key const sharedKey = yield Oidc.deriveSharedKey(clientDid, delegateResolvedDid.didDocument); // add the pin to the AAD const additionalData = Object.assign(Object.assign({}, header), { pin: pin }); const AAD = Convert.object(additionalData).toUint8Array(); const nonce = Convert.base64Url(nonceB64U).toUint8Array(); const ciphertext = Convert.base64Url(ciphertextB64U).toUint8Array(); const authenticationTag = Convert.base64Url(authenticationTagB64U).toUint8Array(); // The cipher expects the encrypted data and tag to be concatenated. const ciphertextAndTag = new Uint8Array([ ...ciphertext, ...authenticationTag, ]); // decrypt using the sharedKey const chacha = xchacha20poly1305(sharedKey, nonce, AAD); const decryptedJwtBytes = chacha.decrypt(ciphertextAndTag); const jwt = Convert.uint8Array(decryptedJwtBytes).toString(); return jwt; }); } /** Derives a shared ECDH private key in order to encrypt the {@link Web5ConnectAuthResponse} */ function deriveSharedKey(privateKeyDid, publicKeyDid) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { const privatePortableDid = yield privateKeyDid.export(); const publicJwk = (_a = publicKeyDid.verificationMethod) === null || _a === void 0 ? void 0 : _a[0].publicKeyJwk; const privateJwk = (_b = privatePortableDid.privateKeys) === null || _b === void 0 ? void 0 : _b[0]; publicJwk.alg = 'EdDSA'; const publicX25519 = yield Ed25519.convertPublicKeyToX25519({ publicKey: publicJwk, }); const privateX25519 = yield Ed25519.convertPrivateKeyToX25519({ privateKey: privateJwk, }); const sharedKey = yield X25519.sharedSecret({ privateKeyA: privateX25519, publicKeyB: publicX25519, }); const derivedKey = yield crypto.subtle.importKey('raw', sharedKey, { name: 'HKDF' }, false, ['deriveBits']); const derivedKeyBits = yield crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', info: new Uint8Array(), salt: new Uint8Array(), }, derivedKey, 256); const sharedEncryptionKey = new Uint8Array(derivedKeyBits); return sharedEncryptionKey; }); } /** * 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 * for the client to derive the shared ECDH private key. */ function encryptAuthResponse({ jwt, encryptionKey, delegateDidKeyId, randomPin, }) { const protectedHeader = { alg: 'dir', cty: 'JWT', enc: 'XC20P', typ: 'JWT', kid: delegateDidKeyId, }; const nonce = CryptoUtils.randomBytes(24); const additionalData = Convert.object(Object.assign(Object.assign({}, protectedHeader), { pin: randomPin })).toUint8Array(); const jwtBytes = Convert.string(jwt).toUint8Array(); const chacha = xchacha20poly1305(encryptionKey, nonce, additionalData); const ciphertextAndTag = chacha.encrypt(jwtBytes); /** The cipher output concatenates the encrypted data and tag * so we need to extract the values for use in the JWE. */ const ciphertext = ciphertextAndTag.subarray(0, -16); const authenticationTag = ciphertextAndTag.subarray(-16); const compactJwe = [ Convert.object(protectedHeader).toBase64Url(), '', Convert.uint8Array(nonce).toBase64Url(), Convert.uint8Array(ciphertext).toBase64Url(), Convert.uint8Array(authenticationTag).toBase64Url(), ].join('.'); return compactJwe; } function shouldUseDelegatePermission(scope) { // Currently all record permissions are treated as delegated permissions // In the future only methods that modify state will be delegated and the rest will be normal permissions if (isRecordPermissionScope(scope)) { return true; } else if (scope.interface === DwnInterfaceName.Protocols && scope.method === DwnMethodName.Configure) { // ProtocolConfigure messages are also delegated, as they modify state return true; } // All other permissions are not treated as delegated return false; } /** * Creates the permission grants that assign to the selectedDid the level of * permissions that the web app requested in the {@link Web5ConnectAuthRequest} */ function createPermissionGrants(selectedDid, delegateBearerDid, agent, scopes) { return __awaiter(this, void 0, void 0, function* () { 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 = yield 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. const delegated = shouldUseDelegatePermission(scope); return permissionsApi.createGrant({ delegated, store: true, grantedTo: delegateBearerDid.uri, scope, dateExpires: '2040-06-25T16:09:16.693356Z', author: selectedDid, }); })); logger.log(`Sending ${permissionGrants.length} permission grants to remote DWN...`); const messagePromises = permissionGrants.map((grant) => __awaiter(this, void 0, void 0, function* () { // Quirk: we have to pull out encodedData out of the message the schema validator doesn't want it there const _a = grant.message, { encodedData } = _a, rawMessage = __rest(_a, ["encodedData"]); const data = Convert.base64Url(encodedData).toUint8Array(); const { reply } = yield agent.sendDwnRequest({ author: selectedDid, target: selectedDid, messageType: DwnInterface.RecordsWrite, dataStream: new Blob([data]), rawMessage, }); // check if the message was sent successfully, if the remote returns 409 the message may have come through already via sync if (reply.status.code !== 202 && reply.status.code !== 409) { logger.error(`Error sending RecordsWrite: ${reply.status.detail}`); logger.error(`RecordsWrite message: ${rawMessage}`); throw new Error(`Could not send the message. Error details: ${reply.status.detail}`); } return grant.message; })); try { const messages = yield Promise.all(messagePromises); return messages; } catch (error) { logger.error(`Error during batch-send of permission grants: ${error}`); throw error; } }); } /** * Installs the protocol required by the Client on the Provider if it doesn't already exist. */ function prepareProtocol(selectedDid, agent, protocolDefinition) { return __awaiter(this, void 0, void 0, function* () { const queryMessage = yield agent.processDwnRequest({ author: selectedDid, messageType: DwnInterface.ProtocolsQuery, target: selectedDid, messageParams: { filter: { protocol: protocolDefinition.protocol } }, }); if (queryMessage.reply.status.code !== 200) { // if the query failed, throw an error throw new Error(`Could not fetch protocol: ${queryMessage.reply.status.detail}`); } else if (queryMessage.reply.entries === undefined || queryMessage.reply.entries.length === 0) { logger.log(`Protocol does not exist, creating: ${protocolDefinition.protocol}`); // send the protocol definition to the remote DWN first, if it passes we can process it locally const { reply: sendReply, message: configureMessage } = yield agent.sendDwnRequest({ author: selectedDid, target: selectedDid, messageType: DwnInterface.ProtocolsConfigure, messageParams: { definition: protocolDefinition }, }); // check if the message was sent successfully, if the remote returns 409 the message may have come through already via sync if (sendReply.status.code !== 202 && sendReply.status.code !== 409) { throw new Error(`Could not send protocol: ${sendReply.status.detail}`); } // process the protocol locally, we don't have to check if it exists as this is just a convenience over waiting for sync. yield agent.processDwnRequest({ author: selectedDid, target: selectedDid, messageType: DwnInterface.ProtocolsConfigure, rawMessage: configureMessage }); } else { logger.log(`Protocol already exists: ${protocolDefinition.protocol}`); // the protocol already exists, let's make sure it exists on the remote DWN as the requesting app will need it const configureMessage = queryMessage.reply.entries[0]; const { reply: sendReply } = yield agent.sendDwnRequest({ author: selectedDid, target: selectedDid, messageType: DwnInterface.ProtocolsConfigure, rawMessage: configureMessage, }); if (sendReply.status.code !== 202 && sendReply.status.code !== 409) { throw new Error(`Could not send protocol: ${sendReply.status.detail}`); } } }); } /** * Creates a delegate did which the web app will use as its future indentity. * Assigns to that DID the level of permissions that the web app requested in * the {@link Web5ConnectAuthRequest}. Encrypts via ECDH key that the web app * will have access to because the web app has the public key which it provided * in the {@link Web5ConnectAuthRequest}. Then sends the ciphertext of this * {@link Web5ConnectAuthResponse} to the callback endpoint. Which the * web app will need to retrieve from the token endpoint and decrypt with the pin to access. */ function submitAuthResponse(selectedDid, authRequest, randomPin, agent) { return __awaiter(this, void 0, void 0, function* () { const delegateBearerDid = yield DidJwk.create(); const delegatePortableDid = yield 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((permissionRequest) => __awaiter(this, void 0, void 0, function* () { 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.'); } yield prepareProtocol(selectedDid, agent, protocolDefinition); const permissionGrants = yield Oidc.createPermissionGrants(selectedDid, delegateBearerDid, agent, permissionScopes); return permissionGrants; })); const delegateGrants = (yield Promise.all(delegateGrantPromises)).flat(); logger.log('Generating auth response object...'); const responseObject = yield 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 = yield Oidc.signJwt({ did: delegateBearerDid, data: responseObject, }); const clientDid = yield DidJwk.resolve(authRequest.client_id); const sharedKey = yield Oidc.deriveSharedKey(delegateBearerDid, clientDid === null || clientDid === void 0 ? void 0 : clientDid.didDocument); logger.log('Encrypting auth response object...'); const encryptedResponse = Oidc.encryptAuthResponse({ jwt: responseObjectJwt, encryptionKey: sharedKey, delegateDidKeyId: delegateBearerDid.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}`); yield fetch(authRequest.redirect_uri, { body: formEncodedRequest, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }); }); } export const Oidc = { createAuthRequest, encryptAuthRequest, getAuthRequest, decryptAuthRequest, createPermissionGrants, createResponseObject, encryptAuthResponse, decryptAuthResponse, deriveSharedKey, signJwt, verifyJwt, buildOidcUrl, generateCodeChallenge, submitAuthResponse, }; //# sourceMappingURL=oidc.js.map