Files
app/ts/reception/classes.passportmanager.ts
jkunz e9eb9b4172 add office-aware passport policies and alert lifecycle
Enforce geofenced location evidence for passport challenges and extend admin alerting so mobile devices can review, dismiss, and act on real org and security events.
2026-04-20 13:21:28 +00:00

960 lines
34 KiB
TypeScript

import * as plugins from '../plugins.js';
import { PassportChallenge } from './classes.passportchallenge.js';
import { PassportDevice } from './classes.passportdevice.js';
import { PassportNonce } from './classes.passportnonce.js';
import { logger } from './logging.js';
import { Reception } from './classes.reception.js';
export class PassportManager {
private readonly enrollmentChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
minutes: 10,
});
private readonly assertionChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
minutes: 5,
});
private readonly deviceRequestWindowMillis = plugins.smarttime.getMilliSecondsFromUnits({
minutes: 5,
});
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedRouter = new plugins.typedrequest.TypedRouter();
public CPassportDevice = plugins.smartdata.setDefaultManagerForDoc(this, PassportDevice);
public CPassportChallenge = plugins.smartdata.setDefaultManagerForDoc(this, PassportChallenge);
public CPassportNonce = plugins.smartdata.setDefaultManagerForDoc(this, PassportNonce);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportEnrollmentChallenge>(
'createPassportEnrollmentChallenge',
async (requestArg) => {
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
const enrollmentChallenge = await this.createEnrollmentChallengeForUser(userId, {
deviceLabel: requestArg.deviceLabel,
platform: requestArg.platform,
appVersion: requestArg.appVersion,
capabilities: requestArg.capabilities,
});
return {
challengeId: enrollmentChallenge.challenge.id,
pairingToken: enrollmentChallenge.pairingToken,
pairingPayload: enrollmentChallenge.pairingPayload,
signingPayload: enrollmentChallenge.signingPayload,
expiresAt: enrollmentChallenge.challenge.data.expiresAt,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompletePassportEnrollment>(
'completePassportEnrollment',
async (requestArg) => {
const passportDevice = await this.completeEnrollment({
pairingToken: requestArg.pairingToken,
deviceLabel: requestArg.deviceLabel,
platform: requestArg.platform,
publicKeyX963Base64: requestArg.publicKeyX963Base64,
signatureBase64: requestArg.signatureBase64,
signatureFormat: requestArg.signatureFormat,
appVersion: requestArg.appVersion,
capabilities: requestArg.capabilities,
});
return {
device: {
id: passportDevice.id,
data: passportDevice.data,
},
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDevices>(
'getPassportDevices',
async (requestArg) => {
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
const devices = await this.getPassportDevicesForUser(userId);
return {
devices: devices.map((deviceArg) => ({
id: deviceArg.id,
data: deviceArg.data,
})),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokePassportDevice>(
'revokePassportDevice',
async (requestArg) => {
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
await this.revokePassportDeviceForUser(userId, requestArg.deviceId);
return {
success: true,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportChallenge>(
'createPassportChallenge',
async (requestArg) => {
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
const challengeResult = await this.createPassportChallengeForUser(userId, {
type: requestArg.type,
preferredDeviceId: requestArg.preferredDeviceId,
audience: requestArg.audience,
notificationTitle: requestArg.notificationTitle,
requireLocation: requestArg.requireLocation,
requireNfc: requestArg.requireNfc,
});
return {
challengeId: challengeResult.challenge.id,
challenge: challengeResult.challenge.data.challenge,
signingPayload: challengeResult.signingPayload,
deviceId: challengeResult.challenge.data.deviceId!,
expiresAt: challengeResult.challenge.data.expiresAt,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDashboard>(
'getPassportDashboard',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'getPassportDashboard',
});
const user = await this.receptionRef.userManager.CUser.getInstance({
id: passportDevice.data.userId,
});
const organizations = user
? await this.receptionRef.organizationmanager.getAllOrganizationsForUser(user)
: [];
const devices = await this.getPassportDevicesForUser(passportDevice.data.userId);
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
const alerts = await this.receptionRef.alertManager.listAlertsForUser(passportDevice.data.userId);
return {
profile: {
userId: passportDevice.data.userId,
name: user?.data?.name || user?.data?.email || 'Passport User',
handle: user?.data?.username || user?.data?.email || passportDevice.data.userId,
organizations: organizations.map((organizationArg) => ({
id: organizationArg.id,
name: organizationArg.data.name,
})),
deviceCount: devices.length,
recoverySummary: 'Recovery workflows are not configured yet for this passport.',
},
devices: devices.map((deviceArg) => ({ id: deviceArg.id, data: deviceArg.data })),
challenges: challenges.map((challengeArg) => ({
challenge: { id: challengeArg.id, data: challengeArg.data },
signingPayload: this.buildChallengeSigningPayload(challengeArg),
})),
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ApprovePassportChallenge>(
'approvePassportChallenge',
async (requestArg) => {
const passportChallenge = await this.approvePassportChallenge({
challengeId: requestArg.challengeId,
deviceId: requestArg.deviceId,
signatureBase64: requestArg.signatureBase64,
signatureFormat: requestArg.signatureFormat,
location: requestArg.location,
nfc: requestArg.nfc,
});
return {
success: true,
challenge: {
id: passportChallenge.id,
data: passportChallenge.data,
},
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RejectPassportChallenge>(
'rejectPassportChallenge',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'rejectPassportChallenge',
signedFields: [`challenge_id=${requestArg.challengeId}`],
});
const challenge = await this.rejectPassportChallenge(passportDevice.id, requestArg.challengeId);
return {
success: true,
challenge: {
id: challenge.id,
data: challenge.data,
},
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegisterPassportPushToken>(
'registerPassportPushToken',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'registerPassportPushToken',
signedFields: [
`provider=${requestArg.provider}`,
`token=${requestArg.token}`,
`topic=${requestArg.topic}`,
`environment=${requestArg.environment}`,
],
});
passportDevice.data.pushRegistration = {
provider: requestArg.provider,
token: requestArg.token,
topic: requestArg.topic,
environment: requestArg.environment,
registeredAt: Date.now(),
lastDeliveredAt: passportDevice.data.pushRegistration?.lastDeliveredAt,
lastError: undefined,
};
passportDevice.data.lastSeenAt = Date.now();
await passportDevice.save();
return {
success: true,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPendingPassportChallenges>(
'listPendingPassportChallenges',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'listPendingPassportChallenges',
});
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
return {
challenges: challenges.map((challengeArg) => ({
id: challengeArg.id,
data: challengeArg.data,
})),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportChallengeByHint>(
'getPassportChallengeByHint',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'getPassportChallengeByHint',
signedFields: [`hint_id=${requestArg.hintId}`],
});
const passportChallenge = await this.getPassportChallengeByHint(passportDevice.id, requestArg.hintId);
return {
challenge: passportChallenge
? {
challenge: {
id: passportChallenge.id,
data: passportChallenge.data,
},
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
}
: undefined,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportChallengeSeen>(
'markPassportChallengeSeen',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'markPassportChallengeSeen',
signedFields: [`hint_id=${requestArg.hintId}`],
});
await this.markPassportChallengeSeen(passportDevice.id, requestArg.hintId);
return {
success: true,
};
}
)
);
}
private async getAuthenticatedUserId(jwtArg: string) {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
return jwt.data.userId;
}
private getOriginHost() {
return new URL(this.receptionRef.options.baseUrl).host;
}
private createOpaqueToken(prefixArg: string) {
return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`;
}
private buildDeviceRequestSigningPayload(
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
actionArg: string,
signedFieldsArg: string[] = []
) {
return [
'purpose=passport-device-request',
`origin=${this.getOriginHost()}`,
`action=${actionArg}`,
`device_id=${requestArg.deviceId}`,
`timestamp=${requestArg.timestamp}`,
`nonce=${requestArg.nonce}`,
...signedFieldsArg,
].join('\n');
}
private async consumePassportNonce(deviceIdArg: string, nonceArg: string, timestampArg: number) {
const now = Date.now();
if (Math.abs(now - timestampArg) > this.deviceRequestWindowMillis) {
throw new plugins.typedrequest.TypedResponseError('Passport device request timestamp expired');
}
const existingNonce = await this.CPassportNonce.getInstance({
id: PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`),
});
if (existingNonce && !existingNonce.isExpired(now)) {
throw new plugins.typedrequest.TypedResponseError('Passport device request replay detected');
}
const passportNonce = existingNonce || new PassportNonce();
passportNonce.id = PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`);
passportNonce.data = {
deviceId: deviceIdArg,
nonceHash: PassportNonce.hashNonce(nonceArg),
createdAt: now,
expiresAt: now + this.deviceRequestWindowMillis,
};
await passportNonce.save();
}
public async authenticatePassportDeviceRequest(
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
optionsArg: {
action: string;
signedFields?: string[];
}
) {
const passportDevice = await this.CPassportDevice.getInstance({
id: requestArg.deviceId,
'data.status': 'active',
});
if (!passportDevice) {
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
}
const verified = this.verifyPassportSignature(
passportDevice.data.publicKeyX963Base64,
requestArg.signatureBase64,
requestArg.signatureFormat || 'raw',
this.buildDeviceRequestSigningPayload(
requestArg,
optionsArg.action,
optionsArg.signedFields || []
)
);
if (!verified) {
throw new plugins.typedrequest.TypedResponseError('Passport device signature invalid');
}
await this.consumePassportNonce(requestArg.deviceId, requestArg.nonce, requestArg.timestamp);
passportDevice.data.lastSeenAt = Date.now();
await passportDevice.save();
return passportDevice;
}
private normalizeCapabilities(
capabilitiesArg?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>
): plugins.idpInterfaces.data.IPassportCapabilities {
return {
gps: !!capabilitiesArg?.gps,
nfc: !!capabilitiesArg?.nfc,
push: !!capabilitiesArg?.push,
};
}
private buildEnrollmentSigningPayload(pairingTokenArg: string, challengeArg: PassportChallenge) {
return [
'purpose=passport-enrollment',
`origin=${this.getOriginHost()}`,
`token=${pairingTokenArg}`,
`challenge=${challengeArg.data.challenge}`,
`challenge_id=${challengeArg.id}`,
].join('\n');
}
private buildChallengeSigningPayload(challengeArg: PassportChallenge) {
return [
'purpose=passport-challenge',
`origin=${this.getOriginHost()}`,
`challenge=${challengeArg.data.challenge}`,
`challenge_id=${challengeArg.id}`,
`type=${challengeArg.data.type}`,
`device_id=${challengeArg.data.deviceId || ''}`,
`audience=${challengeArg.data.metadata.audience || ''}`,
`require_location=${challengeArg.data.metadata.requireLocation}`,
`require_nfc=${challengeArg.data.metadata.requireNfc}`,
`location_policy=${challengeArg.data.metadata.locationPolicy ? JSON.stringify(challengeArg.data.metadata.locationPolicy) : ''}`,
].join('\n');
}
private evaluateLocationPolicy(
locationPolicyArg: plugins.idpInterfaces.data.IPassportLocationPolicy,
locationEvidenceArg: plugins.idpInterfaces.data.IPassportLocationEvidence
) {
const earthRadiusMeters = 6371000;
const latitude1 = (locationPolicyArg.latitude * Math.PI) / 180;
const latitude2 = (locationEvidenceArg.latitude * Math.PI) / 180;
const deltaLatitude = ((locationEvidenceArg.latitude - locationPolicyArg.latitude) * Math.PI) / 180;
const deltaLongitude = ((locationEvidenceArg.longitude - locationPolicyArg.longitude) * Math.PI) / 180;
const haversine =
Math.sin(deltaLatitude / 2) * Math.sin(deltaLatitude / 2) +
Math.cos(latitude1) * Math.cos(latitude2) * Math.sin(deltaLongitude / 2) * Math.sin(deltaLongitude / 2);
const distanceMeters = 2 * earthRadiusMeters * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine));
const accuracyAccepted =
!locationPolicyArg.maxAccuracyMeters ||
locationEvidenceArg.accuracyMeters <= locationPolicyArg.maxAccuracyMeters;
const withinGeofence = distanceMeters <= locationPolicyArg.radiusMeters;
return {
matched: accuracyAccepted && withinGeofence,
distanceMeters,
accuracyAccepted,
evaluatedAt: Date.now(),
reason: !accuracyAccepted
? `Accuracy ${locationEvidenceArg.accuracyMeters}m exceeds allowed ${locationPolicyArg.maxAccuracyMeters}m`
: !withinGeofence
? `Location is ${Math.round(distanceMeters)}m away from ${locationPolicyArg.label || 'required area'}`
: undefined,
};
}
private createPairingPayload(
pairingTokenArg: string,
challengeArg: PassportChallenge,
deviceLabelArg: string
) {
const searchParams = new URLSearchParams({
token: pairingTokenArg,
challenge: challengeArg.data.challenge,
challenge_id: challengeArg.id,
origin: this.getOriginHost(),
device: deviceLabelArg,
});
return `idp.global://pair?${searchParams.toString()}`;
}
private createP256JwkFromX963(publicKeyX963Base64Arg: string) {
const rawPublicKey = Buffer.from(publicKeyX963Base64Arg, 'base64');
if (rawPublicKey.length !== 65 || rawPublicKey[0] !== 4) {
throw new plugins.typedrequest.TypedResponseError('Invalid passport public key');
}
return {
kty: 'EC',
crv: 'P-256',
x: rawPublicKey.subarray(1, 33).toString('base64url'),
y: rawPublicKey.subarray(33, 65).toString('base64url'),
ext: true,
} as JsonWebKey;
}
private verifyPassportSignature(
publicKeyX963Base64Arg: string,
signatureBase64Arg: string,
signatureFormatArg: plugins.idpInterfaces.data.TPassportSignatureFormat,
payloadArg: string
) {
const publicKey = plugins.crypto.createPublicKey({
key: this.createP256JwkFromX963(publicKeyX963Base64Arg),
format: 'jwk',
});
const signature = Buffer.from(signatureBase64Arg, 'base64');
const payload = Buffer.from(payloadArg, 'utf8');
return signatureFormatArg === 'raw'
? plugins.crypto.verify('sha256', payload, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature)
: plugins.crypto.verify('sha256', payload, publicKey, signature);
}
public async createEnrollmentChallengeForUser(
userIdArg: string,
optionsArg: {
deviceLabel: string;
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
appVersion?: string;
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
}
) {
const pairingToken = this.createOpaqueToken('passport_pair_');
const passportChallenge = new PassportChallenge();
passportChallenge.id = plugins.smartunique.shortId();
passportChallenge.data = {
userId: userIdArg,
deviceId: null,
type: 'device_enrollment',
status: 'pending',
tokenHash: PassportChallenge.hashToken(pairingToken),
challenge: this.createOpaqueToken('challenge_'),
metadata: {
originHost: this.getOriginHost(),
deviceLabel: optionsArg.deviceLabel,
requireLocation: false,
requireNfc: false,
locationPolicy: undefined,
requestedCapabilities: this.normalizeCapabilities(optionsArg.capabilities),
},
evidence: undefined,
notification: undefined,
createdAt: Date.now(),
expiresAt: Date.now() + this.enrollmentChallengeMillis,
completedAt: null,
};
await passportChallenge.save();
return {
challenge: passportChallenge,
pairingToken,
pairingPayload: this.createPairingPayload(
pairingToken,
passportChallenge,
optionsArg.deviceLabel
),
signingPayload: this.buildEnrollmentSigningPayload(pairingToken, passportChallenge),
};
}
public async completeEnrollment(optionsArg: {
pairingToken: string;
deviceLabel: string;
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
publicKeyX963Base64: string;
signatureBase64: string;
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
appVersion?: string;
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
}) {
const passportChallenge = await this.CPassportChallenge.getInstance({
'data.tokenHash': PassportChallenge.hashToken(optionsArg.pairingToken),
'data.type': 'device_enrollment',
'data.status': 'pending',
});
if (!passportChallenge) {
throw new plugins.typedrequest.TypedResponseError('Pairing token not found');
}
if (passportChallenge.isExpired()) {
await passportChallenge.markExpired();
throw new plugins.typedrequest.TypedResponseError('Pairing token expired');
}
const existingPassportDevice = await this.CPassportDevice.getInstance({
'data.publicKeyX963Base64': optionsArg.publicKeyX963Base64,
'data.status': 'active',
});
if (existingPassportDevice) {
throw new plugins.typedrequest.TypedResponseError('Passport device already enrolled');
}
const verified = this.verifyPassportSignature(
optionsArg.publicKeyX963Base64,
optionsArg.signatureBase64,
optionsArg.signatureFormat || 'raw',
this.buildEnrollmentSigningPayload(optionsArg.pairingToken, passportChallenge)
);
if (!verified) {
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
}
const passportDevice = new PassportDevice();
passportDevice.id = plugins.smartunique.shortId();
passportDevice.data = {
userId: passportChallenge.data.userId,
label: optionsArg.deviceLabel,
platform: optionsArg.platform,
status: 'active',
publicKeyAlgorithm: 'p256',
publicKeyX963Base64: optionsArg.publicKeyX963Base64,
capabilities: this.normalizeCapabilities(
optionsArg.capabilities || passportChallenge.data.metadata.requestedCapabilities
),
pushRegistration: undefined,
appVersion: optionsArg.appVersion,
createdAt: Date.now(),
lastSeenAt: Date.now(),
lastChallengeAt: undefined,
};
await passportDevice.save();
passportChallenge.data.deviceId = passportDevice.id;
passportChallenge.data.tokenHash = null;
await passportChallenge.markApproved({
signatureFormat: optionsArg.signatureFormat || 'raw',
});
await this.receptionRef.activityLogManager.logActivity(
passportChallenge.data.userId,
'passport_device_enrolled',
`Enrolled passport device ${passportDevice.data.label}`,
{
targetId: passportDevice.id,
targetType: 'passport-device',
}
);
return passportDevice;
}
public async getPassportDevicesForUser(userIdArg: string) {
const devices = await this.CPassportDevice.getInstances({
'data.userId': userIdArg,
'data.status': 'active',
});
return devices.sort(
(leftArg, rightArg) =>
(rightArg.data.lastSeenAt || rightArg.data.createdAt) -
(leftArg.data.lastSeenAt || leftArg.data.createdAt)
);
}
public async revokePassportDeviceForUser(userIdArg: string, deviceIdArg: string) {
const passportDevice = await this.CPassportDevice.getInstance({
id: deviceIdArg,
'data.userId': userIdArg,
'data.status': 'active',
});
if (!passportDevice) {
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
}
passportDevice.data.status = 'revoked';
await passportDevice.save();
await this.receptionRef.activityLogManager.logActivity(
userIdArg,
'passport_device_revoked',
`Revoked passport device ${passportDevice.data.label}`,
{
targetId: passportDevice.id,
targetType: 'passport-device',
}
);
}
public async createPassportChallengeForUser(
userIdArg: string,
optionsArg: {
type?: Exclude<plugins.idpInterfaces.data.TPassportChallengeType, 'device_enrollment'>;
preferredDeviceId?: string;
audience?: string;
notificationTitle?: string;
requireLocation?: boolean;
requireNfc?: boolean;
locationPolicy?: plugins.idpInterfaces.data.IPassportLocationPolicy;
}
) {
const passportDevices = await this.getPassportDevicesForUser(userIdArg);
if (passportDevices.length === 0) {
throw new plugins.typedrequest.TypedResponseError('No passport device enrolled');
}
const targetDevice = optionsArg.preferredDeviceId
? passportDevices.find((deviceArg) => deviceArg.id === optionsArg.preferredDeviceId)
: passportDevices[0];
if (!targetDevice) {
throw new plugins.typedrequest.TypedResponseError('Target passport device not found');
}
const passportChallenge = new PassportChallenge();
passportChallenge.id = plugins.smartunique.shortId();
passportChallenge.data = {
userId: userIdArg,
deviceId: targetDevice.id,
type: optionsArg.type || 'step_up',
status: 'pending',
tokenHash: null,
challenge: this.createOpaqueToken('passport_challenge_'),
metadata: {
originHost: this.getOriginHost(),
audience: optionsArg.audience,
notificationTitle: optionsArg.notificationTitle,
deviceLabel: targetDevice.data.label,
requireLocation: !!optionsArg.requireLocation || !!optionsArg.locationPolicy,
requireNfc: !!optionsArg.requireNfc,
locationPolicy: optionsArg.locationPolicy,
},
evidence: undefined,
notification: {
hintId: plugins.crypto.randomUUID(),
status: 'pending',
attemptCount: 0,
createdAt: Date.now(),
deliveredAt: null,
seenAt: null,
lastError: null,
},
createdAt: Date.now(),
expiresAt: Date.now() + this.assertionChallengeMillis,
completedAt: null,
};
await passportChallenge.save();
targetDevice.data.lastChallengeAt = Date.now();
await targetDevice.save();
await this.receptionRef.passportPushManager.deliverChallengeHint(targetDevice, passportChallenge);
return {
challenge: passportChallenge,
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
};
}
public async approvePassportChallenge(optionsArg: {
challengeId: string;
deviceId: string;
signatureBase64: string;
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
location?: plugins.idpInterfaces.data.IPassportLocationEvidence;
nfc?: plugins.idpInterfaces.data.IPassportNfcEvidence;
}) {
const passportChallenge = await this.CPassportChallenge.getInstance({
id: optionsArg.challengeId,
'data.status': 'pending',
});
if (!passportChallenge) {
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
}
if (passportChallenge.isExpired()) {
await passportChallenge.markExpired();
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
}
if (passportChallenge.data.deviceId && passportChallenge.data.deviceId !== optionsArg.deviceId) {
throw new plugins.typedrequest.TypedResponseError('Passport challenge not assigned to this device');
}
const passportDevice = await this.CPassportDevice.getInstance({
id: optionsArg.deviceId,
'data.status': 'active',
});
if (!passportDevice) {
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
}
if (passportDevice.data.userId !== passportChallenge.data.userId) {
throw new plugins.typedrequest.TypedResponseError('Passport device user mismatch');
}
if (passportChallenge.data.metadata.requireLocation && !optionsArg.location) {
throw new plugins.typedrequest.TypedResponseError('Location evidence required');
}
if (passportChallenge.data.metadata.requireNfc && !optionsArg.nfc) {
throw new plugins.typedrequest.TypedResponseError('NFC evidence required');
}
const verified = this.verifyPassportSignature(
passportDevice.data.publicKeyX963Base64,
optionsArg.signatureBase64,
optionsArg.signatureFormat || 'raw',
this.buildChallengeSigningPayload(passportChallenge)
);
if (!verified) {
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
}
const locationEvaluation =
passportChallenge.data.metadata.locationPolicy && optionsArg.location
? this.evaluateLocationPolicy(passportChallenge.data.metadata.locationPolicy, optionsArg.location)
: undefined;
if (passportChallenge.data.metadata.locationPolicy && !locationEvaluation?.matched) {
throw new plugins.typedrequest.TypedResponseError(
locationEvaluation?.reason || 'Location evidence did not satisfy the office policy'
);
}
await passportChallenge.markApproved({
signatureFormat: optionsArg.signatureFormat || 'raw',
location: optionsArg.location,
locationEvaluation,
nfc: optionsArg.nfc,
});
passportDevice.data.lastSeenAt = Date.now();
await passportDevice.save();
await this.receptionRef.activityLogManager.logActivity(
passportChallenge.data.userId,
'passport_challenge_approved',
`Approved passport challenge ${passportChallenge.data.type}`,
{
targetId: passportChallenge.id,
targetType: 'passport-challenge',
}
);
return passportChallenge;
}
public async rejectPassportChallenge(deviceIdArg: string, challengeIdArg: string) {
const passportChallenge = await this.CPassportChallenge.getInstance({
id: challengeIdArg,
'data.deviceId': deviceIdArg,
'data.status': 'pending',
});
if (!passportChallenge) {
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
}
if (passportChallenge.isExpired()) {
await passportChallenge.markExpired();
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
}
await passportChallenge.markRejected();
await this.receptionRef.activityLogManager.logActivity(
passportChallenge.data.userId,
'passport_challenge_rejected',
`Rejected passport challenge ${passportChallenge.data.type}`,
{
targetId: passportChallenge.id,
targetType: 'passport-challenge',
}
);
return passportChallenge;
}
public async listPendingChallengesForDevice(deviceIdArg: string) {
const passportChallenges = await this.CPassportChallenge.getInstances({
'data.deviceId': deviceIdArg,
'data.status': 'pending',
});
return passportChallenges.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
}
public async getPassportChallengeByHint(deviceIdArg: string, hintIdArg: string) {
return this.CPassportChallenge.getInstance({
'data.deviceId': deviceIdArg,
'data.status': 'pending',
'data.notification.hintId': hintIdArg,
});
}
public async markPassportChallengeSeen(deviceIdArg: string, hintIdArg: string) {
const passportChallenge = await this.getPassportChallengeByHint(deviceIdArg, hintIdArg);
if (!passportChallenge) {
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
}
passportChallenge.data.notification = {
...passportChallenge.data.notification!,
status: 'seen',
seenAt: Date.now(),
};
await passportChallenge.save();
return passportChallenge;
}
public async cleanupExpiredChallenges() {
const passportChallenges = await this.CPassportChallenge.getInstances({});
for (const passportChallenge of passportChallenges) {
if (passportChallenge.data.status === 'pending' && passportChallenge.isExpired()) {
await passportChallenge.markExpired();
}
}
const passportNonces = await this.CPassportNonce.getInstances({});
for (const passportNonce of passportNonces) {
if (passportNonce.isExpired()) {
await passportNonce.delete();
}
}
}
public async reDeliverPendingChallengeHints() {
const passportChallenges = await this.CPassportChallenge.getInstances({
'data.status': 'pending',
});
for (const passportChallenge of passportChallenges) {
if (!passportChallenge.data.notification || passportChallenge.data.notification.status === 'sent') {
continue;
}
if (!passportChallenge.data.deviceId) {
continue;
}
const passportDevice = await this.CPassportDevice.getInstance({
id: passportChallenge.data.deviceId,
'data.status': 'active',
});
if (!passportDevice) {
continue;
}
try {
await this.receptionRef.passportPushManager.deliverChallengeHint(passportDevice, passportChallenge);
} catch (errorArg) {
logger.log('warn', `passport hint redelivery failed: ${(errorArg as Error).message}`);
}
}
}
}