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
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,
|
|
},
|
|
});
|
|
}
|
|
|