817 lines
28 KiB
TypeScript
817 lines
28 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_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_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
|
|
? {
|
|
id: passportChallenge.id,
|
|
data: passportChallenge.data,
|
|
}
|
|
: 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}`,
|
|
].join('\n');
|
|
}
|
|
|
|
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,
|
|
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;
|
|
}
|
|
) {
|
|
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,
|
|
requireNfc: !!optionsArg.requireNfc,
|
|
},
|
|
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');
|
|
}
|
|
|
|
await passportChallenge.markApproved({
|
|
signatureFormat: optionsArg.signatureFormat || 'raw',
|
|
location: optionsArg.location,
|
|
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 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}`);
|
|
}
|
|
}
|
|
}
|
|
}
|