@web5/agent
Version:
507 lines • 25.9 kB
JavaScript
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