435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
|
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||
|
|
|
||
|
|
import * as plugins from '../ts/plugins.js';
|
||
|
|
import { PassportChallenge } from '../ts/reception/classes.passportchallenge.js';
|
||
|
|
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||
|
|
import { PassportManager } from '../ts/reception/classes.passportmanager.js';
|
||
|
|
import { PassportNonce } from '../ts/reception/classes.passportnonce.js';
|
||
|
|
|
||
|
|
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||
|
|
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||
|
|
};
|
||
|
|
|
||
|
|
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||
|
|
return Object.entries(queryArg).every(([keyArg, valueArg]) => {
|
||
|
|
return getNestedValue(targetArg, keyArg) === valueArg;
|
||
|
|
});
|
||
|
|
};
|
||
|
|
|
||
|
|
const createTestPassportManager = () => {
|
||
|
|
const passportDevices = new Map<string, PassportDevice>();
|
||
|
|
const passportChallenges = new Map<string, PassportChallenge>();
|
||
|
|
const passportNonces = new Map<string, PassportNonce>();
|
||
|
|
const activityLogCalls: Array<{
|
||
|
|
userId: string;
|
||
|
|
action: string;
|
||
|
|
description: string;
|
||
|
|
}> = [];
|
||
|
|
const deliveredHintIds: string[] = [];
|
||
|
|
|
||
|
|
const manager = new PassportManager({
|
||
|
|
db: { smartdataDb: {} },
|
||
|
|
typedrouter: { addTypedRouter: () => undefined },
|
||
|
|
options: { baseUrl: 'https://idp.global' },
|
||
|
|
jwtManager: { verifyJWTAndGetData: async () => null },
|
||
|
|
activityLogManager: {
|
||
|
|
logActivity: async (userIdArg: string, actionArg: string, descriptionArg: string) => {
|
||
|
|
activityLogCalls.push({
|
||
|
|
userId: userIdArg,
|
||
|
|
action: actionArg,
|
||
|
|
description: descriptionArg,
|
||
|
|
});
|
||
|
|
},
|
||
|
|
},
|
||
|
|
passportPushManager: {
|
||
|
|
deliverChallengeHint: async (_passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) => {
|
||
|
|
deliveredHintIds.push(passportChallengeArg.data.notification!.hintId);
|
||
|
|
passportChallengeArg.data.notification = {
|
||
|
|
...passportChallengeArg.data.notification!,
|
||
|
|
status: 'sent',
|
||
|
|
attemptCount: passportChallengeArg.data.notification!.attemptCount + 1,
|
||
|
|
deliveredAt: Date.now(),
|
||
|
|
lastError: null,
|
||
|
|
};
|
||
|
|
await passportChallengeArg.save();
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
} as any);
|
||
|
|
|
||
|
|
const originalPassportDeviceSave = PassportDevice.prototype.save;
|
||
|
|
const originalPassportDeviceDelete = PassportDevice.prototype.delete;
|
||
|
|
const originalPassportChallengeSave = PassportChallenge.prototype.save;
|
||
|
|
const originalPassportChallengeDelete = PassportChallenge.prototype.delete;
|
||
|
|
const originalPassportNonceSave = PassportNonce.prototype.save;
|
||
|
|
const originalPassportNonceDelete = PassportNonce.prototype.delete;
|
||
|
|
|
||
|
|
(PassportDevice.prototype as PassportDevice & { save: () => Promise<void> }).save = async function () {
|
||
|
|
passportDevices.set(this.id, this);
|
||
|
|
};
|
||
|
|
(PassportDevice.prototype as PassportDevice & { delete: () => Promise<void> }).delete = async function () {
|
||
|
|
passportDevices.delete(this.id);
|
||
|
|
};
|
||
|
|
(PassportChallenge.prototype as PassportChallenge & { save: () => Promise<void> }).save = async function () {
|
||
|
|
passportChallenges.set(this.id, this);
|
||
|
|
};
|
||
|
|
(PassportChallenge.prototype as PassportChallenge & { delete: () => Promise<void> }).delete = async function () {
|
||
|
|
passportChallenges.delete(this.id);
|
||
|
|
};
|
||
|
|
(PassportNonce.prototype as PassportNonce & { save: () => Promise<void> }).save = async function () {
|
||
|
|
passportNonces.set(this.id, this);
|
||
|
|
};
|
||
|
|
(PassportNonce.prototype as PassportNonce & { delete: () => Promise<void> }).delete = async function () {
|
||
|
|
passportNonces.delete(this.id);
|
||
|
|
};
|
||
|
|
|
||
|
|
(manager as any).CPassportDevice = {
|
||
|
|
getInstance: async (queryArg: Record<string, any>) => {
|
||
|
|
return Array.from(passportDevices.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||
|
|
},
|
||
|
|
getInstances: async (queryArg: Record<string, any>) => {
|
||
|
|
return Array.from(passportDevices.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
(manager as any).CPassportChallenge = {
|
||
|
|
getInstance: async (queryArg: Record<string, any>) => {
|
||
|
|
return (
|
||
|
|
Array.from(passportChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null
|
||
|
|
);
|
||
|
|
},
|
||
|
|
getInstances: async (queryArg: Record<string, any>) => {
|
||
|
|
return Array.from(passportChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
(manager as any).CPassportNonce = {
|
||
|
|
getInstance: async (queryArg: Record<string, any>) => {
|
||
|
|
return Array.from(passportNonces.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||
|
|
},
|
||
|
|
getInstances: async (queryArg: Record<string, any>) => {
|
||
|
|
return Array.from(passportNonces.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
return {
|
||
|
|
manager,
|
||
|
|
passportDevices,
|
||
|
|
passportChallenges,
|
||
|
|
passportNonces,
|
||
|
|
activityLogCalls,
|
||
|
|
deliveredHintIds,
|
||
|
|
restore: () => {
|
||
|
|
PassportDevice.prototype.save = originalPassportDeviceSave;
|
||
|
|
PassportDevice.prototype.delete = originalPassportDeviceDelete;
|
||
|
|
PassportChallenge.prototype.save = originalPassportChallengeSave;
|
||
|
|
PassportChallenge.prototype.delete = originalPassportChallengeDelete;
|
||
|
|
PassportNonce.prototype.save = originalPassportNonceSave;
|
||
|
|
PassportNonce.prototype.delete = originalPassportNonceDelete;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const createRawPassportSigner = async () => {
|
||
|
|
const subtle = plugins.crypto.webcrypto.subtle;
|
||
|
|
const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||
|
|
'sign',
|
||
|
|
'verify',
|
||
|
|
]);
|
||
|
|
const publicKeyRaw = Buffer.from(await subtle.exportKey('raw', keyPair.publicKey)).toString('base64');
|
||
|
|
|
||
|
|
return {
|
||
|
|
publicKeyX963Base64: publicKeyRaw,
|
||
|
|
sign: async (payloadArg: string) => {
|
||
|
|
const signature = await subtle.sign(
|
||
|
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||
|
|
keyPair.privateKey,
|
||
|
|
Buffer.from(payloadArg, 'utf8')
|
||
|
|
);
|
||
|
|
return Buffer.from(signature).toString('base64');
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const createDerPassportSigner = () => {
|
||
|
|
const keyPair = plugins.crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||
|
|
const publicJwk = keyPair.publicKey.export({ format: 'jwk' }) as JsonWebKey;
|
||
|
|
const publicKeyX963Base64 = Buffer.concat([
|
||
|
|
Buffer.from([4]),
|
||
|
|
Buffer.from(publicJwk.x!, 'base64url'),
|
||
|
|
Buffer.from(publicJwk.y!, 'base64url'),
|
||
|
|
]).toString('base64');
|
||
|
|
|
||
|
|
return {
|
||
|
|
publicKeyX963Base64,
|
||
|
|
sign: (payloadArg: string) => {
|
||
|
|
return plugins.crypto.sign('sha256', Buffer.from(payloadArg, 'utf8'), keyPair.privateKey).toString('base64');
|
||
|
|
},
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const createSignedDeviceRequest = async (
|
||
|
|
managerArg: PassportManager,
|
||
|
|
signerArg: { sign: (payloadArg: string) => Promise<string> | string },
|
||
|
|
requestArg: {
|
||
|
|
deviceId: string;
|
||
|
|
action: string;
|
||
|
|
signedFields?: string[];
|
||
|
|
}
|
||
|
|
) => {
|
||
|
|
const baseRequest = {
|
||
|
|
deviceId: requestArg.deviceId,
|
||
|
|
timestamp: Date.now(),
|
||
|
|
nonce: plugins.crypto.randomUUID(),
|
||
|
|
};
|
||
|
|
const payload = (managerArg as any).buildDeviceRequestSigningPayload(
|
||
|
|
baseRequest,
|
||
|
|
requestArg.action,
|
||
|
|
requestArg.signedFields || []
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
...baseRequest,
|
||
|
|
signatureBase64: await signerArg.sign(payload),
|
||
|
|
signatureFormat: 'raw' as const,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
tap.test('enrolls a passport device from a pairing challenge', async () => {
|
||
|
|
const { manager, passportDevices, passportChallenges, activityLogCalls, restore } =
|
||
|
|
createTestPassportManager();
|
||
|
|
|
||
|
|
try {
|
||
|
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-1', {
|
||
|
|
deviceLabel: 'Phil iPhone',
|
||
|
|
platform: 'ios',
|
||
|
|
capabilities: {
|
||
|
|
gps: true,
|
||
|
|
nfc: true,
|
||
|
|
push: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const signer = await createRawPassportSigner();
|
||
|
|
const signatureBase64 = await signer.sign(enrollment.signingPayload);
|
||
|
|
|
||
|
|
const passportDevice = await manager.completeEnrollment({
|
||
|
|
pairingToken: enrollment.pairingToken,
|
||
|
|
deviceLabel: 'Phil iPhone',
|
||
|
|
platform: 'ios',
|
||
|
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||
|
|
signatureBase64,
|
||
|
|
signatureFormat: 'raw',
|
||
|
|
capabilities: {
|
||
|
|
gps: true,
|
||
|
|
nfc: true,
|
||
|
|
push: true,
|
||
|
|
},
|
||
|
|
appVersion: '1.0.0',
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(passportDevice.data.userId).toEqual('user-1');
|
||
|
|
expect(passportDevice.data.label).toEqual('Phil iPhone');
|
||
|
|
expect(passportDevices.size).toEqual(1);
|
||
|
|
expect(passportChallenges.size).toEqual(1);
|
||
|
|
expect(Array.from(passportChallenges.values())[0].data.status).toEqual('approved');
|
||
|
|
expect(activityLogCalls[0].action).toEqual('passport_device_enrolled');
|
||
|
|
} finally {
|
||
|
|
restore();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('creates and approves a passport challenge with DER signatures and evidence', async () => {
|
||
|
|
const { manager, activityLogCalls, deliveredHintIds, restore } = createTestPassportManager();
|
||
|
|
|
||
|
|
try {
|
||
|
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-2', {
|
||
|
|
deviceLabel: 'Office iPhone',
|
||
|
|
platform: 'ios',
|
||
|
|
capabilities: {
|
||
|
|
gps: true,
|
||
|
|
nfc: true,
|
||
|
|
push: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const signer = createDerPassportSigner();
|
||
|
|
const passportDevice = await manager.completeEnrollment({
|
||
|
|
pairingToken: enrollment.pairingToken,
|
||
|
|
deviceLabel: 'Office iPhone',
|
||
|
|
platform: 'ios',
|
||
|
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||
|
|
signatureBase64: signer.sign(enrollment.signingPayload),
|
||
|
|
signatureFormat: 'der',
|
||
|
|
capabilities: {
|
||
|
|
gps: true,
|
||
|
|
nfc: true,
|
||
|
|
push: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const challengeResult = await manager.createPassportChallengeForUser('user-2', {
|
||
|
|
type: 'physical_access',
|
||
|
|
preferredDeviceId: passportDevice.id,
|
||
|
|
audience: 'hq-door-a',
|
||
|
|
notificationTitle: 'Office entry request',
|
||
|
|
requireLocation: true,
|
||
|
|
requireNfc: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(deliveredHintIds).toHaveLength(1);
|
||
|
|
expect(challengeResult.challenge.data.notification?.status).toEqual('sent');
|
||
|
|
|
||
|
|
await expect(
|
||
|
|
manager.approvePassportChallenge({
|
||
|
|
challengeId: challengeResult.challenge.id,
|
||
|
|
deviceId: passportDevice.id,
|
||
|
|
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||
|
|
signatureFormat: 'der',
|
||
|
|
})
|
||
|
|
).rejects.toThrow();
|
||
|
|
|
||
|
|
const approvedChallenge = await manager.approvePassportChallenge({
|
||
|
|
challengeId: challengeResult.challenge.id,
|
||
|
|
deviceId: passportDevice.id,
|
||
|
|
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||
|
|
signatureFormat: 'der',
|
||
|
|
location: {
|
||
|
|
latitude: 53.0793,
|
||
|
|
longitude: 8.8017,
|
||
|
|
accuracyMeters: 12,
|
||
|
|
capturedAt: Date.now(),
|
||
|
|
},
|
||
|
|
nfc: {
|
||
|
|
readerId: 'door-reader-a',
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
expect(approvedChallenge.data.status).toEqual('approved');
|
||
|
|
expect(approvedChallenge.data.evidence?.signatureFormat).toEqual('der');
|
||
|
|
expect(approvedChallenge.data.evidence?.location?.accuracyMeters).toEqual(12);
|
||
|
|
expect(approvedChallenge.data.evidence?.nfc?.readerId).toEqual('door-reader-a');
|
||
|
|
expect(activityLogCalls.at(-1)?.action).toEqual('passport_challenge_approved');
|
||
|
|
} finally {
|
||
|
|
restore();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
tap.test('registers push tokens and loads pending challenges through signed device requests', async () => {
|
||
|
|
const { manager, passportNonces, restore } = createTestPassportManager();
|
||
|
|
|
||
|
|
try {
|
||
|
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-3', {
|
||
|
|
deviceLabel: 'Work iPhone',
|
||
|
|
platform: 'ios',
|
||
|
|
capabilities: {
|
||
|
|
gps: true,
|
||
|
|
nfc: false,
|
||
|
|
push: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const signer = await createRawPassportSigner();
|
||
|
|
const passportDevice = await manager.completeEnrollment({
|
||
|
|
pairingToken: enrollment.pairingToken,
|
||
|
|
deviceLabel: 'Work iPhone',
|
||
|
|
platform: 'ios',
|
||
|
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||
|
|
signatureBase64: await signer.sign(enrollment.signingPayload),
|
||
|
|
signatureFormat: 'raw',
|
||
|
|
capabilities: {
|
||
|
|
gps: true,
|
||
|
|
nfc: false,
|
||
|
|
push: true,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const pushRequest = await createSignedDeviceRequest(manager, signer, {
|
||
|
|
deviceId: passportDevice.id,
|
||
|
|
action: 'registerPassportPushToken',
|
||
|
|
signedFields: [
|
||
|
|
'provider=apns',
|
||
|
|
'token=device-token-1',
|
||
|
|
'topic=global.idp.swiftapp',
|
||
|
|
'environment=development',
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
const registeredPassportDevice = await (manager as any).authenticatePassportDeviceRequest(
|
||
|
|
{
|
||
|
|
...pushRequest,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
action: 'registerPassportPushToken',
|
||
|
|
signedFields: [
|
||
|
|
'provider=apns',
|
||
|
|
'token=device-token-1',
|
||
|
|
'topic=global.idp.swiftapp',
|
||
|
|
'environment=development',
|
||
|
|
],
|
||
|
|
}
|
||
|
|
);
|
||
|
|
registeredPassportDevice.data.pushRegistration = {
|
||
|
|
provider: 'apns',
|
||
|
|
token: 'device-token-1',
|
||
|
|
topic: 'global.idp.swiftapp',
|
||
|
|
environment: 'development',
|
||
|
|
registeredAt: Date.now(),
|
||
|
|
};
|
||
|
|
await registeredPassportDevice.save();
|
||
|
|
|
||
|
|
const challengeResult = await manager.createPassportChallengeForUser('user-3', {
|
||
|
|
type: 'authentication',
|
||
|
|
preferredDeviceId: passportDevice.id,
|
||
|
|
audience: 'office-saas',
|
||
|
|
notificationTitle: 'Office sign-in verification',
|
||
|
|
});
|
||
|
|
|
||
|
|
const listRequest = await createSignedDeviceRequest(manager, signer, {
|
||
|
|
deviceId: passportDevice.id,
|
||
|
|
action: 'listPendingPassportChallenges',
|
||
|
|
});
|
||
|
|
|
||
|
|
const authenticatedDevice = await (manager as any).authenticatePassportDeviceRequest(listRequest, {
|
||
|
|
action: 'listPendingPassportChallenges',
|
||
|
|
});
|
||
|
|
const pendingChallenges = await manager.listPendingChallengesForDevice(authenticatedDevice.id);
|
||
|
|
expect(pendingChallenges).toHaveLength(1);
|
||
|
|
expect(pendingChallenges[0].id).toEqual(challengeResult.challenge.id);
|
||
|
|
|
||
|
|
const hintId = challengeResult.challenge.data.notification!.hintId;
|
||
|
|
const getRequest = await createSignedDeviceRequest(manager, signer, {
|
||
|
|
deviceId: passportDevice.id,
|
||
|
|
action: 'getPassportChallengeByHint',
|
||
|
|
signedFields: [`hint_id=${hintId}`],
|
||
|
|
});
|
||
|
|
const hintChallenge = await manager.getPassportChallengeByHint(
|
||
|
|
(
|
||
|
|
await (manager as any).authenticatePassportDeviceRequest(getRequest, {
|
||
|
|
action: 'getPassportChallengeByHint',
|
||
|
|
signedFields: [`hint_id=${hintId}`],
|
||
|
|
})
|
||
|
|
).id,
|
||
|
|
hintId
|
||
|
|
);
|
||
|
|
expect(hintChallenge?.id).toEqual(challengeResult.challenge.id);
|
||
|
|
|
||
|
|
const seenRequest = await createSignedDeviceRequest(manager, signer, {
|
||
|
|
deviceId: passportDevice.id,
|
||
|
|
action: 'markPassportChallengeSeen',
|
||
|
|
signedFields: [`hint_id=${hintId}`],
|
||
|
|
});
|
||
|
|
await (manager as any).authenticatePassportDeviceRequest(seenRequest, {
|
||
|
|
action: 'markPassportChallengeSeen',
|
||
|
|
signedFields: [`hint_id=${hintId}`],
|
||
|
|
});
|
||
|
|
const seenChallenge = await manager.markPassportChallengeSeen(passportDevice.id, hintId);
|
||
|
|
expect(seenChallenge.data.notification?.status).toEqual('seen');
|
||
|
|
expect(passportNonces.size).toEqual(4);
|
||
|
|
} finally {
|
||
|
|
restore();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
export default tap.start();
|