You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

230 lines
7.0 KiB

/**
* Verifiable Credential & DID functions, specifically for EndorserSearch.org tools
*
* The goal is to make this folder similar across projects, then move it to a library.
* Other projects: endorser-ch, image-api
*
*/
import { Buffer } from "buffer/";
import * as didJwt from "did-jwt";
import { JWTVerified } from "did-jwt";
import { Resolver } from "did-resolver";
import { IIdentifier } from "@veramo/core";
import * as u8a from "uint8arrays";
import { didEthLocalResolver } from "./did-eth-local-resolver";
import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer";
import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer";
import { urlBase64ToUint8Array } from "./util";
export const ETHR_DID_PREFIX = "did:ethr:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
/**
* Meta info about a key
*/
export interface KeyMeta {
/**
* Decentralized ID for the key
*/
did: string;
/**
* Stringified IIDentifier object from Veramo
*/
identity?: string;
/**
* The Webauthn credential ID in hex, if this is from a passkey
*/
passkeyCredIdHex?: string;
}
const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver });
/**
* Tell whether a key is from a passkey
* @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey
*/
export function isFromPasskey(keyMeta?: KeyMeta): boolean {
return !!keyMeta?.passkeyCredIdHex;
}
export async function createEndorserJwtForKey(
account: KeyMeta,
payload: object,
expiresIn?: number,
) {
if (account?.identity) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const identity: IIdentifier = JSON.parse(account.identity!);
const privateKeyHex = identity.keys[0].privateKeyHex;
const signer = await SimpleSigner(privateKeyHex as string);
const options = {
// alg: "ES256K", // "K" is the default, "K-R" is used by the server in tests
issuer: account.did,
signer: signer,
expiresIn: undefined as number | undefined,
};
if (expiresIn) {
options.expiresIn = expiresIn;
}
return didJwt.createJWT(payload, options);
} else if (account?.passkeyCredIdHex) {
return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload);
} else {
throw new Error("No identity data found to sign for DID " + account.did);
}
}
/**
* Copied out of did-jwt since it's deprecated in that library.
*
* The SimpleSigner returns a configured function for signing data.
*
* @example
* const signer = SimpleSigner(privateKeyHexString)
* signer(data, (err, signature) => {
* ...
* })
*
* @param {String} hexPrivateKey a hex encoded private key
* @return {Function} a configured signer function
*/
function SimpleSigner(hexPrivateKey: string): didJwt.Signer {
const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true);
return async (data) => {
const signature = (await signer(data)) as string;
return fromJose(signature);
};
}
// from did-jwt/util; see SimpleSigner above
function fromJose(signature: string): {
r: string;
s: string;
recoveryParam?: number;
} {
const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature);
if (signatureBytes.length < 64 || signatureBytes.length > 65) {
throw new TypeError(
`Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`,
);
}
const r = bytesToHex(signatureBytes.slice(0, 32));
const s = bytesToHex(signatureBytes.slice(32, 64));
const recoveryParam =
signatureBytes.length === 65 ? signatureBytes[64] : undefined;
return { r, s, recoveryParam };
}
// from did-jwt/util; see SimpleSigner above
function bytesToHex(b: Uint8Array): string {
return u8a.toString(b, "base16");
}
// We should be calling 'verify' in more places, showing warnings if it fails.
// @returns JWTDecoded with { header: JWTHeader, payload: any, signature: string, data: string } (but doesn't verify the signature)
export function decodeEndorserJwt(jwt: string) {
try {
// First try the standard did-jwt decode
return didJwt.decodeJWT(jwt);
} catch (error) {
// If that fails, try manual decoding
try {
const parts = jwt.split(".");
if (parts.length !== 3) {
throw new Error("JWT must have 3 parts");
}
const header = JSON.parse(Buffer.from(parts[0], "base64url").toString());
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString());
// Validate the header
if (header.typ !== "JWT" || !header.alg) {
throw new Error("Invalid JWT header format");
}
// Return in the same format as didJwt.decodeJWT
return {
header,
payload,
signature: parts[2],
data: parts[0] + "." + parts[1],
};
} catch (e) {
throw new Error(`invalid_argument: Incorrect format JWT - ${e.message}`);
}
}
}
// return Promise of at least { issuer, payload, verified boolean }
// ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer
export async function decodeAndVerifyJwt(
jwt: string,
): Promise<Omit<JWTVerified, "didResolutionResult" | "signer" | "jwt">> {
const pieces = jwt.split(".");
const header = JSON.parse(base64urlDecodeString(pieces[0]));
const payload = JSON.parse(base64urlDecodeString(pieces[1]));
const issuerDid = payload.iss;
if (!issuerDid) {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await didJwt.verifyJWT(jwt, {
resolver: ethLocalResolver,
});
return verified;
} catch (e: unknown) {
return Promise.reject({
clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") {
const verified = await verifyPeerSignature(
Buffer.from(payload),
issuerDid,
urlBase64ToUint8Array(pieces[2]),
);
if (!verified) {
return Promise.reject({
clientError: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
message: `JWT failed verification: ` + e.toString(),
code: JWT_VERIFY_FAILED_CODE,
},
});
} else {
return { issuer: issuerDid, payload: payload, verified: true };
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX)) {
return Promise.reject({
clientError: {
message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`,
},
});
}
return Promise.reject({
clientError: {
message: `Unsupported DID method ${issuerDid}`,
code: UNSUPPORTED_DID_METHOD_CODE,
},
});
}