Add Bearer JWT auth middleware for notification routes

Mirror image-api’s DID JWT verification (src/vc + requireAuth) so
/notifications/* require a valid Authorization header while /health
stays public. Attach req.did, req.jwt, and req.auth for downstream use.
This commit is contained in:
Jose Olarte III
2026-05-19 18:23:41 +08:00
parent fc0cad4f2e
commit 4bf57d26fd
9 changed files with 802 additions and 0 deletions

327
package-lock.json generated
View File

@@ -8,6 +8,11 @@
"name": "notification-wakeup-service",
"version": "0.1.0",
"dependencies": {
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"cbor-x": "^1.5.9",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"express": "^5.1.0",
"firebase-admin": "^13.9.0"
},
@@ -18,6 +23,84 @@
"typescript": "^5.7.2"
}
},
"node_modules/@cbor-extract/cbor-extract-darwin-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-arm64/-/cbor-extract-darwin-arm64-2.2.2.tgz",
"integrity": "sha512-ZKZ/F8US7JR92J4DMct6cLW/Y66o2K576+zjlEN/MevH70bFIsB10wkZEQPLzl2oNh2SMGy55xpJ9JoBRl5DOA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-darwin-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-darwin-x64/-/cbor-extract-darwin-x64-2.2.2.tgz",
"integrity": "sha512-32b1mgc+P61Js+KW9VZv/c+xRw5EfmOcPx990JbCBSkYJFY0l25VinvyyWfl+3KjibQmAcYwmyzKF9J4DyKP/Q==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm/-/cbor-extract-linux-arm-2.2.2.tgz",
"integrity": "sha512-tNg0za41TpQfkhWjptD+0gSD2fggMiDCSacuIeELyb2xZhr7PrhPe5h66Jc67B/5dmpIhI2QOUtv4SBsricyYQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-arm64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-arm64/-/cbor-extract-linux-arm64-2.2.2.tgz",
"integrity": "sha512-wfqgzqCAy/Vn8i6WVIh7qZd0DdBFaWBjPdB6ma+Wihcjv0gHqD/mw3ouVv7kbbUNrab6dKEx/w3xQZEdeXIlzg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-linux-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-linux-x64/-/cbor-extract-linux-x64-2.2.2.tgz",
"integrity": "sha512-rpiLnVEsqtPJ+mXTdx1rfz4RtUGYIUg2rUAZgd1KjiC1SehYUSkJN7Yh+aVfSjvCGtVP0/bfkQkXpPXKbmSUaA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@cbor-extract/cbor-extract-win32-x64": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@cbor-extract/cbor-extract-win32-x64/-/cbor-extract-win32-x64-2.2.2.tgz",
"integrity": "sha512-dI+9P7cfWxkTQ+oE+7Aa6onEn92PHgfWXZivjNheCRmTBDBf2fx6RyTi0cmgpYLnD1KLZK9ZYrMxaPZ4oiXhGA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
@@ -765,6 +848,48 @@
"url": "https://opencollective.com/js-sdsl"
}
},
"node_modules/@multiformats/base-x": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@multiformats/base-x/-/base-x-4.0.1.tgz",
"integrity": "sha512-eMk0b9ReBbV23xXU693TAIrLyeO5iTgBZGSJfpqriG8UkYvr/hC9u9pyMlAakDNHWmbhMZCDs6KQO0jzKD8OTw==",
"license": "MIT"
},
"node_modules/@noble/ciphers": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.4.1.tgz",
"integrity": "sha512-QCOA9cgf3Rc33owG0AYBB9wszz+Ul2kramWN8tXG44Gyciud/tbkEqvxRF/IpqQaBpRBNi9f4jdNxqB2CQCIXg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodable/entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
@@ -788,6 +913,50 @@
"node": ">=8.0.0"
}
},
"node_modules/@peculiar/asn1-ecc": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz",
"integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/asn1-x509": "^2.7.0",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-schema": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz",
"integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==",
"license": "MIT",
"dependencies": {
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/asn1-x509": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz",
"integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==",
"license": "MIT",
"dependencies": {
"@peculiar/asn1-schema": "^2.7.0",
"@peculiar/utils": "^2.0.2",
"asn1js": "^3.0.6",
"tslib": "^2.8.1"
}
},
"node_modules/@peculiar/utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz",
"integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -862,6 +1031,15 @@
"license": "BSD-3-Clause",
"optional": true
},
"node_modules/@scure/base": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz",
"integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz",
@@ -1090,6 +1268,20 @@
"node": ">=8"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
"integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==",
"license": "BSD-3-Clause",
"dependencies": {
"pvtsutils": "^1.3.6",
"pvutils": "^1.1.5",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/async-retry": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
@@ -1204,6 +1396,46 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/canonicalize": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz",
"integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==",
"license": "Apache-2.0",
"bin": {
"canonicalize": "bin/canonicalize.js"
}
},
"node_modules/cbor-extract": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/cbor-extract/-/cbor-extract-2.2.2.tgz",
"integrity": "sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.1.1"
},
"bin": {
"download-cbor-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@cbor-extract/cbor-extract-darwin-arm64": "2.2.2",
"@cbor-extract/cbor-extract-darwin-x64": "2.2.2",
"@cbor-extract/cbor-extract-linux-arm": "2.2.2",
"@cbor-extract/cbor-extract-linux-arm64": "2.2.2",
"@cbor-extract/cbor-extract-linux-x64": "2.2.2",
"@cbor-extract/cbor-extract-win32-x64": "2.2.2"
}
},
"node_modules/cbor-x": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/cbor-x/-/cbor-x-1.6.4.tgz",
"integrity": "sha512-UGKHjp6RHC6QuZ2yy5LCKm7MojM4716DwoSaqwQpaH4DvZvbBTGcoDNTiG9Y2lByXZYFEs9WRkS5tLl96IrF1Q==",
"license": "MIT",
"optionalDependencies": {
"cbor-extract": "^2.2.2"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1337,6 +1569,39 @@
"node": ">= 0.8"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/did-jwt": {
"version": "7.4.7",
"resolved": "https://registry.npmjs.org/did-jwt/-/did-jwt-7.4.7.tgz",
"integrity": "sha512-Apz7nIfIHSKWIMaEP5L/K8xkwByvjezjTG0xiqwKdnNj1x8M0+Yasury5Dm/KPltxi2PlGfRPf3IejRKZrT8mQ==",
"license": "Apache-2.0",
"dependencies": {
"@noble/ciphers": "^0.4.0",
"@noble/curves": "^1.0.0",
"@noble/hashes": "^1.3.0",
"@scure/base": "^1.1.3",
"canonicalize": "^2.0.0",
"did-resolver": "^4.1.0",
"multibase": "^4.0.6",
"multiformats": "^9.6.2",
"uint8arrays": "3.1.1"
}
},
"node_modules/did-resolver": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/did-resolver/-/did-resolver-4.1.0.tgz",
"integrity": "sha512-S6fWHvCXkZg2IhS4RcVHxwuyVejPR7c+a4Go0xbQ9ps5kILa8viiYQgrM4gfTyeTjJ0ekgJH9gk/BawTpmkbZA==",
"license": "Apache-2.0"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2542,6 +2807,26 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multibase": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/multibase/-/multibase-4.0.6.tgz",
"integrity": "sha512-x23pDe5+svdLz/k5JPGCVdfn7Q5mZVMBETiC+ORfO+sor9Sgs0smJzAjfTbM5tckeCqnaUuMYoz+k3RXMmJClQ==",
"deprecated": "This module has been superseded by the multiformats module",
"license": "MIT",
"dependencies": {
"@multiformats/base-x": "^4.0.1"
},
"engines": {
"node": ">=12.0.0",
"npm": ">=6.0.0"
}
},
"node_modules/multiformats": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
"license": "(Apache-2.0 AND MIT)"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -2601,6 +2886,21 @@
"node": ">= 6.13.0"
}
},
"node_modules/node-gyp-build-optional-packages": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.1.1.tgz",
"integrity": "sha512-+P72GAjVAbTxjjwUmwjVrqrdZROD4nf8KgpBoDxqXXTiYZZt/ud60dE5yvCSr9lRO8e8yv6kgJIC0K0PfZFVQw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
@@ -2746,6 +3046,24 @@
"node": ">= 0.10"
}
},
"node_modules/pvtsutils": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz",
"integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.8.1"
}
},
"node_modules/pvutils": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz",
"integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==",
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -3235,6 +3553,15 @@
"node": ">=14.17"
}
},
"node_modules/uint8arrays": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz",
"integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==",
"license": "MIT",
"dependencies": {
"multiformats": "^9.4.2"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View File

@@ -9,6 +9,11 @@
"build": "tsc"
},
"dependencies": {
"@peculiar/asn1-ecc": "^2.3.8",
"@peculiar/asn1-schema": "^2.3.8",
"cbor-x": "^1.5.9",
"did-jwt": "^7.4.7",
"did-resolver": "^4.1.0",
"express": "^5.1.0",
"firebase-admin": "^13.9.0"
},

84
src/middleware/auth.ts Normal file
View File

@@ -0,0 +1,84 @@
import type { NextFunction, Request, Response } from "express";
import { decodeAndVerifyJwt } from "../vc/index.js";
export type AuthContext = {
did: string;
jwt: string;
};
type ClientErrorBody = {
clientError?: {
message?: string;
code?: string;
};
};
function clientErrorMessage(err: unknown): string | undefined {
if (err && typeof err === "object" && "clientError" in err) {
const message = (err as ClientErrorBody).clientError?.message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
return undefined;
}
/**
* Express middleware mirroring image-api decodeJwt: Bearer JWT, DID verification,
* attaches req.did / req.jwt / req.auth on success.
*/
export async function requireAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
console.log("[Auth] Authentication failed");
res.status(401).json({
success: false,
message: 'Missing "Bearer JWT" in Authorization header.',
});
return;
}
const token = authHeader.substring("Bearer ".length);
try {
const verified = await decodeAndVerifyJwt(token);
if (!verified.verified) {
const errorTime = new Date().toISOString();
console.log("[Auth] Authentication failed");
console.error(
errorTime,
"Got invalid JWT in Authorization header:",
verified
);
res.status(401).json({
success: false,
message:
"Got invalid JWT in Authorization header. See server logs at " +
errorTime,
});
return;
}
const did = verified.issuer;
req.did = did;
req.jwt = token;
req.auth = { did, jwt: token };
console.log("[Auth] Authenticated user:", did);
next();
} catch (err) {
const errorTime = new Date().toISOString();
console.log("[Auth] Authentication failed");
console.error(errorTime, "Got invalid JWT in Authorization header:", err);
res.status(401).json({
success: false,
message:
clientErrorMessage(err) ??
"Got invalid JWT in Authorization header. See server logs at " +
errorTime,
});
}
}

View File

@@ -1,8 +1,11 @@
import { Router } from "express";
import { db } from "../db/fcmTokens.js";
import { requireAuth } from "../middleware/auth.js";
export const notificationsRouter = Router();
notificationsRouter.use(requireAuth);
notificationsRouter.get("/", (_req, res) => {
res.json({ ok: true, resource: "notifications" });
});

14
src/types/express.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare global {
namespace Express {
interface Request {
/** Authenticated user DID (issuer from verified JWT). */
did?: string;
/** Raw Bearer JWT from the Authorization header. */
jwt?: string;
/** Verified auth context (did + jwt). */
auth?: { did: string; jwt: string };
}
}
}
export {};

View File

@@ -0,0 +1,46 @@
import type { DIDResolutionResult } from "did-resolver";
/**
* This did:ethr resolver instructs the did-jwt machinery to use the
* EcdsaSecp256k1RecoveryMethod2020Uses verification method which adds the recovery bit to the
* signature to recover the DID's public key from a signature.
*
* Similar code resides in image-api, crowd-funder-for-time-pwa, and endorser-ch.
*/
export const didEthLocalResolver = async (
did: string
): Promise<DIDResolutionResult> => {
const didRegex = /^did:ethr:(0x[0-9a-fA-F]{40})$/;
const match = did.match(didRegex);
if (match) {
const address = match[1];
const publicKeyHex = address;
return {
didDocumentMetadata: {},
didResolutionMetadata: {
contentType: "application/did+ld+json",
},
didDocument: {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/secp256k1recovery-2020/v2",
],
id: did,
verificationMethod: [
{
id: `${did}#controller`,
type: "EcdsaSecp256k1RecoveryMethod2020",
controller: did,
blockchainAccountId: "eip155:1:" + publicKeyHex,
},
],
authentication: [`${did}#controller`],
assertionMethod: [`${did}#controller`],
},
};
}
throw new Error(`Unsupported DID format: ${did}`);
};

104
src/vc/didPeer.ts Normal file
View File

@@ -0,0 +1,104 @@
import { AsnParser } from "@peculiar/asn1-schema";
import { ECDSASigValue } from "@peculiar/asn1-ecc";
import crypto from "crypto";
import { decode as cborDecode } from "cbor-x";
/**
* similar code is in image-api and crowd-funder-for-time-pwa
*/
export async function verifyPeerSignature(
payloadBytes: Uint8Array,
publicKeyBytes: Uint8Array,
signatureBytes: Uint8Array
) {
const finalSignatureBuffer = unwrapEC2Signature(signatureBytes);
const verifyAlgorithm = {
name: "ECDSA",
hash: { name: "SHA-256" },
};
const publicKeyJwk = cborToKeys(publicKeyBytes).publicKeyJwk;
const keyAlgorithm = {
name: "ECDSA",
namedCurve: publicKeyJwk.crv,
};
const publicKeyCryptoKey = await crypto.subtle.importKey(
"jwk",
publicKeyJwk,
keyAlgorithm,
false,
["verify"]
);
const verified = await crypto.subtle.verify(
verifyAlgorithm,
publicKeyCryptoKey,
finalSignatureBuffer,
payloadBytes
);
return verified;
}
function cborToKeys(publicKeyBytes: Uint8Array) {
const jwkObj = cborDecode(publicKeyBytes) as Record<number, unknown>;
if (
jwkObj[1] != 2 ||
jwkObj[3] != -7 ||
jwkObj[-1] != 1 ||
!Array.isArray(jwkObj[-2]) ||
(jwkObj[-2] as Uint8Array).length != 32 ||
!Array.isArray(jwkObj[-3]) ||
(jwkObj[-3] as Uint8Array).length != 32
) {
throw new Error("Unable to extract key.");
}
const publicKeyJwk = {
alg: "ES256",
crv: "P-256",
kty: "EC",
x: arrayToBase64Url(jwkObj[-2] as Uint8Array),
y: arrayToBase64Url(jwkObj[-3] as Uint8Array),
};
const publicKeyBuffer = Buffer.concat([
Buffer.from(jwkObj[-2] as Uint8Array),
Buffer.from(jwkObj[-3] as Uint8Array),
]);
return { publicKeyJwk, publicKeyBuffer };
}
function toBase64Url(anythingB64: string) {
return anythingB64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function arrayToBase64Url(anything: Uint8Array) {
return toBase64Url(Buffer.from(anything).toString("base64"));
}
function unwrapEC2Signature(signature: Uint8Array) {
const parsedSignature = AsnParser.parse(signature, ECDSASigValue);
let rBytes = new Uint8Array(parsedSignature.r);
let sBytes = new Uint8Array(parsedSignature.s);
if (shouldRemoveLeadingZero(rBytes)) {
rBytes = rBytes.slice(1);
}
if (shouldRemoveLeadingZero(sBytes)) {
sBytes = sBytes.slice(1);
}
return isoUint8ArrayConcat([rBytes, sBytes]);
}
function shouldRemoveLeadingZero(bytes: Uint8Array) {
return bytes[0] === 0x0 && (bytes[1] & (1 << 7)) !== 0;
}
function isoUint8ArrayConcat(arrays: Uint8Array[]) {
let pointer = 0;
const totalLength = arrays.reduce((prev, curr) => prev + curr.length, 0);
const toReturn = new Uint8Array(totalLength);
arrays.forEach((arr) => {
toReturn.set(arr, pointer);
pointer += arr.length;
});
return toReturn;
}

100
src/vc/index.ts Normal file
View File

@@ -0,0 +1,100 @@
/**
* Verifiable Credential & DID functions (shared pattern with image-api, endorser-ch).
*/
import { verifyJWT } from "did-jwt";
import { Resolver } from "did-resolver";
import { didEthLocalResolver } from "./did-eth-local-resolver.js";
import { verifyJwt as peerVerifyJwt } from "./passkeyDidPeer.js";
export const TEST_BYPASS_ENV_VALUE = "test-local";
export const ETHR_DID_PREFIX = "did:ethr:";
export const PEER_DID_PREFIX = "did:peer:";
export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED_CODE";
export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD";
const resolver = new Resolver({
ethr: didEthLocalResolver,
});
export type VerifiedJwt = {
issuer: string;
payload: Record<string, unknown>;
verified: boolean;
};
export async function decodeAndVerifyJwt(jwt: string): Promise<VerifiedJwt> {
const pieces = jwt.split(".");
const header = JSON.parse(
Buffer.from(pieces[0], "base64url").toString("utf8")
) as Record<string, unknown>;
const payload = JSON.parse(
Buffer.from(pieces[1], "base64url").toString("utf8")
) as Record<string, unknown>;
const issuerDid = payload.iss;
if (!issuerDid || typeof issuerDid !== "string") {
return Promise.reject({
clientError: {
message: `Missing "iss" field in JWT.`,
},
});
}
if (
issuerDid.startsWith(ETHR_DID_PREFIX) &&
process.env.NODE_ENV === TEST_BYPASS_ENV_VALUE
) {
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (typeof payload.exp === "number" && payload.exp < nowEpoch) {
console.log(
"JWT with exp " +
payload.exp +
" has expired but we're in test mode so we'll use a new time."
);
payload.exp = nowEpoch + 100;
}
return { issuer: issuerDid, payload, verified: true };
}
if (issuerDid.startsWith(ETHR_DID_PREFIX)) {
try {
const verified = await verifyJWT(jwt, { resolver });
return verified as VerifiedJwt;
} catch (e) {
return Promise.reject({
clientError: {
message: `JWT failed verification: ` + e,
code: JWT_VERIFY_FAILED_CODE,
},
});
}
}
if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") {
const { claimPayload, verified } = await peerVerifyJwt(
payload,
issuerDid,
pieces[2]
);
return {
issuer: issuerDid,
payload: claimPayload as Record<string, unknown>,
verified,
};
}
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,
},
});
}

119
src/vc/passkeyDidPeer.ts Normal file
View File

@@ -0,0 +1,119 @@
import crypto from "crypto";
import { multibaseToBytes } from "did-jwt";
import { PEER_DID_PREFIX, TEST_BYPASS_ENV_VALUE } from "./index.js";
import { verifyPeerSignature } from "./didPeer.js";
export async function verifyJwt(
payload: Record<string, unknown>,
issuerDid: string,
signatureString: string
) {
if (!payload.iss) {
return Promise.reject({
clientError: {
message: `JWT is missing an "iss" field.`,
},
});
}
const nowEpoch = Math.floor(new Date().getTime() / 1000);
if (!payload.exp) {
return Promise.reject({
clientError: {
message: `JWT with is missing an "exp" field.`,
},
});
}
if (
typeof payload.exp === "number" &&
payload.exp < nowEpoch &&
process.env.NODE_ENV !== TEST_BYPASS_ENV_VALUE
) {
return Promise.reject({
clientError: {
message: `JWT with exp ${payload.exp} has expired.`,
},
});
}
const authData = payload.AuthenticationDataB64URL;
const clientData = payload.ClientDataJSONB64URL;
if (typeof authData !== "string" || typeof clientData !== "string") {
return Promise.reject({
clientError: {
message: `JWT with typ == JWANT requires AuthenticationData and ClientDataJSON.`,
},
});
}
const decodedAuthDataBuff = Buffer.from(authData, "base64url");
const decodedClientData = Buffer.from(clientData, "base64url");
let claimPayload = JSON.parse(decodedClientData.toString()) as Record<
string,
unknown
>;
if (claimPayload.challenge) {
claimPayload = JSON.parse(
Buffer.from(claimPayload.challenge as string, "base64url").toString()
) as Record<string, unknown>;
if (!claimPayload.exp) {
claimPayload.exp = payload.exp;
}
if (!claimPayload.iat) {
claimPayload.iat = payload.iat;
}
if (!claimPayload.iss) {
claimPayload.iss = payload.iss;
}
}
if (!claimPayload.exp) {
return Promise.reject({
clientError: {
message: `JWT client data challenge is missing an "exp" field.`,
},
});
}
if (
typeof claimPayload.exp === "number" &&
claimPayload.exp < nowEpoch &&
process.env.NODE_ENV !== TEST_BYPASS_ENV_VALUE
) {
return Promise.reject({
clientError: {
message: `JWT client data challenge exp time is past.`,
},
});
}
if (claimPayload.exp !== payload.exp) {
return Promise.reject({
clientError: {
message: `JWT client data challenge "exp" field doesn't match the outside payload "exp".`,
},
});
}
if (claimPayload.iss !== payload.iss) {
return Promise.reject({
clientError: {
message: `JWT client data challenge "iss" field doesn't match the outside payload "iss".`,
},
});
}
const hashedClientDataBuff = crypto
.createHash("sha256")
.update(decodedClientData)
.digest();
const preimage = new Uint8Array(
Buffer.concat([decodedAuthDataBuff, hashedClientDataBuff])
);
const PEER_DID_MULTIBASE_PREFIX = PEER_DID_PREFIX + "0";
const publicKey = multibaseToBytes(
issuerDid.substring(PEER_DID_MULTIBASE_PREFIX.length)
);
const signature = new Uint8Array(
Buffer.from(signatureString, "base64url")
);
const verified = await verifyPeerSignature(preimage, publicKey, signature);
return { claimPayload, verified };
}