211 lines
6.1 KiB
JavaScript
211 lines
6.1 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const test = require("node:test");
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const {
|
||
|
|
HoloLakeSessionManager,
|
||
|
|
} = require("./hololake-session");
|
||
|
|
|
||
|
|
function fixture(overrides = {}) {
|
||
|
|
let now = 1_800_000_000_000;
|
||
|
|
const mail = [];
|
||
|
|
const manager = new HoloLakeSessionManager({
|
||
|
|
registeredEmails: ["owner@example.invalid"],
|
||
|
|
pepper: "test-only-pepper-with-enough-entropy",
|
||
|
|
stateFile: "",
|
||
|
|
now: () => now,
|
||
|
|
sendEmail: async message => {
|
||
|
|
mail.push(message);
|
||
|
|
return true;
|
||
|
|
},
|
||
|
|
...overrides,
|
||
|
|
});
|
||
|
|
return {
|
||
|
|
manager,
|
||
|
|
mail,
|
||
|
|
advance(milliseconds) {
|
||
|
|
now += milliseconds;
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
test("OTP request is non-enumerating and stores no plaintext code", async () => {
|
||
|
|
const known = fixture();
|
||
|
|
const requested = await known.manager.requestOtp({
|
||
|
|
email: "Owner@Example.Invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
assert.equal(requested.accepted, true);
|
||
|
|
assert.match(requested.request_id, /^[0-9a-f-]{36}$/);
|
||
|
|
assert.equal(known.mail.length, 1);
|
||
|
|
assert.match(known.mail[0].text, /\b\d{6}\b/);
|
||
|
|
const otp = known.mail[0].text.match(/\b\d{6}\b/)[0];
|
||
|
|
assert.doesNotMatch(JSON.stringify(known.manager.inspectState()), new RegExp(otp));
|
||
|
|
|
||
|
|
const unknown = fixture();
|
||
|
|
const decoy = await unknown.manager.requestOtp({
|
||
|
|
email: "nobody@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
assert.equal(decoy.accepted, requested.accepted);
|
||
|
|
assert.match(decoy.request_id, /^[0-9a-f-]{36}$/);
|
||
|
|
assert.equal(unknown.mail.length, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("OTP verification is device-bound, attempt-limited, and returns a one-time session token", async () => {
|
||
|
|
const state = fixture();
|
||
|
|
const requested = await state.manager.requestOtp({
|
||
|
|
email: "owner@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
const otp = state.mail[0].text.match(/\b\d{6}\b/)[0];
|
||
|
|
|
||
|
|
const wrongDevice = state.manager.verifyOtp({
|
||
|
|
requestId: requested.request_id,
|
||
|
|
code: otp,
|
||
|
|
deviceId: "ios-device-002",
|
||
|
|
});
|
||
|
|
assert.equal(wrongDevice.ok, false);
|
||
|
|
assert.equal(wrongDevice.error, "invalid_or_expired_code");
|
||
|
|
|
||
|
|
const verified = state.manager.verifyOtp({
|
||
|
|
requestId: requested.request_id,
|
||
|
|
code: otp,
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
assert.equal(verified.ok, true);
|
||
|
|
assert.match(verified.session_token, /^[A-Za-z0-9_-]{40,}$/);
|
||
|
|
assert.doesNotMatch(
|
||
|
|
JSON.stringify(state.manager.inspectState()),
|
||
|
|
new RegExp(verified.session_token),
|
||
|
|
);
|
||
|
|
|
||
|
|
const reused = state.manager.verifyOtp({
|
||
|
|
requestId: requested.request_id,
|
||
|
|
code: otp,
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
assert.equal(reused.ok, false);
|
||
|
|
assert.equal(reused.error, "invalid_or_expired_code");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("session authentication, expiry, and revocation never return the stored token", async () => {
|
||
|
|
const state = fixture({ sessionTtlSeconds: 60 });
|
||
|
|
const requested = await state.manager.requestOtp({
|
||
|
|
email: "owner@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
const otp = state.mail[0].text.match(/\b\d{6}\b/)[0];
|
||
|
|
const verified = state.manager.verifyOtp({
|
||
|
|
requestId: requested.request_id,
|
||
|
|
code: otp,
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
|
||
|
|
const active = state.manager.authenticate(
|
||
|
|
verified.session_token,
|
||
|
|
"ios-device-001",
|
||
|
|
);
|
||
|
|
assert.equal(active.ok, true);
|
||
|
|
assert.equal(active.session.device_id, "ios-device-001");
|
||
|
|
assert.equal(Object.hasOwn(active.session, "token"), false);
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
state.manager.authenticate(verified.session_token, "ios-device-002").error,
|
||
|
|
"session_device_mismatch",
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
state.manager.revoke(verified.session_token, "ios-device-002").error,
|
||
|
|
"session_device_mismatch",
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
state.manager.revoke(verified.session_token, "ios-device-001").ok,
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
state.manager.authenticate(verified.session_token, "ios-device-001").error,
|
||
|
|
"session_invalid",
|
||
|
|
);
|
||
|
|
|
||
|
|
const second = await state.manager.requestOtp({
|
||
|
|
email: "owner@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
const secondOtp = state.mail[1].text.match(/\b\d{6}\b/)[0];
|
||
|
|
const secondSession = state.manager.verifyOtp({
|
||
|
|
requestId: second.request_id,
|
||
|
|
code: secondOtp,
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
state.advance(61_000);
|
||
|
|
assert.equal(
|
||
|
|
state.manager.authenticate(
|
||
|
|
secondSession.session_token,
|
||
|
|
"ios-device-001",
|
||
|
|
).error,
|
||
|
|
"session_expired",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("OTP expires, locks after five failed attempts, and request rate is bounded", async () => {
|
||
|
|
const expired = fixture({ otpTtlSeconds: 30 });
|
||
|
|
const requested = await expired.manager.requestOtp({
|
||
|
|
email: "owner@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
const otp = expired.mail[0].text.match(/\b\d{6}\b/)[0];
|
||
|
|
expired.advance(31_000);
|
||
|
|
assert.equal(
|
||
|
|
expired.manager.verifyOtp({
|
||
|
|
requestId: requested.request_id,
|
||
|
|
code: otp,
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
}).error,
|
||
|
|
"invalid_or_expired_code",
|
||
|
|
);
|
||
|
|
|
||
|
|
const locked = fixture({ maxOtpAttempts: 5 });
|
||
|
|
const lockRequest = await locked.manager.requestOtp({
|
||
|
|
email: "owner@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
});
|
||
|
|
const realOtp = locked.mail[0].text.match(/\b\d{6}\b/)[0];
|
||
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||
|
|
assert.equal(
|
||
|
|
locked.manager.verifyOtp({
|
||
|
|
requestId: lockRequest.request_id,
|
||
|
|
code: "000000",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
}).ok,
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
assert.equal(
|
||
|
|
locked.manager.verifyOtp({
|
||
|
|
requestId: lockRequest.request_id,
|
||
|
|
code: realOtp,
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
}).error,
|
||
|
|
"invalid_or_expired_code",
|
||
|
|
);
|
||
|
|
|
||
|
|
const limited = fixture({ requestLimit: 2 });
|
||
|
|
await limited.manager.requestOtp({
|
||
|
|
email: "owner@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
networkKey: "198.51.100.7",
|
||
|
|
});
|
||
|
|
await limited.manager.requestOtp({
|
||
|
|
email: "owner@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
networkKey: "198.51.100.7",
|
||
|
|
});
|
||
|
|
const denied = await limited.manager.requestOtp({
|
||
|
|
email: "owner@example.invalid",
|
||
|
|
deviceId: "ios-device-001",
|
||
|
|
networkKey: "198.51.100.7",
|
||
|
|
});
|
||
|
|
assert.equal(denied.accepted, false);
|
||
|
|
assert.equal(denied.error, "rate_limited");
|
||
|
|
assert.equal(limited.mail.length, 2);
|
||
|
|
});
|