diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000..044636a Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 8afa975..63cbb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.5] - 2026.08.26 +### Added +- `PUT /notifications/alert-authorization` stores a 100-JWT delegated alert-authorization batch per user (SQLite), authenticated with the current user JWT + + ## [0.1.4] - 2026.08.07 ### Changed - Renamed `FCM_TOKEN_DATA_DIR` env var to `NOTIFY_DATA_DIR` diff --git a/README.md b/README.md index 39df998..1aeef34 100644 --- a/README.md +++ b/README.md @@ -28,10 +28,38 @@ On first use, the service creates `NOTIFY_DATA_DIR` (default `./data`) and the S `POST /notifications/register` and `POST /notifications/refresh` require a Bearer JWT. After local JWT verification, the service checks the token with Endorser (`GET /api/report/rateLimits` on `ENDORSER_URL`). Registration and refresh continue only if Endorser accepts the JWT. -**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before. +`PUT /notifications/alert-authorization` uses the same current-user Bearer JWT + Endorser check. It does **not** accept the `testMode` local bypass. The 100 delegated JWTs in the body are stored credentials, not the request authenticator. + +**Local notification test bypass:** send `testMode: true` in the JSON body and omit the `Authorization` header. The request skips JWT and Endorser checks and uses a synthetic local test user, same as before. This applies to register/refresh only. Set `NODE_ENV=test-local` in `.env` to bypass ethr JWT *expiry* verification during local development (this is separate from the `testMode` bypass above). +### Alert authorization + +`PUT /notifications/alert-authorization` + +``` +Authorization: Bearer +``` + +```json +{ + "batchId": "client-batch-id", + "timezone": "America/Denver", + "jwts": [ + { + "sequence": 0, + "day": "2026-08-27", + "nbf": 1756270800, + "exp": 1756357200, + "jwt": "eyJ..." + } + ] +} +``` + +`timezone` is the IANA zone used when minting the 100 validity windows; it is stored as batch metadata, not live device-timezone tracking. A successful call replaces that user's previous **unused** JWTs atomically. Passkey (`did:peer`) identities cannot mint this batch and receive `DELEGATED_JWT_UNSUPPORTED_IDENTITY`. + ## Storage ### Database location @@ -54,7 +82,14 @@ Table `fcm_registrations` holds one row per registered device: Unique on `(user_id, device_id)`. Indexes also exist on `user_id`, `device_id`, `fcm_token`, and `(user_id, fcm_token)`. -The schema is created automatically on startup if the database or tables do not already exist. +Tables `alert_authorization_batches` and `alert_authorization_jwts` hold a user's delegated notification-JWT inventory (separate from device registration): + +- Batch: `id`, `user_id` (authenticated DID), `batch_id`, `timezone` (IANA name at mint time), `created_at` +- JWT: `batch_pk`, `sequence`, `day` (`YYYY-MM-DD`), `jwt`, `nbf`, `exp`, `status` (`unused` / `consumed`), `consumed_at`, timestamps + +Unique on `(batch_pk, sequence)` and on `(user_id, day)` for unused rows. Indexes also exist on `(user_id, status)`, `(user_id, day)`, and `batch_pk`. + +The schema is created automatically on startup if the database or tables do not already exist. New tables are added with `CREATE TABLE IF NOT EXISTS`; existing `fcm_registrations` rows are not migrated or altered. ### JSON → SQLite @@ -96,5 +131,5 @@ Required environment variables: |---|---| | `FIREBASE_SERVICE_ACCOUNT_JSON` | Inline service account JSON (one line). If unset, falls back to Application Default Credentials. | | `PORT` | HTTP port (default: `3003`). | -| `ENDORSER_URL` | Endorser API base URL used for auth checks on register/refresh (default: `https://api.endorser.ch`). | +| `ENDORSER_URL` | Endorser API base URL used for auth checks on register, refresh, and alert-authorization (default: `https://api.endorser.ch`). | | `NOTIFY_DATA_DIR` | Directory for the SQLite database file `notify.sqlite` (default: `./data`). | diff --git a/package.json b/package.json index a7496e7..77aa1ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notification-wakeup-service", - "version": "0.1.4", + "version": "0.1.5", "private": true, "type": "module", "packageManager": "pnpm@11.4.0", diff --git a/src/db/alertAuthorizationSqlite.ts b/src/db/alertAuthorizationSqlite.ts new file mode 100644 index 0000000..7bb2f14 --- /dev/null +++ b/src/db/alertAuthorizationSqlite.ts @@ -0,0 +1,235 @@ +import { randomUUID } from "node:crypto"; +import { getDatabase } from "./sqlite.js"; + +export const ALERT_JWT_STATUS_UNUSED = "unused"; +export const ALERT_JWT_STATUS_CONSUMED = "consumed"; + +export const EXPECTED_ALERT_JWT_BATCH_SIZE = 100; + +export type AlertAuthorizationBatchRecord = { + id: string; + userId: string; + batchId: string; + timezone: string; + createdAt: string; +}; + +export type AlertAuthorizationJwtRecord = { + id: string; + batchPk: string; + userId: string; + batchId: string; + sequence: number; + day: string; + jwt: string; + nbf: number; + exp: number; + status: string; + consumedAt?: string; + createdAt: string; +}; + +export type AlertAuthorizationJwtInput = { + sequence: number; + day: string; + jwt: string; + nbf: number; + exp: number; +}; + +type BatchDbRow = { + id: string; + user_id: string; + batch_id: string; + timezone: string; + created_at: string; +}; + +type JwtDbRow = { + id: string; + batch_pk: string; + user_id: string; + batch_id: string; + sequence: number; + day: string; + jwt: string; + nbf: number; + exp: number; + status: string; + consumed_at: string | null; + created_at: string; +}; + +const JWT_COLUMNS = + "id, batch_pk, user_id, batch_id, sequence, day, jwt, nbf, exp, status, consumed_at, created_at"; + +function toJwtRecord(row: JwtDbRow): AlertAuthorizationJwtRecord { + return { + id: row.id, + batchPk: row.batch_pk, + userId: row.user_id, + batchId: row.batch_id, + sequence: row.sequence, + day: row.day, + jwt: row.jwt, + nbf: row.nbf, + exp: row.exp, + status: row.status, + consumedAt: row.consumed_at ?? undefined, + createdAt: row.created_at, + }; +} + +function toBatchRecord(row: BatchDbRow): AlertAuthorizationBatchRecord { + return { + id: row.id, + userId: row.user_id, + batchId: row.batch_id, + timezone: row.timezone, + createdAt: row.created_at, + }; +} + +export const alertAuthorizationDb = { + /** + * Atomically drop this user's unused JWTs (and empty batch rows), then install + * a new batch. Consumed JWTs from prior batches are left in place. + */ + async replaceUnusedBatch(input: { + userId: string; + batchId: string; + timezone: string; + jwts: AlertAuthorizationJwtInput[]; + }): Promise<{ + batch: AlertAuthorizationBatchRecord; + storedCount: number; + unusedCount: number; + }> { + const connection = getDatabase(); + const now = new Date().toISOString(); + const batchPk = randomUUID(); + + const run = connection.transaction(() => { + connection + .prepare( + ` + DELETE FROM alert_authorization_jwts + WHERE user_id = ? AND status = ? + ` + ) + .run(input.userId, ALERT_JWT_STATUS_UNUSED); + + connection + .prepare( + ` + DELETE FROM alert_authorization_batches + WHERE user_id = ? + AND id NOT IN ( + SELECT DISTINCT batch_pk FROM alert_authorization_jwts + WHERE user_id = ? + ) + ` + ) + .run(input.userId, input.userId); + + connection + .prepare( + ` + INSERT INTO alert_authorization_batches ( + id, user_id, batch_id, timezone, created_at + ) VALUES (?, ?, ?, ?, ?) + ` + ) + .run(batchPk, input.userId, input.batchId, input.timezone, now); + + const insertJwt = connection.prepare( + ` + INSERT INTO alert_authorization_jwts ( + id, batch_pk, user_id, batch_id, sequence, day, jwt, + nbf, exp, status, consumed_at, created_at + ) VALUES ( + @id, @batch_pk, @user_id, @batch_id, @sequence, @day, @jwt, + @nbf, @exp, @status, @consumed_at, @created_at + ) + ` + ); + + for (const item of input.jwts) { + insertJwt.run({ + id: randomUUID(), + batch_pk: batchPk, + user_id: input.userId, + batch_id: input.batchId, + sequence: item.sequence, + day: item.day, + jwt: item.jwt, + nbf: item.nbf, + exp: item.exp, + status: ALERT_JWT_STATUS_UNUSED, + consumed_at: null, + created_at: now, + }); + } + }); + + run(); + + const unusedCount = await this.countUnused(input.userId); + return { + batch: { + id: batchPk, + userId: input.userId, + batchId: input.batchId, + timezone: input.timezone, + createdAt: now, + }, + storedCount: input.jwts.length, + unusedCount, + }; + }, + + async getUnusedForDay( + userId: string, + day: string + ): Promise { + const row = getDatabase() + .prepare( + ` + SELECT ${JWT_COLUMNS} FROM alert_authorization_jwts + WHERE user_id = ? AND day = ? AND status = ? + LIMIT 1 + ` + ) + .get(userId, day, ALERT_JWT_STATUS_UNUSED) as JwtDbRow | undefined; + return row === undefined ? undefined : toJwtRecord(row); + }, + + async countUnused(userId: string): Promise { + const row = getDatabase() + .prepare( + ` + SELECT COUNT(*) AS n FROM alert_authorization_jwts + WHERE user_id = ? AND status = ? + ` + ) + .get(userId, ALERT_JWT_STATUS_UNUSED) as { n: number }; + return row.n; + }, + + async getLatestBatch( + userId: string + ): Promise { + const row = getDatabase() + .prepare( + ` + SELECT id, user_id, batch_id, timezone, created_at + FROM alert_authorization_batches + WHERE user_id = ? + ORDER BY created_at DESC + LIMIT 1 + ` + ) + .get(userId) as BatchDbRow | undefined; + return row === undefined ? undefined : toBatchRecord(row); + }, +}; diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts index f29453d..951cf00 100644 --- a/src/db/sqlite.ts +++ b/src/db/sqlite.ts @@ -33,6 +33,50 @@ CREATE INDEX IF NOT EXISTS idx_fcm_registrations_fcm_token CREATE INDEX IF NOT EXISTS idx_fcm_registrations_user_fcm_token ON fcm_registrations (user_id, fcm_token); + +CREATE TABLE IF NOT EXISTS alert_authorization_batches ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + batch_id TEXT NOT NULL, + timezone TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_alert_auth_batches_user_id + ON alert_authorization_batches (user_id); + +CREATE INDEX IF NOT EXISTS idx_alert_auth_batches_user_batch + ON alert_authorization_batches (user_id, batch_id); + +CREATE TABLE IF NOT EXISTS alert_authorization_jwts ( + id TEXT PRIMARY KEY NOT NULL, + batch_pk TEXT NOT NULL, + user_id TEXT NOT NULL, + batch_id TEXT NOT NULL, + sequence INTEGER NOT NULL, + day TEXT NOT NULL, + jwt TEXT NOT NULL, + nbf INTEGER NOT NULL, + exp INTEGER NOT NULL, + status TEXT NOT NULL, + consumed_at TEXT, + created_at TEXT NOT NULL +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_auth_jwts_batch_seq + ON alert_authorization_jwts (batch_pk, sequence); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_day_unused + ON alert_authorization_jwts (user_id, day) WHERE status = 'unused'; + +CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_status + ON alert_authorization_jwts (user_id, status); + +CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_user_day + ON alert_authorization_jwts (user_id, day); + +CREATE INDEX IF NOT EXISTS idx_alert_auth_jwts_batch_pk + ON alert_authorization_jwts (batch_pk); `; let database: Database.Database | null = null; diff --git a/src/index.ts b/src/index.ts index 8eb7fd6..705b2a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,12 +12,12 @@ const port = Number(process.env.PORT) || 3003; app.use( cors({ origin: true, - methods: ["GET", "POST", "OPTIONS"], + methods: ["GET", "POST", "PUT", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization"], }), ); -app.use(express.json()); +app.use(express.json({ limit: "1mb" })); // Keep stable for diagnostics tooling compatibility app.get("/health", (_req, res) => { diff --git a/src/routes/notifications.ts b/src/routes/notifications.ts index 40389ff..41a23e5 100644 --- a/src/routes/notifications.ts +++ b/src/routes/notifications.ts @@ -1,9 +1,17 @@ import express, { Router } from "express"; +import { alertAuthorizationDb } from "../db/alertAuthorizationSqlite.js"; import { db } from "../db/fcmTokensSqlite.js"; import { + requireAuth, requireAuthOrNotificationLocalTest, requireEndorserAuth, } from "../middleware/auth.js"; +import { + type AlertAuthorizationRequestBody, + unsupportedIdentityFailure, + validateAlertAuthorizationBatch, +} from "../services/alertAuthorization.js"; +import { identitySupportsDelegatedJwtBatch } from "../vc/index.js"; import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js"; import { maskToken } from "../util/maskToken.js"; @@ -88,6 +96,88 @@ notificationsRouter.post( } ); +notificationsRouter.put( + "/alert-authorization", + requireAuth, + requireEndorserAuth, + async (req, res) => { + const started = Date.now(); + const userId = req.did; + if (userId === undefined) { + res.status(401).json({ success: false, message: "Unauthorized" }); + return; + } + + console.log("[AlertAuthorization] Request received, user=" + userId); + + if (!identitySupportsDelegatedJwtBatch(userId)) { + const failure = unsupportedIdentityFailure(userId); + console.log( + "[AlertAuthorization] Rejected in", + formatElapsedMs(Date.now() - started) + ":", + failure.error + ); + res.status(400).json({ + success: false, + error: failure.error, + message: failure.message, + details: failure.details, + }); + return; + } + + const body = (req.body ?? {}) as AlertAuthorizationRequestBody; + const validated = await validateAlertAuthorizationBatch(userId, body); + if (!validated.ok) { + console.log( + "[AlertAuthorization] Rejected in", + formatElapsedMs(Date.now() - started) + ":", + validated.error + ",", + validated.details[0] ?? validated.message + ); + res.status(400).json({ + success: false, + error: validated.error, + message: validated.message, + details: validated.details, + }); + return; + } + + try { + const stored = await alertAuthorizationDb.replaceUnusedBatch({ + userId, + batchId: validated.batchId, + timezone: validated.timezone, + jwts: validated.jwts, + }); + res.status(200).json({ + success: true, + batchId: stored.batch.batchId, + timezone: stored.batch.timezone, + storedCount: stored.storedCount, + unusedCount: stored.unusedCount, + }); + console.log( + "[AlertAuthorization] Completed in", + formatElapsedMs(Date.now() - started) + ",", + "batchId=" + stored.batch.batchId + ",", + "stored=" + stored.storedCount + ); + } catch (err) { + console.error( + "[AlertAuthorization] Failed in", + formatElapsedMs(Date.now() - started) + ":", + errorMessage(err) + ); + res.status(500).json({ + success: false, + message: "Failed to store delegated notification-JWT batch.", + }); + } + } +); + notificationsRouter.post( "/register", requireAuthOrNotificationLocalTest, diff --git a/src/services/alertAuthorization.ts b/src/services/alertAuthorization.ts new file mode 100644 index 0000000..8f3bbb1 --- /dev/null +++ b/src/services/alertAuthorization.ts @@ -0,0 +1,287 @@ +import { EXPECTED_ALERT_JWT_BATCH_SIZE } from "../db/alertAuthorizationSqlite.js"; +import { + DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE, + decodeAndVerifyDelegatedJwt, + identitySupportsDelegatedJwtBatch, +} from "../vc/index.js"; + +const DAY_RE = /^\d{4}-\d{2}-\d{2}$/; +const MAX_REPORTED_ERRORS = 20; + +export type AlertAuthorizationRequestBody = { + batchId?: unknown; + timezone?: unknown; + jwts?: unknown; +}; + +export type ValidatedAlertJwt = { + sequence: number; + day: string; + jwt: string; + nbf: number; + exp: number; +}; + +export type BatchValidationSuccess = { + ok: true; + batchId: string; + timezone: string; + jwts: ValidatedAlertJwt[]; +}; + +export type BatchValidationFailure = { + ok: false; + error: string; + message: string; + details: string[]; +}; + +type JwtItemBody = { + sequence?: unknown; + day?: unknown; + jwt?: unknown; + nbf?: unknown; + exp?: unknown; +}; + +function isValidIanaTimeZone(timezone: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: timezone }); + return true; + } catch { + return false; + } +} + +function calendarDayInTimeZone(epochSec: number, timeZone: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(epochSec * 1000)); + const year = parts.find((p) => p.type === "year")?.value; + const month = parts.find((p) => p.type === "month")?.value; + const day = parts.find((p) => p.type === "day")?.value; + return `${year}-${month}-${day}`; +} + +function clientErrorInfo(err: unknown): { message: string; code?: string } { + if (err && typeof err === "object" && "clientError" in err) { + const clientError = (err as { clientError?: { message?: string; code?: string } }) + .clientError; + if (typeof clientError?.message === "string" && clientError.message.length > 0) { + return { message: clientError.message, code: clientError.code }; + } + } + if (err instanceof Error && err.message.length > 0) { + return { message: err.message }; + } + return { message: "Delegated JWT verification failed." }; +} + +function isFiniteInteger(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value); +} + +export function unsupportedIdentityFailure(did: string): BatchValidationFailure { + return { + ok: false, + error: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE, + message: + "This identity type cannot mint a delegated notification-JWT batch. Passkey (did:peer) sessions require a live assertion and cannot pre-issue 100 future JWTs.", + details: [`Authenticated DID: ${did}`], + }; +} + +export async function validateAlertAuthorizationBatch( + userDid: string, + body: AlertAuthorizationRequestBody +): Promise { + if (!identitySupportsDelegatedJwtBatch(userDid)) { + return unsupportedIdentityFailure(userDid); + } + + const details: string[] = []; + const batchId = + typeof body.batchId === "string" ? body.batchId.trim() : undefined; + if (batchId === undefined || batchId.length === 0) { + details.push("batchId is required"); + } + + const timezone = + typeof body.timezone === "string" ? body.timezone.trim() : undefined; + if (timezone === undefined || timezone.length === 0) { + details.push("timezone is required (IANA name used when minting validity windows)"); + } else if (!isValidIanaTimeZone(timezone)) { + details.push(`timezone is not a valid IANA time zone: ${timezone}`); + } + + if (!Array.isArray(body.jwts)) { + details.push("jwts must be an array of 100 delegated JWT entries"); + return fail(details); + } + + if (body.jwts.length !== EXPECTED_ALERT_JWT_BATCH_SIZE) { + details.push( + `jwts must contain exactly ${EXPECTED_ALERT_JWT_BATCH_SIZE} entries, got ${body.jwts.length}` + ); + return fail(details); + } + + const sequences = new Set(); + const days = new Set(); + const validated: ValidatedAlertJwt[] = []; + + for (let i = 0; i < body.jwts.length; i++) { + const raw = body.jwts[i]; + const prefix = `jwts[${i}]`; + if (raw === null || typeof raw !== "object") { + details.push(`${prefix} must be an object`); + continue; + } + const item = raw as JwtItemBody; + + if (!isFiniteInteger(item.sequence)) { + details.push(`${prefix}.sequence must be an integer`); + continue; + } + if (sequences.has(item.sequence)) { + details.push(`${prefix}.sequence ${item.sequence} is duplicated`); + } else { + sequences.add(item.sequence); + } + + if (typeof item.day !== "string" || !DAY_RE.test(item.day)) { + details.push(`${prefix}.day must be YYYY-MM-DD`); + continue; + } + if (days.has(item.day)) { + details.push(`${prefix}.day ${item.day} is duplicated`); + } else { + days.add(item.day); + } + + if (typeof item.jwt !== "string" || item.jwt.length === 0) { + details.push(`${prefix}.jwt is required`); + continue; + } + + if (!isFiniteInteger(item.nbf) || !isFiniteInteger(item.exp)) { + details.push(`${prefix}.nbf and ${prefix}.exp must be integer unix seconds`); + continue; + } + if (item.nbf >= item.exp) { + details.push(`${prefix}.nbf must be less than ${prefix}.exp`); + continue; + } + + if (timezone !== undefined && isValidIanaTimeZone(timezone)) { + const dayFromNbf = calendarDayInTimeZone(item.nbf, timezone); + if (dayFromNbf !== item.day) { + details.push( + `${prefix}.day ${item.day} does not match nbf ${item.nbf} in timezone ${timezone} (${dayFromNbf})` + ); + } + } + + try { + const verified = await decodeAndVerifyDelegatedJwt(item.jwt); + if (!verified.verified) { + details.push(`${prefix}.jwt failed verification`); + continue; + } + if (verified.issuer !== userDid) { + details.push( + `${prefix}.jwt iss ${verified.issuer} does not match the authenticated user` + ); + continue; + } + const payloadNbf = verified.payload.nbf; + const payloadExp = verified.payload.exp; + if (payloadNbf !== item.nbf) { + details.push( + `${prefix}.nbf ${item.nbf} does not match JWT claim nbf ${String(payloadNbf)}` + ); + } + if (payloadExp !== item.exp) { + details.push( + `${prefix}.exp ${item.exp} does not match JWT claim exp ${String(payloadExp)}` + ); + } + if ( + typeof payloadNbf === "number" && + typeof payloadExp === "number" && + payloadNbf >= payloadExp + ) { + details.push(`${prefix} JWT nbf must be less than exp`); + } + } catch (err) { + const info = clientErrorInfo(err); + if (info.code === DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE) { + return { + ok: false, + error: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE, + message: info.message, + details: [`${prefix}: ${info.message}`], + }; + } + details.push(`${prefix}.jwt: ${info.message}`); + continue; + } + + validated.push({ + sequence: item.sequence, + day: item.day, + jwt: item.jwt, + nbf: item.nbf, + exp: item.exp, + }); + } + + if ( + details.length === 0 && + body.jwts.length === EXPECTED_ALERT_JWT_BATCH_SIZE && + sequences.size === EXPECTED_ALERT_JWT_BATCH_SIZE + ) { + const seqList = [...sequences].sort((a, b) => a - b); + const spanOk = + seqList[seqList.length - 1] - seqList[0] === + EXPECTED_ALERT_JWT_BATCH_SIZE - 1; + if (!spanOk) { + details.push( + `sequence values must be 100 consecutive integers (got ${seqList[0]}..${seqList[seqList.length - 1]})` + ); + } + } + + if ( + details.length > 0 || + batchId === undefined || + timezone === undefined || + validated.length !== EXPECTED_ALERT_JWT_BATCH_SIZE + ) { + return fail(details); + } + + return { + ok: true, + batchId, + timezone, + jwts: validated, + }; +} + +function fail(details: string[]): BatchValidationFailure { + const reported = details.slice(0, MAX_REPORTED_ERRORS); + if (details.length > MAX_REPORTED_ERRORS) { + reported.push(`and ${details.length - MAX_REPORTED_ERRORS} more`); + } + return { + ok: false, + error: "ALERT_AUTHORIZATION_INVALID_BATCH", + message: + "Delegated notification-JWT batch was rejected. Nothing was stored.", + details: reported.length > 0 ? reported : ["Invalid batch"], + }; +} diff --git a/src/vc/index.ts b/src/vc/index.ts index 0c78821..267351c 100644 --- a/src/vc/index.ts +++ b/src/vc/index.ts @@ -13,6 +13,8 @@ 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"; +export const DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE = + "DELEGATED_JWT_UNSUPPORTED_IDENTITY"; const resolver = new Resolver({ ethr: didEthLocalResolver, @@ -24,14 +26,26 @@ export type VerifiedJwt = { verified: boolean; }; -export async function decodeAndVerifyJwt(jwt: string): Promise { +function parseJwtHeaderAndPayload(jwt: string): { + header: Record; + payload: Record; + pieces: string[]; +} { const pieces = jwt.split("."); + if (pieces.length < 3 || pieces[0].length === 0 || pieces[1].length === 0) { + throw new Error("JWT must have a header, payload, and signature."); + } const header = JSON.parse( Buffer.from(pieces[0], "base64url").toString("utf8") ) as Record; const payload = JSON.parse( Buffer.from(pieces[1], "base64url").toString("utf8") ) as Record; + return { header, payload, pieces }; +} + +export async function decodeAndVerifyJwt(jwt: string): Promise { + const { header, payload, pieces } = parseJwtHeaderAndPayload(jwt); const issuerDid = payload.iss; if (!issuerDid || typeof issuerDid !== "string") { return Promise.reject({ @@ -98,3 +112,77 @@ export async function decodeAndVerifyJwt(jwt: string): Promise { }, }); } + +/** + * Verify a pre-issued delegated notification JWT (future nbf is expected). + * Passkey / did:peer JWANT tokens cannot be stored as standing credentials. + */ +export async function decodeAndVerifyDelegatedJwt( + jwt: string +): Promise { + let header: Record; + let payload: Record; + try { + ({ header, payload } = parseJwtHeaderAndPayload(jwt)); + } catch { + return Promise.reject({ + clientError: { + message: "Delegated credential is not a valid JWT.", + }, + }); + } + + const issuerDid = payload.iss; + if (!issuerDid || typeof issuerDid !== "string") { + return Promise.reject({ + clientError: { + message: `Missing "iss" field in JWT.`, + }, + }); + } + + if (issuerDid.startsWith(PEER_DID_PREFIX) || header.typ === "JWANT") { + return Promise.reject({ + clientError: { + message: + "This identity type cannot mint a delegated notification-JWT batch. Passkey (did:peer / JWANT) credentials require a live assertion and cannot be pre-issued for future days.", + code: DELEGATED_JWT_UNSUPPORTED_IDENTITY_CODE, + }, + }); + } + + if ( + issuerDid.startsWith(ETHR_DID_PREFIX) && + process.env.NODE_ENV === TEST_BYPASS_ENV_VALUE + ) { + return { issuer: issuerDid, payload, verified: true }; + } + + if (issuerDid.startsWith(ETHR_DID_PREFIX)) { + try { + const verified = await verifyJWT(jwt, { + resolver, + policies: { nbf: false }, + }); + return verified as VerifiedJwt; + } catch (e) { + return Promise.reject({ + clientError: { + message: `JWT failed verification: ` + e, + code: JWT_VERIFY_FAILED_CODE, + }, + }); + } + } + + return Promise.reject({ + clientError: { + message: `Unsupported DID method ${issuerDid}`, + code: UNSUPPORTED_DID_METHOD_CODE, + }, + }); +} + +export function identitySupportsDelegatedJwtBatch(did: string): boolean { + return did.startsWith(ETHR_DID_PREFIX); +}